Merge pull request #948 from alirezarezvani/claude/humanizer-skill-audit-plugin-hocj85

feat(engineering): human-gate — audit of petergyang/human-review + batched human review as a verification artifact
This commit is contained in:
Alireza Rezvani 2026-08-21 11:04:49 +02:00 committed by GitHub
commit 4d7df8adeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 3498 additions and 12 deletions

View file

@ -8,7 +8,7 @@
"homepage": "https://github.com/alirezarezvani/claude-skills",
"repository": "https://github.com/alirezarezvani/claude-skills",
"metadata": {
"description": "370 production-ready skills across 19 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 672 Python tools, 809 reference guides, 104 agents (cs-* + personas), 120 slash commands across 92 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"description": "371 production-ready skills across 19 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 675 Python tools, 812 reference guides, 104 agents (cs-* + personas), 120 slash commands across 93 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"version": "2.11.2"
},
"plugins": [
@ -1940,6 +1940,26 @@
"collab-proof"
],
"category": "engineering"
},
{
"name": "human-gate",
"source": "./engineering/human-gate",
"description": "Human-verification gate for an agent loop: builds a single-file review page (the page itself makes no network request; a reviewed HTML artifact's own https: assets still load), collects batched feedback as structured batch.v1 data instead of chat prose, and refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed. Non-blocking, headless-guarded, round-capped. Stdlib-only.",
"version": "1.0.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"keywords": [
"human-in-the-loop",
"review-gate",
"sign-off",
"agent-loop",
"verification",
"batched-feedback",
"human-gate"
],
"category": "engineering"
}
]
}

7
.gitignore vendored
View file

@ -63,3 +63,10 @@ tests/
# Autoresearch agent workspace
.autoresearch/
.idea/
# human-gate generated artifacts (review pages + local gate state).
# The review page is a disposable viewing surface rebuilt on every `open`;
# the sidecar (<artifact>.review.md) is deliberately NOT ignored — it is the
# reviewer's feedback and belongs next to the artifact in git.
*.review.html
.human-gate/

View file

@ -5,6 +5,161 @@ All notable changes to the Claude Skills Library will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — human-gate: batched human review as a verification artifact (this PR)
### Audited — `petergyang/human-review`
Public audit record at `audit/human-review-2026-08/AUDIT.md`. Upstream (npm
`human-review@0.6.0`, MIT © Peter Yang) is a ~5,200 LOC Node application that opens
an HTML/Markdown file or localhost page in the browser for direct editing and
anchored comments, then ships the batch back to the agent as JSON. **Verified: its
own test suite passes 90/90.** Security posture is better than most local-server
tools — loopback-only bind, DNS-rebinding `Host` check, constant-time token compare,
realpath-checked traversal guard, a deliberately inert Markdown renderer, and a
45-minute idle self-shutdown.
**Verdict: do not vendor, do adopt the pattern.** Node 20 + an npm runtime dependency
fails the same stdlib-only test that kept the heavier `skillopt` package out in
v2.11.2. Seven findings recorded, three material: **F1 (HIGH)** the skill instructs
the agent to run unpinned `npx -y human-review`, so every invocation may fetch and
execute a newly published version; **F2 (MED)** "do not end your turn" plus re-poll
on timeout, with no headless guard and no retry cap — the AR5 loop-discipline gap
`audit/engineering-agentic-2026-07/` already named as repo-wide; **F3 (MED)** only
`/api/*` is token-gated, not `/artifact/<key>` or `/s/<id>`.
Also worth stating plainly: despite the name, this is **not** a humanizer. It is
human *approval*, not human *voice* — no overlap with `engineering/behuman` or
`marketing-skill/content-humanizer`.
### Added — `engineering/human-gate`
Conceptual derivation (no upstream code copied), built to this repo's conventions:
three stdlib-only Python scripts, no server, no socket, no network fetch.
- **`review_page_builder.py`** — Markdown/HTML → single-file review page with every
block anchored (`data-hg="b7"`). **Zero network requests** — no CDN, no fonts, no
Prism; ~11 KB, opens over `file://`. Markdown is rendered by a stdlib subset parser
that escapes before applying inline markup and scheme-allowlists every href;
HTML input is re-emitted through `html.parser` with `<script>`/`<style>`/`<head>`
dropped and top-level block elements tagged. Review UI is vanilla JS with
localStorage persistence and an export that writes the sidecar.
- **`feedback_parser.py`** — sidecar Markdown → `batch.v1` JSON. Severities
BLOCKER/MAJOR/MINOR/NIT (matching `markdown-html/md-review`, from Google's
code-review guidance) plus EDIT/NOTE/APPROVE. Verifies every quote against the real
file and reports mismatches rather than swallowing them. Strips HTML comments so an
example written inside one cannot parse as a real sign-off.
- **`human_gate.py`** — `open`/`status`/`collect`/`close`/`reset` state machine with
atomic writes and `0700`/`0600` state permissions. Gate rules **G1G7**: refuses to
close with no collected round, an open BLOCKER/MAJOR, an unnamed reviewer, a sidecar
changed after collection, an exhausted round cap (exit 5 = escalate, never pass), a
waiver without a recorded reason, or a round carrying unresolved integrity problems.
Waivers store both the reason and every refusal they overrode.
**Four more fixes came out of a second PR review round**, each reproduced before fixing:
**(a)** the HTML artifact path re-emitted attributes verbatim, so a reviewed draft containing
`<img src=x onerror=...>`, `<a href="javascript:...">` or an `<iframe>` executed inside the
review page — the Markdown path had `_safe_href` scheme-allowlisting all along and the HTML
path had nothing. `sanitize_attrs()` now drops `on*`/`srcdoc`/`srcset`, runs every URL
attribute through the same allowlist (control characters stripped first, so `java\tscript:`
cannot smuggle a scheme), and `DROP_TAGS` removes `iframe`/`object`/`embed`/`base`. Legitimate
`https:` links and relative images survive. **(b)** `verify_quotes()` compared a browser
selection (rendered text) against raw markup, so quoting a sentence containing `**bold**` or a
link failed — and since G7 made that blocking, it refused a legitimate close. It now matches
against raw *or* a rendered-text projection, while a genuinely fabricated quote is still
caught. **(c)** `state["waiver"]` was never cleared, so a clean unwaived round N+1 still
printed round N's waiver reason — in a tool whose premise is an honest record, that is its own
integrity bug. **(d)** `status` returned 4 for both "no sidecar yet" and "collected, blockers
open"; the blocked case now returns 2, matching `close`, so an agent can branch on the exit
code alone (0 clear · 2 blocked · 3 collect · 4 nothing yet).
**A fifth round found two more.** `state_dir()` anchored gate state to `os.getcwd()` while
keying it by the artifact's realpath, so an agent whose shell cwd drifted between turns
silently started from empty state — `close` from a subdirectory reported G1 "nobody has
looked at this" for a round that really was collected. It failed closed rather than falsely
passing, but it lost real feedback; state now follows the artifact, the same way the sidecar
and review page already do (`--state-dir` still wins). Separately, `build_page()` substituted
`__CONTENT__` before `__TITLE__`/`__CONFIG__`, so reviewing a document that mentions those
tokens — this skill's own docs, for instance — re-substituted inside the inserted body and
injected the entire JSON config into the visible page. All three slots now fill in a single
`re.sub` pass, and the Markdown `--sample` fixture carries the token text so the case is
guarded. Minor: `--waive` with nothing to waive now says so instead of silently no-opping.
**A fourth round found the gate itself was one flag away from opt-out.** `--waive` applied to
whatever `gate_refusals()` returned — including **G1, "no review round has been collected"**
so an agent could close having had no review at all by supplying any reason string. That is the
most tempting shortcut under time pressure and it defeats the skill's entire premise, so G1 is
now **unwaivable**: a waiver accepts objections a reviewer raised, it cannot manufacture a
review that never happened. Waiving a genuine objection still works. Same round: inline
`style` joined `DROP_ATTRS` (a `background-image:url(https://…)` beacons the reviewer's IP on
open with no script involved, breaking the stated no-network property — and the `<style>` tag
was already dropped, so keeping the attribute was inconsistent too); Markdown `![alt](url)`
now renders a real scheme-checked `<img>` instead of leaking a stray `!` before a link (which
also made `_safe_href(image=True)` dead code on that path); an unterminated `<script>` now
emits a diagnostic instead of silently truncating the body; and raw-HTML `target="_blank"`
anchors get the same `rel="noreferrer noopener"` the Markdown path already added.
**A third review round found the HTML path was broken outright.** `meta`, `link` and
`base` are void elements: `html.parser` fires `handle_starttag` for them but never a
matching `handle_endtag`. Because they were also in `DROP_TAGS`, each bare `<meta charset>`
incremented the skip counter permanently, so every real HTML5 document — anything with a
charset meta or a stylesheet link in `<head>` — swallowed its entire body and reported
"No reviewable blocks". The documented landing-page use case simply did not work; earlier
HTML fixtures happened to use `<title>`/`<style>` only, which is why three rounds missed it.
Void drop-tags no longer touch the counter. `--sample` now builds **both** a Markdown and a
full-DOCTYPE HTML fixture, asserts the expected block count for each, exits 2 on regression,
and writes to a temp dir so a sample run cannot litter the caller's cwd. Same round:
`xlink:href` joined the URL allowlist (SVG anchors still honour it, so
`<svg><a xlink:href="javascript:...">` bypassed the plain `href` check), and `status` now
previews **every** gate rule through a shared `gate_refusals()` — previously it looked only
at blocking items, so a round with no named reviewer reported 0 while `close` refused on G3.
**A sixth round caught the void-element fix having been only half-applied, and a forgery
route through the artifact itself.** The round-three commit message claimed "meta, link and
base are void elements", but only `meta` and `link` reached `VOID` — so `<base href="/">`,
which sits in the `<head>` of a great many real pages, still swallowed the whole body and
returned "No reviewable blocks". `VOID` is now the complete HTML spec list rather than a
hand-picked subset, and `SAMPLE_HTML` carries a `<base>` tag so the regression gate would
catch a third recurrence. Separately: a reviewed HTML artifact carrying its own
`data-hg="..."` attribute kept it, and the builder appended a second — browsers honour the
*first*, and attribute values may contain raw newlines, so a crafted artifact could inject
a forged `## APPROVE` heading into the exported sidecar. That is the same silent-false-approval
failure G7 exists to prevent, arriving through the artifact instead of the sidecar. Reserved
attributes (`data-hg`) and reserved element ids (the page's own `doc`, `items`, `reviewer`,
`export`, …) are now stripped from reviewed HTML before anchoring.
**G7 came out of the first PR review round** and closes a real hole: a mistyped severity heading
(`## BLOKCER`) silently downgrades to `NIT`, so before this a reviewer's genuine blocker
could be lost to a typo and `close` would still exit 0. Reproduced, then fixed — the
parser's integrity problems (unknown severity, EDIT with no replacement text, a quote
that is not in the target file) are now closer-blocking rather than advisory prose.
Problems that already have their own rule (G2, G3) are filtered so they are not
double-reported.
**Loop discipline — the deliberate inversion of upstream.** There is no blocking poll:
`status` returns immediately, `open` detects a headless host (`CI`, SSH, no `DISPLAY`)
and says so rather than sending the agent to wait at a browser that will never appear,
and rounds are capped with escalation on exhaustion. The sidecar is plain, hand-writable
Markdown, so the loop still closes over SSH and in CI where no browser exists.
**Optional bridge**, opt-in and asked-first: `npx -y human-review@0.6.0` — always
pinned, never bare. It changes the editor; the gate still governs closure.
Ships 3 references citing 78 sources each (Bainbridge *Ironies of Automation*,
Parasuraman & Riley, Fagan inspection, Wiegers, Weinberg, *SWE at Google* ch. 9,
W3C Web Annotation `TextQuoteSelector`, Conventional Comments, Klein pre-mortem,
Nygard *Release It!*), a `batch.v1` JSON schema, a worked sidecar example,
`cs-human-gate` agent, and `/cs:human-gate`. SKILL.md is a full PASS on the
write-a-skill 6-item checklist; description validator PASS.
### Changed — counters
Merged on top of `book-to-skill`, which landed in `dev` while this branch was open:
skills 363 → 364, tools 663 → 666, refs 746 → 749, agents 103 → 104,
commands 118 → 119, plugins 89 → 90, engineering row 85 → 86
(derived via `scripts/derive_counters.py --check`).
---
## [Unreleased] — book-to-skill: document → knowledge-base skill → plugin (this PR)
### Added — `engineering/book-to-skill`

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# Claude Code Skills & Plugins — Agent Skills for Every Coding Tool
**370 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
**371 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
The most comprehensive open-source library of Claude Code skills and agent plugins — also works with OpenAI Codex, Gemini CLI, Cursor, and 9 more coding agents. Reusable expertise packages covering engineering, DevOps, marketing (incl. AEO — Answer Engine Optimization for LLM citation), security (PreToolUse hooks), compliance, C-level advisory (incl. founder-mode CFO/CMO/CRO/CPO/COO/CHRO/CISO/GC/CDO/CAIO/CCO/VPE personas + 21 /cs:* slash commands), productivity (capture/email/reflect/weekly-review/deep-work/meetings), an academic research stack (litreview/grants/dossier/patent/syllabus/pulse/notebooklm/deep-research + hybrid router), and enterprise Research Operations (clinical-research/research-finance/market-research/product-research, v2.9.0).
@ -10,10 +10,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
[^vibe]: Mistral Vibe is also **BYO-sync tier**: the repo ships a pre-generated `.vibe/skills/claude-skills/` tree, run `./scripts/vibe-install.sh` once locally to install into `~/.vibe/skills/`. Same agentskills.io SKILL.md standard — no format conversion. Docs: <https://docs.mistral.ai/mistral-vibe/agents-skills>.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![Skills](https://img.shields.io/badge/Skills-370-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-104-blue?style=for-the-badge)](#agents)
[![Skills](https://img.shields.io/badge/Skills-371-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-105-blue?style=for-the-badge)](#agents)
[![Personas](https://img.shields.io/badge/Personas-7-purple?style=for-the-badge)](#personas)
[![Commands](https://img.shields.io/badge/Commands-120-orange?style=for-the-badge)](#commands)
[![Commands](https://img.shields.io/badge/Commands-121-orange?style=for-the-badge)](#commands)
[![Stars](https://img.shields.io/github/stars/alirezarezvani/claude-skills?style=for-the-badge)](https://github.com/alirezarezvani/claude-skills/stargazers)
[![SkillCheck Validated](https://img.shields.io/badge/SkillCheck-Validated-4c1?style=for-the-badge)](https://getskillcheck.com)
@ -26,10 +26,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
Claude Code skills (also called agent skills or coding agent plugins) are modular instruction packages that give AI coding agents domain expertise they don't have out of the box. Each skill includes:
- **SKILL.md** — structured instructions, workflows, and decision frameworks
- **Python tools** — 672 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 809 templates, checklists, and domain-specific knowledge files
- **Python tools** — 675 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 812 templates, checklists, and domain-specific knowledge files
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 672 Python tools run anywhere Python runs.
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 675 Python tools run anywhere Python runs.
### Skills vs Agents vs Personas
@ -157,7 +157,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
| Domain | Skills | Highlights | Details |
|--------|--------|------------|---------|
| **🔧 Engineering — Core** | 53 | Architecture, frontend, backend, fullstack, QA, DevOps, SecOps, AI/ML, data, Playwright Pro (test gen, flaky fix, migrations), self-improving agent (auto-memory curation), security suite, a11y audit, **named-persona-adversarial-review** (review via named engineering philosophies), **embedded-iot-mentor** (MCU/board selection, firmware-reuse-first, breadboard-MVP discipline) | [engineering-team/](engineering-team/) |
| **⚡ Engineering — POWERFUL** | 87 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline) | [engineering/](engineering/) |
| **⚡ Engineering — POWERFUL** | 88 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline), **human-gate** (batched human review as a structured artifact + a gate that refuses to close on open blockers) | [engineering/](engineering/) |
| **🎯 Product** | 17 | Product manager, agile PO, strategist, UX researcher, UI design, landing pages, SaaS scaffolder, analytics, experiment designer, discovery, roadmap communicator, code-to-prd, apple-hig-expert | [product-team/](product-team/) |
| **📣 Marketing** | 49 | 8 pods: Content, SEO + AEO (`aeo` — E-E-A-T audit, citation tracking across 5 LLMs) + local (`local-seo-manager` — GBP/NAP/Map-Pack), CRO, Channels, Growth, Intelligence, Sales + `business-name-fit` (cross-cultural naming) + context foundation + orchestration router | [marketing-skill/](marketing-skill/) |
| **🚀 Productivity** | 12 | `capture` (brain-dump-to-action), `email` pair (inbox-setup + inbox-triage), `reflect` (journal), `handoff` (Matt Pocock-inspired), `andreessen` (market-first decision mode), `roast` (5-angle idea panel → GO/RESHAPE/KILL), `fable-goal` (ramble → autonomous /goal prompt), `weekly-review` (GTD loop with refusal gate), `deep-work` (time-blocking + shallow-work budget), `meetings` (cost gate + agenda + action items), `swedish-mentor` (CEFR-leveled Swedish learning paths) | [productivity/](productivity/) |
@ -358,7 +358,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI,
No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release.
**Are the Python tools dependency-free?**
Yes. All 672 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
Yes. All 675 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
**How do I create my own Claude Code skill?**
Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide.

View file

@ -0,0 +1,179 @@
# Audit — `petergyang/human-review`
**Upstream:** https://github.com/petergyang/human-review
**Version audited:** npm `human-review@0.6.0` (clone at audit time, `main`)
**License:** MIT © 2026 Peter Yang
**Auditor:** claude-skills maintainers · **Date:** 2026-08-09
**Purpose:** decide whether/how to bring this capability into the claude-skills ecosystem.
---
## 0. Bottom line
🔴 **Do not vendor verbatim.** 🟢 **Do adopt the idea.**
The upstream is a well-engineered, genuinely careful piece of software — 90/90 tests pass,
and its security model is better than most local-server tools (DNS-rebinding defense,
constant-time token compare, realpath-checked traversal guard, deliberately inert Markdown
renderer). The problem is not quality. It is **fit**: it is a 5,164-LOC Node application with
an npm runtime dependency, and this repository's stated hard rule is stdlib-Python-only,
no build systems, no dependencies. Vendoring it would make it the first Node runtime *and*
the first npm dependency in the tree.
The transferable asset is the **pattern**, not the package: *human feedback as a batched,
structured, machine-parseable artifact — and an agent loop that blocks on it.*
---
## 1. Identity — it is not a "humanizer"
Worth stating plainly, because the name invites the confusion:
`human-review` is a **human-in-the-loop visual review harness**. It opens an HTML/Markdown
file or a localhost page in the browser, lets a person edit text directly and leave anchored
comments, and ships the whole batch back to the agent as JSON.
It is **not** an AI-slop remover. This repo already has two of those, and neither overlaps:
| Existing skill | What it does | Overlap with human-review |
|---|---|---|
| `engineering/behuman` | Self-mirror loop so the *model* writes less robotically | None |
| `marketing-skill/skills/content-humanizer` | Rewrites AI-sounding copy into brand voice | None |
Correct neighbourhood for this capability is the **agent-loop / review-gate** family:
`engineering/agent-harness`, `markdown-html/md-review`, `engineering/grill-me`.
---
## 2. What it actually is
- npm package, `type: module`, Node ≥ 20. One runtime dependency (`marked@^18.0.7`), one dev
dependency (`jsdom`). Two GitHub Actions workflows (test, publish).
- 5,164 LOC across 19 files in `src/`. Largest: `sdk.js` (1,464), `server.js` (926),
`chrome-client.js` (836), `chrome.css` (456).
- Four CLI verbs: open (default), `poll`, `status`, `setup`.
- Architecture: CLI → spawns a **detached local HTTP server** on 127.0.0.1 → opens the browser
at `/s/<session>` → server serves the reviewed artifact in a sandboxed iframe with an
injected editing SDK → user edits/comments → **Send** → agent's blocking `poll` returns a
JSON batch → agent applies to source → `poll --ack` clears and waits again.
### The loop, as SKILL.md teaches it
1. Agent writes/updates the file.
2. `npx -y human-review path/to/file.html`
3. `npx -y human-review poll path/to/file.html --timeout 600`**blocks**; agent is told not
to end its turn.
4. Apply the batch; `poll --ack --timeout 600`; repeat until the user says stop.
---
## 3. Verification performed
| Check | Result |
|---|---|
| `npm ci` | clean |
| `npm test` (`node --test`) | 🟢 **90/90 pass**, 0 fail, 4.14 s |
| Source read of auth/traversal/render paths | see §4 |
Test suite includes dedicated `security.test.js`, `feedback-safety.test.js`,
`frame-policy.test.js`, and a case named *"resolveAsset refuses a symlink that points outside
the directory."* Claims in the README are backed by tests, not just prose.
---
## 4. Security audit
### 4.1 Controls that are genuinely good (verified in source)
| # | Control | Evidence |
|---|---|---|
| S1 | Binds loopback only | `server.listen(port, "127.0.0.1")``server.js:914` |
| S2 | DNS-rebinding defense — `Host` must be `127.0.0.1:<port>` or `localhost:<port>`, else refused | `server.js:448-450` |
| S3 | Per-run 128-bit token on every `/api/*` route; **header-only** (deliberately not a query param, so it cannot leak into logs or shell history); constant-time compare with length pre-check | `server.js:106`, `461-466` |
| S4 | State dir `~/.human-review` created `mode 0o700` | `paths.js` `ensureStateDir()` |
| S5 | Path traversal blocked by **both** lexical containment and `realpathSync` containment — a symlink cannot escape the reviewed file's directory | `state.js:277-306` |
| S6 | URL targets restricted to `localhost` / `127.0.0.1` / `[::1]`; embedded credentials rejected | `paths.js` `localUrl()` |
| S7 | Iframe sandbox: file/Markdown reviews get an **opaque origin** (no `allow-same-origin`), so a reviewed file cannot read sibling files through the artifact route; only real localhost apps keep their origin | `frame-policy.js` |
| S8 | Markdown renderer is **deliberately inert** — raw HTML inside Markdown is escaped to text; link/image hrefs are scheme-allowlisted (`http`/`https`/`mailto`; `data:image/*;base64` for images only) | `markdown.js` |
| S9 | 24 MB body cap; pasted-image filenames are **generated**, never user-supplied; upload MIME allowlisted to png/jpeg/gif/webp | `server.js:36`, `660-688` |
| S10 | Detached server **self-terminates after 45 min idle** | `server.js:39`, `892` |
This is a more disciplined threat model than the average "it's just localhost" tool.
### 4.2 Findings
| # | Sev | Finding |
|---|---|---|
| **F1** | 🔴 **HIGH** | **Unpinned remote code execution by design.** SKILL.md instructs the agent to run `npx -y human-review …` on *every* invocation. `-y` auto-approves the install and the spec is unpinned, so each run may resolve and execute a **newly published** version of the package. The user never approves that upgrade. For a library whose hard rule is zero-dependency, this is the blocking issue. *Mitigation for any adoption: pin an exact version, vendor the code, or require a preinstalled binary and refuse to auto-fetch.* |
| **F2** | 🟠 **MED** | **Unbounded agent poll loop; no headless guard.** SKILL.md says "Do not end your turn while it is waiting" and, on `{"status":"timeout"}`, "run the same poll command again to keep waiting." There is no environment check and no maximum retry count. On a headless/CI/remote agent — no browser, no human at the keyboard — this becomes an indefinite loop burning wall-clock and context. This is precisely the **AR5 loop-discipline gap** this repo's own `audit/engineering-agentic-2026-07/` named as its repo-wide weakness. |
| **F3** | 🟠 **MED** | **Not every route is token-gated.** Only `/api/*` requires the token (`server.js:461`). `/artifact/<key>` and `/s/<id>` do not. Any other local process — or a browser page that learns the port and the 16-hex page key — can read the reviewed file's rendered content and its sibling assets. Keys are 64-bit and the `Host` check (S2) blocks the remote-rebinding path, so this is a local-trust-model gap rather than a remote hole, but it should not be silent. |
| **F4** | 🟠 **MED** | **`setup --global` mutates three global agent configs.** It writes `SKILL.md` into `~/.claude/skills/`, `~/.codex/skills/`, and `~/.agents/skills/`, and appends a block to the project's `AGENTS.md`. Installing a skill by asking an agent to run a CLI that rewrites user-global config is a larger footprint than the README's one-line install implies. (Note: this repo `.gitignore`s `AGENTS.md`, so such an append would be invisible to `git status` here.) |
| **F5** | 🟡 **LOW** | **Silent writes to the user's HTML.** README: *"For HTML files, direct edits and resizes save automatically."* Confirmed at the `action === "save"` route (`server.js:690+`). The reviewed file is overwritten with serialized browser HTML with no explicit confirmation step. Upstream correctly refuses this for Markdown and localhost targets, but for HTML it is a real, undeclared write path. |
| **F6** | 🟡 **LOW** | **One third-party parser in the trust path.** `marked` parses user Markdown. Well-maintained, and upstream mitigates hard via the inert renderer (S8), but it is still a dependency this repo currently has zero of. |
| **F7** | ⚪ **INFO** | **Lingering listener, self-limiting.** The server is spawned `detached` + `unref()`'d and outlives the CLI, with `{port, pid, token}` persisted in `~/.human-review/server.json`. There is no `stop`/`shutdown` verb in the CLI help. Mitigated by S10's 45-minute idle exit — noting it only because "no way to stop it" is the natural first read of the code. |
---
## 5. Convention fit with `claude-skills`
| Repo rule (CLAUDE.md) | Upstream | Fit |
|---|---|---|
| Python scripts, **standard library only** | Node 20, ESM, npm dep | ❌ |
| **No build systems or test frameworks** | `package.json`, `node --test`, 2 CI workflows | ❌ |
| Skill layout: `SKILL.md` + `scripts/` + `references/` + `assets/` | `src/*.js` + one `SKILL.md` | ❌ |
| No LLM calls in scripts | none | ✅ |
| Self-contained, no cross-skill dependencies | self-contained | ✅ |
| MIT + attributable upstream | MIT © Peter Yang | ✅ |
| `plugin.json` schema | absent (npm package, not a CC plugin) | ⚠️ must be authored |
**Why the vendoring precedent does not transfer.** `loop-library/` and
`engineering/skillopt-sleep/` were vendorable *because they were stdlib-Python-only with zero
third-party deps* — that is stated outright in skillopt-sleep's attribution note as the reason
the heavier `skillopt` training package was deliberately left behind. `human-review` fails the
same test that filtered `skillopt` out.
---
## 6. Gap analysis — what it would actually add
No skill in the tree does batched human-in-the-loop review. The nearest three all miss it:
- **`markdown-html/md-review`** — renders a code review *to* HTML. One-way. No feedback channel.
- **`engineering/grill-me`** — interrogates a plan through chat prose. No artifact surface.
- **`engineering/agent-harness`** — has a real loop controller (`loop_controller.py`) with
`init/next/record/verify/close`, but AR4 verification is **machine-only**. There is no
human-verification lane, and `close` cannot represent "a person looked at this and approved it."
That last one is the actual insertion point. The novel, portable idea is:
> **Structured human feedback as a first-class, machine-parseable verification artifact —
> not chat prose — with a loop that blocks on it and a gate that refuses to close without it.**
That idea is fully expressible in stdlib Python. The 5k LOC of contenteditable/anchoring/
block-drag machinery is what makes upstream *pleasant*; it is not what makes it *valuable*.
---
## 7. Recommendation
Adopt the pattern; do not adopt the package. Three viable shapes, in the build decision doc.
Whatever ships must carry, at minimum:
1. **No unpinned network execution** (fixes F1) — nothing auto-fetched at run time.
2. **A headless guard and a hard iteration cap** on any wait loop (fixes F2), with a named
terminal state on exhaustion, matching `agent-harness` loop discipline.
3. **Token-gate every route**, not just `/api/*` (fixes F3), if a local server is used at all.
4. **No writes to user-global config** as an install side effect (fixes F4).
5. **No silent overwrite of a source file** — explicit confirm or write-to-copy (fixes F5).
6. **Attribution block** in `plugin.json` naming upstream, its author, and its MIT license,
per the `caveman`/`grill-me`/`skillopt-sleep` precedent.
---
## 8. Attribution
Upstream is MIT-licensed. Any derived work in this repo must preserve
`Copyright (c) 2026 Peter Yang`, link the source repository, and state in `plugin.json`'s
`attribution` block what was taken (pattern vs. code) and what was changed. Nothing in this
audit's recommended path copies upstream source, so the derivation is *conceptual* — the
attribution should say exactly that rather than implying a code vendor.

View file

@ -0,0 +1,12 @@
{
"source": {
"build_pattern": "Conceptual derivation, not a code vendor -- the batched-review pattern from petergyang/human-review rebuilt as three stdlib-only Python tools (review page builder, sidecar parser, gate state machine) under this repo's no-dependency conventions, with a closing gate and loop discipline upstream does not have. Audit record: audit/human-review-2026-08/AUDIT.md",
"distinct_from": "engineering/agent-harness (machine verification and loop control -- human-gate is the human lane it lacks; pair them); markdown-html/md-review (renders a code review TO html, one-way, no feedback channel -- use when YOU are the reviewer, human-gate when someone else is); engineering/grill-me (interrogates a plan through conversation, before an artifact exists); engineering/ship-gate (pre-production technical audit, not human sign-off); marketing-skill/content-humanizer and engineering/behuman (make AI text sound human -- human APPROVAL, not human VOICE, despite the name)"
},
"attribution": {
"upstream": "https://github.com/petergyang/human-review",
"upstream_author": "Peter Yang",
"upstream_license": "MIT",
"derivation_note": "CONCEPTUAL derivation -- no upstream source code is copied or vendored. Upstream is a ~5,200 LOC Node application (Node >= 20, npm dependency 'marked', detached local HTTP server, browser chrome UI); this repo's conventions are stdlib-Python-only with no build system and no dependencies, which is the same test that kept the heavier skillopt package out in v2.11.2. What was taken is the pattern: human feedback as a batched, anchored, machine-parseable artifact rather than chat prose, and the verbatim-edit rule (a reviewer's `after` text is carried across exactly, applied to the generating source as well as the rendered artifact). What was deliberately built differently, and why, is recorded per-finding in audit/human-review-2026-08/AUDIT.md: no unpinned `npx -y` execution (F1 -- upstream instructs the agent to auto-fetch and run the latest published version on every invocation); no blocking poll and an explicit headless guard plus round cap with escalation (F2 -- upstream tells the agent not to end its turn and to re-poll indefinitely on timeout, with no terminating condition on a browserless host); no local HTTP server or socket at all, so the partially-ungated-route question does not arise (F3); no writes to user-global agent config as an install side effect (F4); no silent in-place overwrite of the reviewed file (F5); no third-party parser in the trust path (F6). Added beyond upstream: a closing gate (G1-G7) that refuses to report done without a collected round, a named reviewer, and resolved blocking items, with explicit recorded waivers. G7 was added during PR review after a reproduced hole: a mistyped severity heading (`## BLOKCER`) silently downgrades to NIT, so a reviewer's genuine blocker could be lost to a typo while close still exited 0; the parser's integrity problems are now closer-blocking rather than advisory prose. The optional bridge documented in SKILL.md invokes upstream PINNED (`npx -y human-review@0.6.0`) and is opt-in only; the gate still governs closure. Upstream's own test suite was run during the audit: 90/90 passing."
}
}

View file

@ -0,0 +1,15 @@
{
"name": "human-gate",
"description": "The human-verification lane for an agent loop. Builds a single-file HTML review page for a Markdown or HTML artifact (opens over file://, makes no network request of its own, and sanitizes reviewed HTML -- on* handlers, javascript: URLs and iframes are dropped so a draft cannot execute inside the page), collects batched feedback as a structured batch.v1 artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, the sidecar changed after collection, the round carries unresolved integrity problems (a mistyped severity silently downgrades to NIT, so a real blocker can be lost to a typo), or nobody has reviewed at all. Non-blocking by design: no poll loop, a headless guard, a round cap, and escalation on exhaustion. Feedback lands in a hand-writable Markdown sidecar, so the loop still works over SSH and in CI where no browser exists. Use before shipping a plan, spec, RFC, report, or any irreversible action, or when the user says 'let me review that', 'get sign-off', or 'don't ship until I've seen it'. The human counterpart to agent-harness's machine verification -- NOT a content humanizer (see content-humanizer / behuman).",
"version": "1.0.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering/human-gate",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills/human-gate"
]
}

View file

@ -0,0 +1,151 @@
# human-gate
**The human-verification lane for an agent loop.**
Machine checks answer *"do the tests pass?"*. This answers the other question:
**has a person actually looked at this, and are their objections resolved?**
```sh
S=engineering/human-gate/skills/human-gate/scripts
python3 $S/human_gate.py --sample # the whole loop, refusals included, in ~1s
```
---
## Why
Human feedback usually arrives as chat prose:
> Change the third paragraph, cut the second card, rewrite the CTA, and the table numbers
> look stale.
Four instructions in one blob. Nothing enumerates them, so nothing detects a miss.
"The third paragraph" drifts as the document changes. Severity is invisible — a wrong
number and a style preference look identical. And round 2 cannot tell what round 1 asked
for.
**human-gate makes feedback an artifact instead of a message:** anchored to blocks,
severity-graded, countable, diffable — and read by a gate that either passes or names
exactly what is still open.
## The loop
```sh
python3 $S/human_gate.py open plan.md --launch # build review page, start round N
# → hand over path, END YOUR TURN
python3 $S/human_gate.py status plan.md # non-blocking: 0 clear · 2 blocked · 3 collect · 4 none
python3 $S/human_gate.py collect plan.md --output json # batch.v1 — apply every item
python3 $S/human_gate.py close plan.md # exit 2 = you are NOT done
```
## What's inside
| File | Purpose |
|---|---|
| `scripts/review_page_builder.py` | Markdown/HTML → single-file review page, every block anchored. The page itself makes **zero network requests** — no CDN, no fonts, no server, no socket; ~11 KB, opens over `file://`. Reviewed HTML is sanitized first: `on*` handlers, `javascript:` URLs (including control-character-smuggled ones), `xlink:href`, `srcdoc`/`srcset`, inline `style` (a `background-image:url(...)` beacons on open), and `iframe`/`object`/`embed`/`base` are dropped, so a draft cannot execute inside the page. A reviewed HTML artifact's own `https:` images still load, as they must for the review to be faithful, and so do protocol-relative (`//host/x`) URLs — neither can execute. Both allowances are pinned by `--sample` assertions, not just by prose. |
| `scripts/feedback_parser.py` | Review sidecar → `batch.v1` JSON, with quote verification against the real file. |
| `scripts/human_gate.py` | State machine + the gate. `open`/`status`/`collect`/`close`/`reset`. |
| `references/human_in_the_loop_canon.md` | Bainbridge, Fagan, Wiegers, Weinberg, Google SWE ch.9, Klein pre-mortem. |
| `references/feedback_batching.md` | W3C Web Annotation selectors, Conventional Comments, GitHub batched-review model. |
| `references/review_loop_discipline.md` | Why there is no blocking poll, why rounds are capped, why exhaustion escalates. |
| `assets/batch.v1.schema.json` | The feedback batch contract. |
| `assets/example_review_sidecar.md` | Worked example of the hand-writable sidecar. |
Stdlib-only Python. No dependencies, no build step.
## The sidecar is hand-writable — on purpose
The review page exports it, but any reviewer with an editor can write it directly. That
is what keeps the loop working over SSH and in CI, where no browser exists:
```markdown
# Review feedback: plan.md
<!-- human-gate:v1 target=plan.md round=1 -->
reviewer: reza
## BLOCKER b2
> We expect a 40% lift in activation.
No source for 40%, and it drives the whole plan. Cite it or cut it.
## EDIT b7
- before: The team will endeavour to deliver incremental value
+ after: The team ships one usable slice per week
## NOTE
Structure is right. Fix the blocker and this is good to go.
```
Severities are **BLOCKER / MAJOR / MINOR / NIT** — the same ladder
`markdown-html/md-review` uses, from Google's *Code Review Developer Guide*. Plus **EDIT**
(a verbatim replacement), **NOTE** (unanchored), **APPROVE** (explicit sign-off).
## The gate
| Rule | Refuses to close when |
|---|---|
| **G1** | no review round collected — nobody has looked |
| **G2** | a BLOCKER or MAJOR is still open |
| **G3** | no named reviewer — approval belongs to a person |
| **G4** | the sidecar changed after the last collect |
| **G5** | round cap exhausted → **escalate**, never pass |
| **G6** | a waiver is used without a recorded reason — and **G1 is never waivable** |
| **G7** | the round carries unresolved integrity problems — a mistyped severity (`## BLOKCER`) silently downgrades to NIT, an EDIT has no replacement text, or a quote is in neither the raw source nor its rendered text |
Overrides are legitimate and must be explicit:
```sh
python3 $S/human_gate.py close plan.md --waive "reviewer on leave; CTO accepted risk in writing"
```
The reason and every refusal it overrode are stored in gate state. **`--waive` cannot
override G1**: it accepts objections a reviewer raised, it cannot manufacture a review that
never happened — otherwise the whole gate would be one flag away from opt-out.
## Loop discipline
There is deliberately **no blocking poll**. `status` returns immediately.
- Open a round, hand over the path, **end the turn**. State lives in `.human-gate/`.
- **Headless guard** — on CI, SSH, or no `DISPLAY`, `open` says so and skips the browser.
- **Round cap** (`--max-rounds`, default 5). Exhaustion exits **5 = ESCALATE**, not 0.
`open` records the cap in gate state and every later command inherits it, so
escalation can't drift with how a command happened to be typed; passing the flag
again renegotiates the cap and says so.
- **`status` previews the whole gate** — it shares `gate_refusals()` with `close`, so the two
can never disagree about whether the work is done.
Reasoning in `references/review_loop_discipline.md`.
## Design notes
Two things this plugin deliberately does **not** ship, and why:
- **No local HTTP server.** A file:// page plus a sidecar covers the review loop without
a listening socket, a token scheme, or a background process to reason about.
- **No rich block-drag / image-paste editor.** That is genuinely nice and genuinely
expensive — it is the bulk of upstream's ~5,200 LOC. If a reviewer wants it, the
optional pinned bridge in `SKILL.md` hands off to upstream, and the gate still governs
closure.
## Related
- **`engineering/agent-harness`** — machine verification and loop control. This is the
human lane it lacks. Pair them.
- **`markdown-html/md-review`** — renders a code review *to* HTML, one-way. Use when
*you* are the reviewer; use human-gate when someone else is.
- **`engineering/grill-me`** — interrogates a plan in conversation, before an artifact exists.
- **`marketing-skill/content-humanizer`**, **`engineering/behuman`** — make AI text sound
human. Different problem: this is human *approval*, not human *voice*.
## Attribution
The batched-review pattern derives from
[`petergyang/human-review`](https://github.com/petergyang/human-review) (MIT © 2026 Peter
Yang). **No upstream code is used** — this is a conceptual derivation with a deliberately
different design (stdlib Python instead of Node, no server, no network fetch, non-blocking
loop with a round cap, plus a closing gate upstream does not have).
The audit that drove those choices — including running upstream's own test suite
(**90/90 passing**) — is at
[`audit/human-review-2026-08/AUDIT.md`](../../audit/human-review-2026-08/AUDIT.md).

View file

@ -0,0 +1,104 @@
---
name: cs-human-gate
description: Runs the human-verification lane of an agent loop. Builds a single-file review page for a Markdown or HTML artifact, hands the reviewer a path and ends the turn (never blocks polling for a human), collects batched feedback as structured batch.v1 data, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use before shipping a plan, spec, RFC, report, or any irreversible action, and whenever the user says "let me review that", "get sign-off", or "don't ship until I've seen it".
skills: engineering/human-gate/skills/human-gate
domain: engineering
model: opus
tools: [Read, Write, Edit, Glob, Grep, Bash]
---
# Human Gate Agent
## Purpose
`cs-human-gate` is the part of the loop that refuses to let an agent mark its own homework.
`engineering/agent-harness` verifies what a script can check. This agent handles what no
script can: **has a person actually looked at this, and are their objections resolved?**
## Operating posture
You are not a reviewer. You are the **registrar** of someone else's review. Your value is
entirely in refusing to fudge the record.
- You never approve anything yourself.
- You never invent or infer a reviewer's name.
- You never report done while `close` exits 2.
- You never paraphrase a human's verbatim edit.
- You never sit in a blocking wait for a human.
## The loop you run
```
S=engineering/human-gate/skills/human-gate/scripts
1. open python3 $S/human_gate.py open <artifact> [--launch]
→ builds the review page, records round N
→ HAND OVER THE SIDECAR PATH, THEN END YOUR TURN
2. status python3 $S/human_gate.py status <artifact>
→ exit 3 = feedback waiting · exit 4 = nothing yet · non-blocking
3. collect python3 $S/human_gate.py collect <artifact> --output json
→ batch.v1: items, severities, counts, blocking total
→ apply EVERY item; EDIT `after` goes across VERBATIM
4. close python3 $S/human_gate.py close <artifact>
→ exit 0 = genuinely done · exit 2 = say what is still open
```
Run `python3 $S/human_gate.py --sample` to see the whole loop with its refusals.
## Decision rules
**When the user asks you to wait for their review** — do not. Explain once, briefly: their
review takes as long as it takes, a held-open turn burns context producing nothing, and the
state is on disk so nothing is lost. Give them the path. End the turn.
**When the host is headless** (`CI`, SSH, no `DISPLAY`) — `open` detects this and says so.
Hand over the sidecar path and note that they can write it by hand in any editor. Never
suggest launching a browser that will not appear.
**When rounds run out** (`--max-rounds`, default 5) — exit 5 is ESCALATE, not pass. Stop
iterating. Write a short summary of what is still contested and who disagrees about what,
and hand it to a human. An exhausted budget is an escalation.
**When two consecutive rounds produce only NITs** — the artifact is done. Say so. Do not
open a third round fishing for more.
**When the artifact is generated** (from MDX, a template, a script) — apply every edit to
the *source* as well, or the reviewer's fix disappears on the next build. Say which files
you touched.
**When the user wants to ship over an open blocker** — that is their call, and it is
legitimate. Record it properly:
`close <artifact> --waive "<their actual stated reason>"`. Never a bare force, never a
reason you invented on their behalf.
## Scaling the gate to the stakes
| Artifact | Posture |
|---|---|
| Internal draft, notes, a branch | Open a round if asked. NITs do not block. |
| Spec, plan, RFC others will build from | Hold G2 strictly. Named reviewer required. |
| External, irreversible, regulated | Require an explicit **APPROVE** item. Absence of blockers is not consent. |
## Voice
Blunt registrar, not a cheerleader. Lead with the verdict.
- ✅ "Gate refused: 2 blockers open from round 1 (b4 unsourced 40% claim, b9 missing Acme risk). Not done."
- ✅ "Round 2 collected — reviewer reza approved, 0 blocking. Gate passed."
- ✅ "Headless host. Here's the sidecar path — send it to whoever is reviewing. Ending my turn."
- ❌ "I've carefully reviewed the document and I think it looks great!"
- ❌ "The feedback has been addressed." *(without running `close`)*
## Boundaries
- **Not a content humanizer.** Despite the name, this is human *approval*, not human
*voice*. For voice → `marketing-skill/content-humanizer` or `engineering/behuman`.
- **Not a code reviewer.** For diffs → `markdown-html/md-review` or `code-reviewer`.
- **Not a plan interrogator.** For pressure-testing before an artifact exists →
`engineering/grill-me`.
- **Not a substitute for machine checks.** Pair with `engineering/agent-harness`; a green
ship-gate plus an open human-gate still means not done.

View file

@ -0,0 +1,128 @@
---
name: "cs-human-gate"
description: "/cs:human-gate — Get real human review on an artifact and prove it happened. Builds a single-file review page, collects batched feedback as structured data, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has looked at all. Non-blocking: hands over the path and ends the turn."
argument-hint: "[path to the .md or .html artifact] [optional: open|status|collect|close]"
---
# /cs:human-gate — Human Verification Gate
**Command:** `/cs:human-gate <artifact> [step]`
Machine checks answer *"do the tests pass?"*. This answers the other one:
**has a person actually looked at this, and are their objections resolved?**
## When to Run
- Before shipping anything external, irreversible, or regulated
- "Let me review that first" / "get sign-off" / "have someone check this"
- "Don't ship until I've seen it"
- You have applied feedback and are about to declare done
- A plan, spec, RFC, report, migration, or customer-facing artifact is ready
## When NOT to Run
- To make AI text sound human → `content-humanizer` / `behuman` (different problem entirely)
- To review a code diff yourself → `md-review` or `code-reviewer`
- To pressure-test an idea before any artifact exists → `grill-me`
- For machine-checkable verification → `agent-harness`
## Pre-flight
Refuse to proceed and say which is missing:
1. **Artifact exists** and is `.md` or `.html`.
2. **A named reviewer** is identified — a person, not "the team". The gate enforces this (G3).
3. **Round budget agreed** — default 5. An uncapped review loop is a way to avoid deciding.
4. **Stakes established** — reversible or not? One-way doors need an explicit APPROVE,
not merely an absence of blockers.
## Steps
```sh
S=engineering/human-gate/skills/human-gate/scripts
```
### 1. `open` — start a round
```sh
python3 $S/human_gate.py open "$ARTIFACT" --launch
```
Builds a single-file review page (zero network requests, opens over `file://`) and records
round N. Prints the sidecar path.
**Then end the turn.** Do not poll. On a headless host `open` detects it, skips the browser,
and tells you to hand over the path — the reviewer can write the sidecar by hand in any editor.
### 2. `status` — non-blocking check
```sh
python3 $S/human_gate.py status "$ARTIFACT"
```
| Exit | Meaning |
|---|---|
| 0 | collected and clear — `close` would pass |
| 2 | collected, but `close` would refuse — prints which rules, same code `close` uses |
| 3 | feedback waiting — collect it |
| 4 | nothing on disk yet — end the turn again |
Branch on the code alone: 0 clear · 2 blocked · 3 collect me · 4 nothing yet.
### 3. `collect` — read the batch
```sh
python3 $S/human_gate.py collect "$ARTIFACT" --output json
```
Emits `batch.v1`: every item with severity, block anchor, quote, and the blocking total.
Quotes are verified against the real file — a mismatch is reported, not swallowed.
**Apply every item.** `EDIT` items carry `after` across **verbatim** — that is the
reviewer's own wording, not a suggestion to paraphrase. If the artifact is generated from
a source, apply the edit there too or it disappears on the next build.
### 4. `close` — the gate
```sh
python3 $S/human_gate.py close "$ARTIFACT"
```
| Rule | Refuses when |
|---|---|
| G1 | no round collected — nobody has looked |
| G2 | a BLOCKER or MAJOR is still open |
| G3 | no named reviewer |
| G4 | the sidecar changed after the last collect |
| G5 | round cap exhausted → escalate |
| G6 | waiver used without a recorded reason — **G1 can never be waived** |
| G7 | the round carries unresolved integrity problems (mistyped severity, EDIT with no replacement, quote not in the file) |
**Exit 2 means you are not done.** Report what is open, not a summary that implies success.
Legitimate override, recorded:
```sh
python3 $S/human_gate.py close "$ARTIFACT" --waive "reviewer on leave; CTO accepted risk in writing"
```
## Output digest
Report back exactly this shape:
```
GATE: <PASSED | REFUSED | ESCALATE>
Reviewer: <name>
Rounds: <n> of <max>
Open: <BLOCKER/MAJOR items, by block id>
Applied: <what you changed, and in which source files>
Next: <the one action, or "none done">
```
## Try it
```sh
python3 engineering/human-gate/skills/human-gate/scripts/human_gate.py --sample
```
Runs the whole loop in a temp dir — including the refusals — in about a second.

View file

@ -0,0 +1,100 @@
---
name: human-gate
description: "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer)."
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: engineering
updated: 2026-08-09
---
# Human Gate
You are the part of the loop that refuses to let an agent mark its own homework.
Machine verification answers *"do the checks pass?"*`engineering/agent-harness` does that.
This answers what no script can: **has a person looked at this, and are their objections
resolved?** Feedback becomes a machine-parseable artifact rather than a message — anchored,
severity-graded, countable — and a gate either passes or names what is still open.
**Before starting**, establish: which artifact (`.md`/`.html`), who the named reviewer is (a
person, not "the team" — G3 enforces it), whether the work is reversible, and whether a human
is available now. Read `human-gate-context.md` first if it exists.
## The loop
```sh
S=engineering/human-gate/skills/human-gate/scripts
python3 $S/human_gate.py open plan.md --launch # build page, start round N → END YOUR TURN
python3 $S/human_gate.py status plan.md # non-blocking: 0 clear·2 blocked·3 collect·4 none
python3 $S/human_gate.py collect plan.md --output json # batch.v1 — apply every item
python3 $S/human_gate.py close plan.md # exit 2 = NOT done
```
`human_gate.py --sample` runs the whole loop, refusals included, in ~1s. It drives
`review_page_builder.py` (Markdown/HTML → single-file anchored page that makes no network
request of its own and sanitizes reviewed HTML — `on*`, `javascript:`, `iframe` dropped) and
`feedback_parser.py` (sidecar → `batch.v1`, quotes checked against raw *and* rendered text).
## The sidecar
Feedback lands in `<artifact>.review.md`. The page exports it; anyone can also write it by hand
in any editor — which keeps this working over SSH and in CI. Worked example and JSON contract
are in `assets/`.
```markdown
<!-- human-gate:v1 target=plan.md round=1 -->
reviewer: reza
## BLOCKER b2
> We expect a 40% lift in activation.
No source, and it drives the whole plan. Cite it or cut it.
```
Severities **BLOCKER / MAJOR / MINOR / NIT** (matching `markdown-html/md-review`, from Google's
code-review guidance), plus **NOTE**, **APPROVE**, and **EDIT** — a replacement the reviewer
already wrote, as `- before:` / `+ after:` lines.
## Gate rules
| | Refuses to close when | | |
|---|---|---|---|
| **G1** | no round collected | **G4** | sidecar changed after the last collect |
| **G2** | a BLOCKER or MAJOR is open | **G5** | round cap exhausted → **escalate**, never pass |
| **G3** | no named reviewer | **G6** | waiver used without a recorded reason |
| **G7** | the round carries unresolved integrity problems — a mistyped severity silently downgrades to NIT, so a real blocker can be lost to a typo | | |
Overrides must be explicit — `close plan.md --waive "<reason>"` — but **G1 is never waivable**:
a waiver accepts objections a reviewer raised; it cannot stand in for review happening.
## Hard rules
1. **Never report done while `close` exits 2.** Say what is open instead.
2. **Never invent a reviewer name** to satisfy G3. No reviewer *is* the finding.
3. **Never paraphrase an EDIT's `after`** — verbatim, or a human was silently overruled. Apply
it to whatever *generates* the artifact too, or it dies on the next build.
4. **Never block-poll for a human.** Hand over the path and end the turn; `open` detects a
headless host. Rounds are capped and exhaustion escalates.
5. **Never auto-fetch and run unpinned code.** The richer editor at `petergyang/human-review`
is opt-in, asked-first, and always pinned (`npx -y human-review@0.6.0`) — unpinned `npx -y`
runs whatever was published most recently. Its `poll` blocks and it rewrites HTML in place,
so wrap both. It changes the editor, never the gate. See `audit/human-review-2026-08/`.
6. **Never treat the review page as source of truth.** It is a viewing surface.
## Forcing questions
One at a time when scope is fuzzy: **Who, by name, signs off?** · **What would make them reject
it outright?** (name it before reading — Klein's pre-mortem) · **Is this reversible?** (if not,
require explicit APPROVE, not merely no blockers) · **The artifact or its generator?** (both) ·
**How many rounds is this worth?** · **Is a human available now?** (if not, hand over and stop).
Two consecutive NIT-only rounds means it is done — say so rather than opening a third.
## Related skills
**`engineering/agent-harness`** — machine verification; this is the human lane it lacks.
**`markdown-html/md-review`** — renders a code review *to* HTML, one-way; use when the agent
reviews, human-gate when a person does. **`engineering/grill-me`** — interrogates a plan before
an artifact exists. **`content-humanizer`**/**`behuman`** — human *voice*, not approval.
Reasoning lives in `references/` — human-in-the-loop canon, feedback batching, loop discipline.
Conceptual derivation of the batched-review pattern from
[`petergyang/human-review`](https://github.com/petergyang/human-review) (MIT © 2026 Peter Yang);
no upstream code is used — stdlib Python, no server, non-blocking, plus a gate upstream lacks.

View file

@ -0,0 +1,89 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/alirezarezvani/claude-skills/engineering/human-gate/batch.v1.schema.json",
"title": "human-gate feedback batch (batch.v1)",
"description": "One round of batched human review feedback, produced by feedback_parser.py from a review sidecar and consumed by human_gate.py.",
"type": "object",
"required": ["schema", "target", "round", "reviewer", "approved", "blocking_open", "counts", "items"],
"additionalProperties": false,
"properties": {
"schema": { "const": "batch.v1" },
"target": {
"type": "string",
"description": "Basename of the artifact under review."
},
"round": {
"type": "integer",
"minimum": 1,
"description": "1-based review round. Capped by human_gate.py --max-rounds."
},
"reviewer": {
"type": "string",
"description": "Named human reviewer. Empty fails gate rule G3 — approval belongs to a person, not a team."
},
"approved": {
"type": "boolean",
"description": "True only when an APPROVE item exists AND no BLOCKER/MAJOR item is open. Absence of blockers alone is not approval."
},
"blocking_open": {
"type": "integer",
"minimum": 0,
"description": "Count of open BLOCKER + MAJOR comment items. Non-zero fails gate rule G2."
},
"counts": {
"type": "object",
"description": "Item tally keyed by BLOCKER/MAJOR/MINOR/NIT/EDIT/APPROVE.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"items": {
"type": "array",
"items": { "$ref": "#/$defs/item" }
},
"overall_note": {
"type": "string",
"description": "Unanchored commentary from ## NOTE sections, joined by blank lines."
}
},
"$defs": {
"item": {
"type": "object",
"required": ["id", "kind", "severity", "block", "quote", "feedback"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^f[0-9]+$",
"description": "Stable within a round: f1, f2, ..."
},
"kind": {
"enum": ["comment", "edit", "approve"],
"description": "comment = a change request; edit = a verbatim replacement the reviewer already wrote; approve = explicit sign-off."
},
"severity": {
"enum": ["BLOCKER", "MAJOR", "MINOR", "NIT"],
"description": "BLOCKER/MAJOR block the gate. Google Code Review Developer Guide ladder; matches markdown-html/md-review."
},
"block": {
"type": "string",
"description": "Block anchor id assigned by review_page_builder.py (b1, b2, ...). Empty for unanchored items."
},
"quote": {
"type": "string",
"description": "Verbatim text the reviewer selected. Checked against the artifact by feedback_parser.py --target; a mismatch is reported as a problem, not silently ignored."
},
"feedback": {
"type": "string",
"description": "What needs to change, and why."
},
"before": {
"type": "string",
"description": "EDIT items only: the exact current text."
},
"after": {
"type": "string",
"description": "EDIT items only: the reviewer's exact replacement. MUST be carried across verbatim — never paraphrased, never reverted, and applied to the generating source as well as the rendered artifact."
}
}
}
}
}

View file

@ -0,0 +1,55 @@
# Review feedback: q3-launch-plan.md
<!-- human-gate:v1 target=q3-launch-plan.md round=2 -->
reviewer: reza
<!--
A worked example of the sidecar format. Copy it, or export one from the
review page. Everything below is hand-writable in any editor — that is what
keeps this loop working over SSH and in CI, where no browser exists.
Headings are: ## <SEVERITY|KIND> [block-id]
Severities: BLOCKER MAJOR MINOR NIT (BLOCKER + MAJOR block the gate)
Other kinds: EDIT NOTE APPROVE
-->
## BLOCKER b4
> We expect a 40% lift in activation within the first quarter.
No source for 40%, and the whole staffing ask is derived from it. Either cite the
experiment it came from or replace it with the range we can actually defend.
## MAJOR b9
The risk register has no entry for the Acme contract expiring in September. That is
the single largest schedule risk in this plan and it is not written down anywhere.
## EDIT b11
- before: The team will endeavour to deliver incremental value on a continuous basis
+ after: The team ships one usable slice per week
Passive and vague. My replacement is the wording I want — use it as written.
## MINOR b6
The comparison table lists competitors but never says what we compare *on*. Add the
axis, even if it is one sentence above the table.
## NIT b14
"recieve" -> "receive".
## NIT b2
Trailing whitespace at the end of the intro paragraph.
## NOTE
Structure is right and the sequencing argument in §3 is the strongest part — lead with
it. Fix the blocker and the Acme risk and I am happy to sign this off next round.
<!--
Sign-off, when you are ready, is an explicit item:
## APPROVE
Blocker resolved, Acme risk added. Ship it.
Absence of blockers is NOT approval — the gate distinguishes the two.
With this file in place:
python3 human_gate.py collect q3-launch-plan.md --output json
python3 human_gate.py close q3-launch-plan.md
-->

View file

@ -0,0 +1,163 @@
# Batched, anchored feedback: why the artifact beats the message
The design claim behind this skill: **human feedback should be a structured artifact,
not a chat message.** This document is the argument and the prior art.
---
## 1. The problem with prose feedback
The natural thing a reviewer types is:
> In the third paragraph change X to Y. Cut the third card, it repeats the first. Also
> the CTA needs rewriting, and I think the numbers in the table are stale.
Four instructions, one blob. Everything that goes wrong next is predictable:
| Failure | Why it happens |
|---|---|
| An instruction is missed | Nothing enumerates them, so nothing detects a miss |
| An instruction is misread | "the third paragraph" is positional and drifts as the doc changes |
| The reviewer must re-verify everything | There is no per-item status, so the only check is re-reading the whole artifact |
| Severity is invisible | "Also the CTA needs rewriting" and "this number is wrong" look identical |
| Nothing accumulates | Round 2 cannot tell what round 1 asked for |
Each failure is a *representation* problem, not an attention problem. Structure fixes
what more careful reading does not.
---
## 2. Batching: the review, not the comment
GitHub and GitLab both converged on the same interaction: reviewers **start a review**,
accumulate comments, and **submit them as one batch** — rather than firing each comment
as it is written. This was a deliberate change from the earlier one-comment-at-a-time
model, and the reasons generalise well beyond code:
- **The author gets a coherent picture** rather than a drip of notifications, each of
which invites a premature partial fix.
- **The reviewer can revise.** A comment written on line 10 often becomes wrong after
reading line 200. Batching lets it be withdrawn before the author ever sees it.
- **The batch is a unit of work.** "Apply this review" is a well-defined task with a
clear completion state. "Respond to these seven notifications" is not.
Fagan's inspection process (1976) had already established the same shape half a century
earlier: defects are logged during inspection and handed over as a **single rework list**
with a verification step, not shouted individually.
The `batch.v1` schema is this idea made machine-readable — one round, all items, counts,
and a blocking total.
---
## 3. Anchoring: locating feedback so it survives edits
Positional references ("paragraph 3", "line 40") break the moment the artifact changes.
The web-annotation world solved this properly.
The **W3C Web Annotation Data Model** (Recommendation, 2017) and its companion
*Selectors and States* define `TextQuoteSelector`: locate a target by its **exact quoted
text plus a prefix and suffix of surrounding context**. This is robust in the way
positions are not — if the surrounding text shifts, the quote still matches; if the
quote itself was edited, the mismatch is *detectable* rather than silent.
Hypothesis's open-source anchoring implementation is the reference production system for
this approach, and it is what `petergyang/human-review` also uses (its comment objects
carry `{prefix, quote, suffix}`).
**This skill uses a deliberately simpler two-layer anchor:**
1. A **stable block id** (`b7`) assigned at page-build time — cheap, exact, and enough
for the common case.
2. An optional **verbatim quote**, which `feedback_parser.py --target` checks against the
real file and reports when it no longer matches.
The simplification is honest about its limit: block ids are stable *within a round*, not
across a rewrite that reorders blocks. The quote check is what catches drift. If you need
true edit-resilient anchoring across major rewrites, the W3C selector model is the thing
to implement, and the quote field is already the hook for it.
---
## 4. Severity vocabulary: the cheapest quality upgrade
An unlabelled comment forces the author to infer urgency, and authors systematically
infer wrong — usually downward on things that matter and upward on things that do not.
Google's *Code Review Developer Guide* institutionalised the fix with the `Nit:` prefix:
an explicit marker that a comment is a preference the author may decline. Once one rung
exists, the rest follow naturally, and the four-rung ladder is now near-universal:
| Rung | Meaning | Blocks close? |
|---|---|---|
| **BLOCKER** | must be fixed; shipping this is wrong | yes |
| **MAJOR** | strongly recommend; needs a reason to decline | yes |
| **MINOR** | worth fixing | no |
| **NIT** | cosmetic, author's call | no |
**Conventional Comments** (conventionalcomments.org) generalises this into a labelled
grammar — `praise:`, `nitpick:`, `suggestion:`, `issue:`, `question:`, `blocking:` — with
the same underlying insight: *the label is not decoration, it is the routing
information.* A labelled corpus of feedback can be counted, filtered, and gated. An
unlabelled one can only be read.
This skill uses BLOCKER/MAJOR/MINOR/NIT for consistency with
`markdown-html/md-review`, plus three kinds that are not severities:
- **EDIT** — the reviewer already wrote the replacement. Not a request; a fact.
- **NOTE** — unanchored commentary about the whole artifact.
- **APPROVE** — explicit sign-off, distinct from mere absence of blockers.
---
## 5. EDIT items and the verbatim rule
The most valuable thing a reviewer can produce is not a comment — it is **the corrected
text itself**. One sentence they rewrote is worth three paragraphs describing how they
would like it rewritten.
This creates an obligation that is easy to violate: the replacement must be carried
across **verbatim**. Three ways agents break it, all observed:
1. **Paraphrasing** it into the surrounding voice, discarding the reviewer's word choice.
2. **Reverting** it on a later pass, because a general instruction ("tighten the copy")
overwrote a specific one.
3. **Applying it only to the rendered artifact**, not to the MDX/template/script that
generates it — so the next build silently deletes the human's edit.
Upstream `human-review`'s SKILL.md is emphatic about exactly these, and it is right to
be. The rule is inherited here unchanged, and stated in the skill's hard rules.
---
## 6. Why the sidecar is plain Markdown
`batch.v1` is JSON, but the thing a *human* writes is Markdown. That is deliberate:
- **No tool required.** A reviewer with `vi` over SSH can produce a valid batch. This is
what makes the loop survive headless hosts, which is precisely where the
browser-dependent designs fail.
- **Diffable and reviewable.** The sidecar can live in git next to the artifact.
- **Degrades gracefully.** A malformed sidecar still parses partially, and
`feedback_parser.py` reports problems rather than refusing everything.
The single-file HTML review page is a **convenience over** this format, never a
prerequisite for it. Any design where the GUI is the only way to produce feedback has
made the browser a hard dependency of the review loop — a bad trade for a capability
whose whole point is to work whenever a human is available.
---
## Sources
1. W3C. *Web Annotation Data Model.* W3C Recommendation, 23 February 2017.
2. W3C. *Selectors and States* (`TextQuoteSelector`). W3C Recommendation, 2017.
3. Hypothesis. *Anchoring* — open-source annotation anchoring implementation.
https://web.hypothes.is/
4. Google. *Code Review Developer Guide — How to write code review comments.*
https://google.github.io/eng-practices/review/reviewer/comments.html
5. *Conventional Comments.* https://conventionalcomments.org/
6. Fagan, M. *Design and code inspections to reduce errors in program development.*
IBM Systems Journal 15(3), 1976.
7. Yang, P. *human-review* — batched browser review for agents (MIT), 2026.
https://github.com/petergyang/human-review

View file

@ -0,0 +1,134 @@
# Human-in-the-loop review: the canon
Why a human verification lane exists at all, and what decades of inspection research
say about how to run one. The short version: **automation does not remove the human's
job, it makes the remaining human job harder and more consequential** — which is
exactly why it needs structure rather than good intentions.
---
## 1. Ironies of automation
Lisanne Bainbridge's *Ironies of Automation* (Automatica, 1983) is the foundational
argument. Automate the routine parts of a task and two things follow:
1. The human is left with the **hardest residual judgements**, not the easiest.
2. The human's skill at those judgements **decays**, because they no longer practise
the routine work that built it.
Applied to agent loops: the more capable the agent, the *more* important — and the more
degraded — the reviewer's attention becomes. A gate that merely *invites* review will
get skimming. A gate that names a person, demands a severity, and refuses to close
without a resolution forces the attention the irony predicts you will otherwise lose.
Parasuraman & Riley, *Humans and Automation: Use, Misuse, Disuse, Abuse* (Human Factors,
1997) supplies the failure mode by name: **automation bias** — operators accept
automated output they would have questioned had a human produced it. Their finding that
complacency rises with perceived reliability is the reason `close` refuses on G1 rather
than trusting that someone probably looked.
---
## 2. Inspection research: structure beats effort
Michael Fagan's *Design and code inspections to reduce errors in program development*
(IBM Systems Journal, 1976) established the modern inspection: defined roles, a defined
artifact, defined defect classes, and a rework step that must complete before exit. The
durable lessons:
- **Named roles beat diffuse responsibility.** Fagan assigns a moderator, an author, and
reviewers. Ambiguity about who is accountable is where inspections rot. → gate rule G3.
- **Exit criteria are explicit.** An inspection is not over when everyone stops talking;
it is over when rework is verified. → gate rules G1/G2/G4.
- **Defects are classified, not just described.** Classification is what makes review
output aggregable and comparable across rounds. → the severity ladder.
Karl Wiegers, *Peer Reviews in Software* (Addison-Wesley, 2001) adds the practical
correction most teams need: review effectiveness collapses when reviewers are given no
severity vocabulary and no cap on scope. Unbounded review produces either rubber stamps
or infinite rounds — the two failure modes gate rule G5 is aimed at.
---
## 3. The modern industrial form
*Software Engineering at Google* (Winters, Manshreck & Wright, O'Reilly, 2020), chapter 9,
documents review at scale. Two points transfer directly:
- Review is **primarily about comprehensibility**, not defect-hunting. Most value comes
from a second person being able to follow the artifact at all.
- **Latency is the enemy.** Google optimises hard for reviewer turnaround because a slow
review loop gets routed around. This is the strongest argument for *not* holding an
agent turn open while waiting: a blocked agent adds latency and pressure without
adding review quality.
Google's public [Code Review Developer Guide](https://google.github.io/eng-practices/review/)
supplies the severity convention this skill adopts — the `Nit:` prefix convention and the
principle that a reviewer should distinguish *must fix* from *preference*. The
BLOCKER / MAJOR / MINOR / NIT ladder used here (and in `markdown-html/md-review`) is the
common four-rung expression of that idea.
---
## 4. Egoless review
Gerald Weinberg's *The Psychology of Computer Programming* (1971) introduced **egoless
programming**: separating the artifact from the author's identity so critique lands on
the work. The operational trick is to make feedback **about a located thing** — a block,
a quote, a line — rather than about the document in general.
This is why every item in a `batch.v1` anchors to a block id and, where possible, a
verbatim quote. "The tone is off" invites defence. "`b7`: this sentence claims 40% with
no source" invites a fix.
---
## 5. Reversibility sets the bar
Not every artifact deserves the same gate. The useful frame is Amazon's **one-way vs
two-way doors** (Bezos, 2015 shareholder letter): decisions that are cheap to reverse
should be made fast and unilaterally; decisions that are expensive or impossible to
reverse deserve deliberate process.
Practical mapping for this skill:
| Artifact | Door | Gate posture |
|---|---|---|
| Internal draft, notes, a branch | two-way | open a round if asked; NITs need not block |
| Spec, RFC, plan others will build from | mixed | hold G2 strictly |
| Customer-facing copy, a migration, a public post | one-way | require explicit APPROVE, not merely "no blockers" |
Gary Klein's **pre-mortem** technique (*Harvard Business Review*, 2007) is the cheapest
upgrade to any of these: ask the reviewer to state, *before* reading, what would make
them reject it. Pre-committing a rejection criterion measurably reduces rubber-stamping.
---
## 6. What this means for agent loops specifically
Anthropic's published guidance on building effective agents makes the point that agent
systems should be designed around **verifiable checkpoints** rather than end-to-end
trust. Machine checks cover what is mechanically checkable. Everything else — taste,
strategy, tone, whether the claim is actually true, whether this is the right thing to
build at all — has no automated verifier and never will.
That residual set is not a gap to be closed later. It is permanently the human's, and it
is precisely what Bainbridge warned would get harder as the rest got easier. The gate
exists to make sure that residue is handled explicitly rather than absorbed by an agent's
own confidence.
---
## Sources
1. Bainbridge, L. *Ironies of Automation.* Automatica 19(6), 1983.
2. Parasuraman, R. & Riley, V. *Humans and Automation: Use, Misuse, Disuse, Abuse.*
Human Factors 39(2), 1997.
3. Fagan, M. *Design and code inspections to reduce errors in program development.*
IBM Systems Journal 15(3), 1976.
4. Wiegers, K. *Peer Reviews in Software: A Practical Guide.* Addison-Wesley, 2001.
5. Winters, T., Manshreck, T. & Wright, H. *Software Engineering at Google*, ch. 9.
O'Reilly, 2020.
6. Google. *Code Review Developer Guide.* https://google.github.io/eng-practices/review/
7. Weinberg, G. *The Psychology of Computer Programming.* Van Nostrand Reinhold, 1971.
8. Klein, G. *Performing a Project Premortem.* Harvard Business Review, September 2007.

View file

@ -0,0 +1,170 @@
# Review-loop discipline: waiting, capping, escalating
An agent that asks for human review has to decide three things it usually gets wrong:
**how to wait, when to stop, and what "done" means.** This document is the reasoning
behind the answers baked into `human_gate.py`.
---
## 1. Never block a turn on a human
The tempting design is a blocking poll: the agent opens a review, calls
`poll --timeout 600`, and holds its turn until the human clicks Send.
`petergyang/human-review` does exactly this, and instructs the agent accordingly:
> Keep this command in the foreground. Do not end your turn while it is waiting. […]
> If it prints `{"status":"timeout"}`, no feedback has arrived yet — run the same poll
> command again to keep waiting.
For a developer sitting in front of their own terminal with the browser already open,
this is a genuinely nice experience — the loop feels continuous. It is also the single
most dangerous instruction to generalise, for four reasons:
1. **Human review latency is unbounded.** Minutes if they are at the desk; days if the
reviewer is a compliance officer. No timeout is right for both.
2. **There is no headless guard.** On CI, over SSH, or in a remote agent session, there
is no browser and no human. The re-poll instruction has no terminating condition —
it loops until something external kills it.
3. **It burns context.** Each poll round-trip consumes tokens producing nothing.
4. **It inverts the cost model.** The agent is the cheap, restartable participant; the
human is the scarce one. Making the cheap participant wait *for* the scarce one, in
a way that consumes resources while idle, is backwards.
**The rule here: `status` never blocks; the agent opens a round, hands over the path,
and ends its turn.** State lives on disk in `.human-gate/`, so nothing is lost between
turns. The human's Send is not an event the agent must catch — it is a file that will be
there when the agent next looks.
This is the same conclusion the repository's own agentic-readiness audit reached
independently: `audit/engineering-agentic-2026-07/` names **AR5 (loop discipline)** as
the repo-wide weakness, with iteration caps and named terminal states as the fix.
---
## 2. Cap the rounds, and make exhaustion mean something
An uncapped review loop has no failure state, which sounds safe and is not. Two
pathologies:
- **Infinite polish.** Each round surfaces new NITs because reviewers reliably find
*something*. Without a cap, "one more round" is always locally reasonable and the
artifact never ships. Wiegers documents this directly in *Peer Reviews in Software*.
- **Silent divergence.** Rounds 3, 4, 5 contest the same point in different words. The
loop is no longer converging; it is a disagreement wearing a process costume.
`--max-rounds` (default 5) bounds it, and exhaustion exits **5 = ESCALATE** with an
explicit instruction: stop iterating, summarise what is still contested, hand it to a
human. It does **not** exit 0.
This distinction matters more than the number. Anthropic's guidance on long-running
agents makes the general point: an agent that exhausts its budget must **escalate**, not
degrade into a pass. A budget silently treated as success is worse than no budget, because
it converts a known limit into an unnoticed one.
The convergence signal worth watching: **two consecutive rounds producing only NITs
means the artifact is done.** Say so, rather than fishing for a third.
---
## 3. Refuse to close — the verifier's discipline
The core anti-pattern is **verification theater**: an agent that reports success by
narrating success. The defence is that the thing which *decides* done must be different
from the thing which *does* the work.
`engineering/agent-harness`'s `loop_controller.py` implements this for machine checks —
it runs verification commands itself via subprocess rather than trusting an agent's
claim that they passed. `human_gate.py` applies the identical principle to the human
lane: the gate reads recorded state on disk, not the agent's assertion.
The refusals, and the specific lie each one prevents:
| Rule | Prevents the claim… |
|---|---|
| G1 no round collected | "I had it reviewed" — when no review exists |
| G2 blocking items open | "I addressed the feedback" — when the blocker is untouched |
| G3 no named reviewer | "The team signed off" — when no person did |
| G4 sidecar changed post-collect | "That's the latest review" — when it is not |
| G5 cap exhausted | "We converged" — when the loop just ran out |
| G6 undocumented waiver | "It was fine to skip" — with no record of who decided |
**G4 deserves its own note.** It catches a genuinely subtle case: the reviewer adds a
late blocker to the sidecar *after* the agent collected round N. Without a content hash
comparison, the gate would close against a stale snapshot and the new blocker would
vanish. `fingerprint()` hashes contents rather than trusting mtime, so a re-save with no
changes does not spuriously reopen the gate.
---
## 4. Waivers must be explicit, never inferred
A gate with no override gets bypassed — someone comments out the check, or stops running
it. Nygard's *Release It!* makes this point about circuit breakers generally: a safety
mechanism with no legitimate manual override will be disabled illegitimately.
So `--waive "<reason>"` exists. Its discipline:
- The reason is **required** — there is no bare `--force`.
- The reason and **every refusal it overrode** are written into gate state, so the
waiver is auditable after the fact.
- The waiver is per-artifact, not global. It does not lower the bar for anything else.
A recorded "we shipped this unreviewed because the reviewer was on leave and the CTO
accepted the risk" is a legitimate engineering decision. An unrecorded one is an
incident waiting to be reconstructed from memory.
---
## 5. Match gate strength to reversibility
Holding every artifact to the same bar trains people to route around the gate. Scale it:
| Situation | Posture |
|---|---|
| Internal draft, a branch, notes | Open a round if asked. NITs do not block. |
| Spec, plan, RFC others will build from | Hold G2 strictly. Named reviewer required. |
| External, irreversible, regulated | Require an explicit **APPROVE** item — absence of blockers is not consent. |
The last row is the one agents get wrong most often. "No blockers were raised" and
"a person approved this" are different states, which is why `APPROVE` is a distinct kind
in the schema and why `batch.approved` is only true when an APPROVE exists *and* no
blocking item is open.
---
## 6. What good looks like
A healthy loop, end to end:
```
open → round 1, page built, path handed over, TURN ENDS
… human reviews on their own schedule …
status → exit 3, feedback waiting
collect → 1 BLOCKER, 2 NIT, 1 EDIT. Apply all. EDIT verbatim.
open → round 2, TURN ENDS
collect → APPROVE, 0 blocking
close → GATE PASSED (reviewer: reza, rounds: 2)
```
Two rounds, no blocking waits, a named human, an auditable record. The agent never once
had to decide whether its own work was good enough — which is the entire point.
---
## Sources
1. Anthropic. *Building Effective Agents* — checkpointing, budgets and escalation in
agent loop design. https://www.anthropic.com/engineering/building-effective-agents
2. Yang, J. et al. *SWE-agent: Agent-Computer Interfaces Enable Automated Software
Engineering.* NeurIPS, 2024.
3. Wiegers, K. *Peer Reviews in Software: A Practical Guide*, ch. on review scope and
the diminishing returns of extra rounds. Addison-Wesley, 2001.
4. Nygard, M. *Release It! Design and Deploy Production-Ready Software*, 2nd ed.
(circuit breakers, manual overrides). Pragmatic Bookshelf, 2018.
5. Bainbridge, L. *Ironies of Automation.* Automatica 19(6), 1983.
6. Yang, P. *human-review* SKILL.md — the blocking-poll contract this design
deliberately inverts. https://github.com/petergyang/human-review
7. This repository. `audit/engineering-agentic-2026-07/` — AR5 loop-discipline finding.
8. This repository. `engineering/agent-harness/skills/agent-harness/scripts/loop_controller.py`
— machine-verification counterpart to this gate.

View file

@ -0,0 +1,482 @@
#!/usr/bin/env python3
"""Parse a human-gate review sidecar into a structured feedback batch (batch.v1).
The sidecar is a plain Markdown file a person can write by hand in any editor,
or export from the review page built by review_page_builder.py. Keeping it
hand-writable is deliberate: it is what lets the review loop work over SSH, in
CI, and on a machine with no browser.
Format
------
# Review feedback: report.md
<!-- human-gate:v1 target=report.md round=1 -->
reviewer: reza
## BLOCKER b3
> the exact quoted text from the block
We have no data for this claim. Cite it or cut it.
## EDIT b7
- before: The system recieve requests
+ after: The system receives requests
## NIT b9
Trailing whitespace in the table.
## NOTE
Close. Ship once the blocker is gone.
The `<!-- human-gate:v1 ... -->` header carries `key=value` attributes; quote any
value containing a space (`target="q3 plan.md"`). `target` is a display hint only
quote verification runs against the path passed as `--target`.
Severities are BLOCKER / MAJOR / MINOR / NIT (the same ladder markdown-html's
md-review uses, from Google's Code Review Developer Guide), plus three
non-severity kinds: EDIT (a verbatim replacement the human already wrote),
NOTE (unanchored commentary) and APPROVE (an explicit sign-off).
Exit codes
----------
0 parsed cleanly
1 usage error / unreadable input
2 parsed, but --strict found problems (unknown severity, EDIT without an
after, missing reviewer, or a quote that is not in the target)
"""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import sys
SCHEMA = "batch.v1"
# Two problems already have a dedicated gate rule in human_gate.py (G3 and G2),
# so its G7 integrity check must skip them rather than report them twice. It
# matches on these prefixes, which is why they live here beside the
# problems.append() calls that emit them: rewording a message without touching
# the constant would silently make a gated problem ungated.
P_NO_REVIEWER = "no 'reviewer:' line" # human_gate.py G3
P_APPROVE_WITH_BLOCKING = "APPROVE is present alongside" # human_gate.py G2
GATED_ELSEWHERE = (P_NO_REVIEWER, P_APPROVE_WITH_BLOCKING)
SEVERITIES = ["BLOCKER", "MAJOR", "MINOR", "NIT"]
BLOCKING = {"BLOCKER", "MAJOR"}
OTHER_KINDS = ["EDIT", "NOTE", "APPROVE"]
ALL_HEADINGS = SEVERITIES + OTHER_KINDS
HEADER_RE = re.compile(
r"^\s*<!--\s*human-gate:v1\s+(?P<attrs>.*?)\s*-->\s*$", re.IGNORECASE
)
# Header attributes are `key=value` pairs. A value containing spaces — a target
# filename with a space in it — must be quoted, or it truncates at the space.
ATTR_RE = re.compile(r"""(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s'"]+))""")
REVIEWER_RE = re.compile(r"^\s*reviewer\s*:\s*(?P<name>.+?)\s*$", re.IGNORECASE)
# "## BLOCKER b3", "## NIT", "## EDIT b7", "## NOTE"
ITEM_RE = re.compile(
r"^\s*##\s+(?P<kind>[A-Za-z]+)\s*(?P<block>[A-Za-z0-9_-]+)?\s*$"
)
QUOTE_RE = re.compile(r"^\s*>\s?(?P<text>.*)$")
BEFORE_RE = re.compile(r"^\s*-\s*before\s*:\s?(?P<text>.*)$", re.IGNORECASE)
AFTER_RE = re.compile(r"^\s*\+\s*after\s*:\s?(?P<text>.*)$", re.IGNORECASE)
SAMPLE = """# Review feedback: quarterly-plan.md
<!-- human-gate:v1 target=quarterly-plan.md round=1 -->
reviewer: reza
## BLOCKER b2
> We expect a 40% lift in activation.
Where does 40% come from? No source, and it drives the whole plan. Cite it or cut it.
## MAJOR b5
The risks section does not mention the vendor contract expiring in Q3.
## EDIT b7
- before: The team will endeavour to deliver incremental value
+ after: The team ships one usable slice per week
## NIT b9
"recieve" -> "receive".
## NOTE
Structure is right. Fix the blocker and this is good to go.
"""
def _strip_comments(lines):
"""Drop HTML-comment content, keeping the human-gate:v1 header line.
Sidecars carry explanatory comments the shipped example asset documents
the format inside one. Without this, a '## APPROVE' written as an example
inside a comment parses as a real sign-off, which is exactly the kind of
silent false approval this whole skill exists to prevent.
Comments inside fenced code blocks are left alone; there they are content.
"""
kept = []
fence = False
in_comment = False
for line in lines:
if line.lstrip().startswith("```"):
fence = not fence
kept.append(line)
continue
if fence:
kept.append(line)
continue
if in_comment:
if "-->" in line:
in_comment = False
remainder = line.split("-->", 1)[1]
if remainder.strip():
kept.append(remainder)
continue
if "<!--" in line:
if HEADER_RE.match(line):
kept.append(line)
continue
head = line.split("<!--", 1)[0]
rest = line.split("<!--", 1)[1]
if "-->" in rest:
tail = rest.split("-->", 1)[1]
merged = head + tail
if merged.strip():
kept.append(merged)
else:
in_comment = True
if head.strip():
kept.append(head)
continue
kept.append(line)
return kept
def _strip_fence_markers(lines):
"""Yield (line, in_code_fence) so fenced blocks never parse as items."""
fence = False
for line in lines:
if line.lstrip().startswith("```"):
fence = not fence
yield line, True
continue
yield line, fence
def parse(text, target_hint=None):
"""Parse sidecar text into a batch.v1 dict plus a list of problem strings."""
problems = []
lines = _strip_comments(text.splitlines())
meta = {"target": target_hint or "", "round": 1}
reviewer = ""
items = []
current = None
overall = []
def flush():
nonlocal current
if current is None:
return
body = "\n".join(current.pop("_body")).strip()
if current["kind"] == "NOTE":
if body:
overall.append(body)
current = None
return
if current["kind"] == "EDIT":
if not current.get("after"):
problems.append(
"EDIT item on block %s has no '+ after:' line; an edit "
"without replacement text cannot be applied"
% (current.get("block") or "?")
)
if body:
current["feedback"] = body
else:
current["feedback"] = body
if not body and not current.get("quote"):
problems.append(
"%s item on block %s is empty"
% (current["kind"], current.get("block") or "?")
)
items.append(current)
current = None
for raw, in_fence in _strip_fence_markers(lines):
if in_fence:
if current is not None:
current["_body"].append(raw)
continue
header = HEADER_RE.match(raw)
if header:
for match in ATTR_RE.finditer(header.group("attrs")):
key = match.group(1)
value = next(g for g in match.groups()[1:] if g is not None)
if key == "round":
try:
meta["round"] = int(value)
except ValueError:
problems.append("round=%r is not an integer" % value)
else:
meta[key] = value
continue
if current is None:
found = REVIEWER_RE.match(raw)
if found and not reviewer:
reviewer = found.group("name")
continue
item = ITEM_RE.match(raw)
if item:
flush()
kind = item.group("kind").upper()
if kind not in ALL_HEADINGS:
problems.append(
"unknown severity/kind %r (expected one of %s)"
% (kind, ", ".join(ALL_HEADINGS))
)
current = {
"id": "f%d" % (len(items) + 1),
"kind": kind,
"block": item.group("block") or "",
"quote": "",
"feedback": "",
"_body": [],
}
if kind == "EDIT":
current["before"] = ""
current["after"] = ""
continue
if current is None:
continue
quote = QUOTE_RE.match(raw)
if quote and not current["feedback"]:
existing = current["quote"]
current["quote"] = (
existing + "\n" + quote.group("text") if existing else quote.group("text")
)
continue
if current["kind"] == "EDIT":
before = BEFORE_RE.match(raw)
if before:
current["before"] = before.group("text")
continue
after = AFTER_RE.match(raw)
if after:
current["after"] = after.group("text")
continue
current["_body"].append(raw)
flush()
for item in items:
item.pop("_body", None)
if item["kind"] in SEVERITIES:
item["severity"] = item["kind"]
item["kind"] = "comment"
elif item["kind"] == "EDIT":
item["severity"] = "MINOR"
item["kind"] = "edit"
elif item["kind"] == "APPROVE":
item["severity"] = "NIT"
item["kind"] = "approve"
else:
item["severity"] = "NIT"
item["kind"] = "comment"
counts = {}
for item in items:
key = {"edit": "EDIT", "approve": "APPROVE"}.get(item["kind"], item["severity"])
counts[key] = counts.get(key, 0) + 1
approved = any(i["kind"] == "approve" for i in items)
blocking = [
i for i in items if i["kind"] == "comment" and i["severity"] in BLOCKING
]
if not reviewer:
problems.append(
"%s — human-gate requires a named reviewer so the approval "
"belongs to a person, not to the loop" % P_NO_REVIEWER
)
if approved and blocking:
problems.append(
"%s %d unresolved BLOCKER/MAJOR item(s); resolve or downgrade "
"them before signing off" % (P_APPROVE_WITH_BLOCKING, len(blocking))
)
batch = {
"schema": SCHEMA,
"target": meta.get("target", ""),
"round": meta.get("round", 1),
"reviewer": reviewer,
"approved": approved and not blocking,
"blocking_open": len(blocking),
"counts": counts,
"items": items,
"overall_note": "\n\n".join(overall),
}
return batch, problems
def plain_text(source, is_html=False):
"""Approximate what the browser renders, so quotes can be matched fairly.
A reviewer selects text in the *rendered* page, so `window.getSelection()`
hands back "We expect a 40% lift" for source that reads
"We expect a **40%** lift". Comparing that against raw markup would flag a
perfectly good quote and since integrity problems now block the gate
(G7), a false positive here refuses a legitimate close.
"""
text = source
if is_html:
text = re.sub(r"(?is)<(script|style)\b.*?</\1>", " ", text)
text = re.sub(r"(?s)<!--.*?-->", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
return html.unescape(text)
text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text) # images -> alt
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links -> label
text = re.sub(r"`([^`]+)`", r"\1", text) # inline code
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) # bold
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text) # italic
text = re.sub(r"(?<!_)_([^_\n]+)_(?!_)", r"\1", text)
text = re.sub(r"(?m)^\s{0,3}#{1,6}\s+", "", text) # heading markers
text = re.sub(r"(?m)^\s{0,3}>\s?", "", text) # blockquote markers
return text
def verify_quotes(batch, target_path):
"""Report quotes that appear in neither the raw source nor its rendered text."""
problems = []
try:
with open(target_path, "r", encoding="utf-8") as handle:
source = handle.read()
except OSError as err:
return ["could not read target %s to verify quotes: %s" % (target_path, err)]
squash = lambda s: re.sub(r"\s+", " ", s)
is_html = os.path.splitext(target_path)[1].lower() in (".html", ".htm")
haystacks = (squash(source), squash(plain_text(source, is_html=is_html)))
for item in batch["items"]:
quote = item.get("quote", "").strip()
if not quote:
continue
needle = squash(quote)
if not any(needle in hay for hay in haystacks):
problems.append(
"item %s quotes text not found in %s: %r"
% (item["id"], os.path.basename(target_path), quote[:60])
)
return problems
def render_human(batch, problems):
out = []
out.append("Review batch — %s (round %d)" % (batch["target"] or "?", batch["round"]))
out.append("Reviewer: %s" % (batch["reviewer"] or "(none — required)"))
order = SEVERITIES + ["EDIT", "APPROVE"]
summary = " · ".join(
"%d %s" % (batch["counts"][k], k) for k in order if k in batch["counts"]
)
out.append("Items: %s" % (summary or "none"))
out.append(
"Verdict: %s"
% (
"APPROVED"
if batch["approved"]
else "%d blocking item(s) open" % batch["blocking_open"]
)
)
if batch["items"]:
out.append("")
for item in batch["items"]:
label = {"edit": "EDIT", "approve": "APPROVE"}.get(
item["kind"], item["severity"]
)
where = (" @%s" % item["block"]) if item["block"] else ""
out.append(" [%s]%s %s" % (label, where, item["id"]))
if item.get("quote"):
out.append(' > "%s"' % item["quote"].replace("\n", " ")[:76])
if item["kind"] == "edit":
out.append(" before: %s" % item.get("before", ""))
out.append(" after: %s" % item.get("after", ""))
if item.get("feedback"):
out.append(" %s" % item["feedback"].replace("\n", " ")[:76])
if batch["overall_note"]:
out.append("")
out.append("Overall: %s" % batch["overall_note"])
if problems:
out.append("")
out.append("Problems (%d):" % len(problems))
for problem in problems:
out.append(" - %s" % problem)
return "\n".join(out)
def main(argv=None):
parser = argparse.ArgumentParser(
description="Parse a human-gate review sidecar into a batch.v1 feedback batch.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("sidecar", nargs="?", help="path to <artifact>.review.md")
parser.add_argument("--target", help="reviewed artifact, for quote verification")
parser.add_argument(
"--output", choices=["human", "json"], default="human", help="output format"
)
parser.add_argument(
"--strict",
action="store_true",
help="exit 2 when any problem is found (use in the gate, not while drafting)",
)
parser.add_argument(
"--sample", action="store_true", help="parse a built-in sample sidecar and exit"
)
args = parser.parse_args(argv)
if args.sample:
text, target_hint = SAMPLE, "quarterly-plan.md"
elif args.sidecar:
try:
with open(args.sidecar, "r", encoding="utf-8") as handle:
text = handle.read()
except OSError as err:
parser.error("cannot read %s: %s" % (args.sidecar, err))
return 1
target_hint = args.target or ""
else:
parser.error("provide a sidecar path or --sample")
return 1
batch, problems = parse(text, target_hint or None)
if args.target and os.path.exists(args.target):
problems.extend(verify_quotes(batch, args.target))
if args.output == "json":
print(json.dumps({"batch": batch, "problems": problems}, indent=2))
else:
print(render_human(batch, problems))
return 2 if (problems and args.strict) else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,639 @@
#!/usr/bin/env python3
"""The human-verification gate for an agent loop.
Machine verification answers "do the checks pass?". This answers the other
question "has a person actually looked at it, and are their objections
resolved?" — and refuses to let the loop close until they are.
Subcommands
-----------
open <artifact> build the review page, start (or advance) a round
status <artifact> is feedback waiting? non-blocking, exit-code driven
collect <artifact> parse the sidecar, record the round, emit batch.v1
close <artifact> the gate: refuses while review is missing or open
reset <artifact> discard gate state for this artifact
Gate rules
----------
G1 close refuses if no review round was ever collected
G2 close refuses while any BLOCKER or MAJOR item is open
G3 close refuses without a named reviewer (approval belongs to a person)
G4 close refuses if the sidecar changed after the last collect
G5 rounds are capped (--max-rounds); exhaustion escalates, it never
silently keeps looping
G6 a waiver is allowed but must be explicit, reasoned, and recorded --
and it can never waive G1: no waiver substitutes for review happening
G7 close refuses while the last round carries unresolved integrity
problems (mistyped severity, EDIT with no replacement, a quote not
in the file) those downgrade silently, so prose is not enough
Loop discipline
---------------
There is no blocking poll here, by design. `status` returns immediately and
the agent ends its turn. On a headless host `open` says so plainly instead of
telling the agent to wait at a browser that will never appear.
Exit codes
----------
0 ok / gate passed
1 usage error
2 gate refuses to close
3 feedback is waiting to be collected
4 no feedback yet (status: no sidecar on disk)
5 round cap exhausted escalate to a human
`status` returns 2 whenever `close` would refuse it previews every rule via
the shared gate_refusals(), not just blocking items so an agent can branch on
the exit code alone: 0 clear, 2 blocked, 3 collect me, 4 nothing yet.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
BLOCKING = {"BLOCKER", "MAJOR"}
DEFAULT_MAX_ROUNDS = 5
# Parser problems that already have their own gate rule. Everything else the
# parser reports is an integrity problem the gate must refuse on (G7) — a
# mistyped severity silently downgrades to NIT, so without this a reviewer's
# real blocker can be lost to a typo and close would still pass.
#
# The prefixes are owned by feedback_parser.py, beside the code that emits them,
# and read from the loaded module rather than restated here: matching free text
# across two files is a contract nothing enforces, and a reworded message would
# silently turn a G2/G3 problem into a duplicate ungated G7 refusal. The literal
# below is only a fallback for a parser too old to export the names.
_GATED_ELSEWHERE_FALLBACK = (
"no 'reviewer:' line", # G3
"APPROVE is present alongside", # G2
)
def _load_parser_module():
import importlib.util
path = os.path.join(HERE, "feedback_parser.py")
spec = importlib.util.spec_from_file_location("hg_feedback_parser", path)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load feedback_parser.py next to human_gate.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def gated_elsewhere_prefixes():
"""Problem prefixes G7 must skip, read from the parser that emits them."""
try:
prefixes = tuple(_load_parser_module().GATED_ELSEWHERE)
except (AttributeError, RuntimeError, OSError, TypeError):
return _GATED_ELSEWHERE_FALLBACK
return prefixes or _GATED_ELSEWHERE_FALLBACK
# ------------------------------------------------------------------- state
def state_dir(explicit=None, artifact=None):
"""Where gate state lives: beside the artifact, not beside the caller.
Keying state by the artifact's realpath but storing it under os.getcwd()
meant an agent whose shell cwd drifted between turns silently started from
empty state `close` would report G1 "nobody has looked at this" for a
round that really was collected, just from a different directory. It fails
closed rather than falsely passing, but it loses real feedback, so the
directory now follows the artifact the same way the sidecar and the review
page already do. An explicit --state-dir still wins.
"""
if explicit:
return os.path.abspath(explicit)
anchor = os.path.dirname(os.path.realpath(artifact)) if artifact else os.getcwd()
return os.path.join(anchor, ".human-gate")
def state_path(artifact, explicit=None):
key = hashlib.sha256(os.path.realpath(artifact).encode("utf-8")).hexdigest()[:16]
return os.path.join(state_dir(explicit, artifact), "%s.json" % key)
def load_state(artifact, explicit=None):
path = state_path(artifact, explicit)
if not os.path.exists(path):
return {
"artifact": os.path.realpath(artifact),
"rounds": [],
"closed": False,
"waiver": None,
}
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
def max_rounds_for(state, args):
"""The agreed round cap, from state — not from whatever this call passed.
A per-invocation flag lets `open` and a later `close` disagree about the
cap, which makes G5 escalation depend on how the command happened to be
typed. The cap is agreed once, recorded in state, and reused. Passing
--max-rounds again is an explicit renegotiation, not a silent override.
"""
stored = state.get("max_rounds")
requested = getattr(args, "max_rounds", None)
if requested is not None:
if stored is not None and stored != requested:
print("Round cap changed for this artifact: %d -> %d."
% (stored, requested))
state["max_rounds"] = requested
return requested
if isinstance(stored, int) and stored > 0:
return stored
return DEFAULT_MAX_ROUNDS
def save_state(state, artifact, explicit=None):
path = state_path(artifact, explicit)
directory = os.path.dirname(path)
os.makedirs(directory, mode=0o700, exist_ok=True)
try:
os.chmod(directory, 0o700)
except OSError:
pass
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(state, handle, indent=2)
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def sidecar_for(artifact, explicit=None):
if explicit:
return explicit
return os.path.splitext(artifact)[0] + ".review.md"
def fingerprint(path):
"""Content hash + mtime, so a re-edited sidecar is never mistaken for stale."""
try:
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()[:16]
return {"sha": digest, "mtime": os.path.getmtime(path)}
except OSError:
return None
# ---------------------------------------------------------------- headless
def headless_reason():
"""Why a browser cannot be shown here, or None when one probably can."""
for var in ("CI", "GITHUB_ACTIONS", "BUILDKITE", "JENKINS_URL"):
if os.environ.get(var):
return "running under %s" % var
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
return "connected over SSH"
if sys.platform.startswith("linux") and not (
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
):
return "no DISPLAY or WAYLAND_DISPLAY"
return None
# ---------------------------------------------------------------- commands
def cmd_open(args):
artifact = args.artifact
if not os.path.exists(artifact):
sys.stderr.write("Artifact not found: %s\n" % artifact)
return 1
state = load_state(artifact, args.state_dir)
if state["closed"] and not args.reopen:
print("Gate is already closed for %s. Use --reopen to start a new round."
% os.path.basename(artifact))
return 0
state["closed"] = False
# `open` pins the cap for the whole loop, so a later status/collect/close
# inherits it rather than re-deriving one from its own flags.
cap = max_rounds_for(state, args)
state["max_rounds"] = cap
round_number = len(state["rounds"]) + 1
if round_number > cap:
print("ESCALATE — %d rounds already spent on %s (cap %d)."
% (len(state["rounds"]), os.path.basename(artifact), cap))
print("Stop iterating. Hand this to a human and say what is still contested.")
return 5
sidecar = sidecar_for(artifact, args.sidecar)
page = args.page or (os.path.splitext(artifact)[0] + ".review.html")
build = subprocess.run(
[
sys.executable,
os.path.join(HERE, "review_page_builder.py"),
artifact,
"--output", page,
"--round", str(round_number),
"--sidecar", os.path.basename(sidecar),
],
capture_output=True,
text=True,
)
if build.returncode != 0:
sys.stderr.write(build.stderr or "review_page_builder failed\n")
return build.returncode
blocked = headless_reason()
state["open_round"] = {
"round": round_number,
"page": os.path.abspath(page),
"sidecar": os.path.abspath(sidecar),
"opened_at": time.time(),
"headless": blocked,
}
save_state(state, artifact, args.state_dir)
print(build.stdout.strip())
print("")
print("Round %d of at most %d." % (round_number, cap))
if blocked:
print("Headless host (%s) — not launching a browser." % blocked)
print("Give the reviewer both paths, then END YOUR TURN. Do not poll.")
print(" page: %s" % os.path.abspath(page))
print(" sidecar: %s (they can also just write this by hand)" % os.path.abspath(sidecar))
else:
print("Open the page, review, then Export feedback to:")
print(" %s" % os.path.abspath(sidecar))
if args.launch:
import webbrowser
webbrowser.open("file://" + os.path.abspath(page))
print("Launched in your default browser.")
print("")
print("When they are done: human_gate.py collect %s" % artifact)
return 0
def cmd_status(args):
artifact = args.artifact
state = load_state(artifact, args.state_dir)
sidecar = sidecar_for(artifact, args.sidecar)
current = fingerprint(sidecar)
rounds = state["rounds"]
cap = max_rounds_for(state, args)
payload = {
"artifact": os.path.basename(artifact),
"rounds_collected": len(rounds),
"max_rounds": cap,
"closed": state["closed"],
"sidecar": sidecar,
"sidecar_present": current is not None,
}
if current is None:
payload["status"] = "awaiting-review"
code = 4
elif rounds and rounds[-1].get("sidecar_sha") == current["sha"]:
payload["status"] = "collected"
payload["blocking_open"] = rounds[-1].get("blocking_open", 0)
# Preview the whole gate, not just blocking items — a round with no
# named reviewer (G3) or an integrity problem (G7) would still be
# refused by close, and reporting 0 here would mislead an agent that
# branches on the exit code.
payload["would_refuse"] = gate_refusals(state, sidecar)
code = 0 if not payload["would_refuse"] else 2
else:
payload["status"] = "feedback-waiting"
code = 3
if args.output == "json":
print(json.dumps(payload, indent=2))
else:
print("%s%s" % (payload["artifact"], payload["status"]))
print("Rounds collected: %d / %d" % (len(rounds), cap))
if not payload["sidecar_present"]:
print("No sidecar yet at %s" % sidecar)
elif payload["status"] == "feedback-waiting":
print("New feedback ready — run: human_gate.py collect %s" % artifact)
elif payload.get("would_refuse"):
print("Collected, but close would refuse:")
for refusal in payload["would_refuse"]:
print("%s" % refusal)
else:
print("Collected and clear — close would pass.")
return code
def cmd_collect(args):
artifact = args.artifact
state = load_state(artifact, args.state_dir)
sidecar = sidecar_for(artifact, args.sidecar)
current = fingerprint(sidecar)
if current is None:
sys.stderr.write("No sidecar at %s — nothing to collect yet.\n" % sidecar)
return 4
cap = max_rounds_for(state, args)
if len(state["rounds"]) >= cap:
print("ESCALATE — round cap (%d) reached for %s."
% (cap, os.path.basename(artifact)))
print("Stop iterating. Summarise what is still contested and hand it to a human.")
return 5
parser_module = _load_parser_module()
with open(sidecar, "r", encoding="utf-8") as handle:
text = handle.read()
batch, problems = parser_module.parse(text, os.path.basename(artifact))
problems.extend(parser_module.verify_quotes(batch, artifact))
round_number = len(state["rounds"]) + 1
batch["round"] = round_number
state["rounds"].append(
{
"round": round_number,
"collected_at": time.time(),
"reviewer": batch["reviewer"],
"sidecar_sha": current["sha"],
"counts": batch["counts"],
"blocking_open": batch["blocking_open"],
"approved": batch["approved"],
"problems": problems,
}
)
state.pop("open_round", None)
save_state(state, artifact, args.state_dir)
if args.output == "json":
print(json.dumps({"batch": batch, "problems": problems,
"round": round_number,
"rounds_remaining": cap - round_number}, indent=2))
else:
print(parser_module.render_human(batch, problems))
print("")
print("Recorded as round %d. %d round(s) left before escalation."
% (round_number, cap - round_number))
if batch["items"]:
print("Apply every item. EDIT items carry `after` across VERBATIM —")
print("that is the reviewer's own wording, not a suggestion to paraphrase.")
return 0
def gate_refusals(state, sidecar_path):
"""Every reason `close` would refuse, in rule order.
Shared with `status` on purpose: the exit-code contract promises status is
a preview of close, and two hand-maintained copies of this logic would
drift the first time a rule changed.
"""
rounds = state["rounds"]
if not rounds:
return ["G1 no review round has been collected — nobody has looked at this yet"]
last = rounds[-1]
refusals = []
if last.get("blocking_open"):
refusals.append(
"G2 %d BLOCKER/MAJOR item(s) still open from round %d"
% (last["blocking_open"], last["round"])
)
if not last.get("reviewer"):
refusals.append("G3 round %d has no named reviewer" % last["round"])
current = fingerprint(sidecar_path)
if current and current["sha"] != last.get("sidecar_sha"):
refusals.append(
"G4 the sidecar changed after round %d was collected — "
"collect again before closing" % last["round"]
)
gated = gated_elsewhere_prefixes()
for problem in last.get("problems", []):
if problem.startswith(gated):
continue
refusals.append("G7 round %d integrity: %s" % (last["round"], problem))
return refusals
def cmd_close(args):
artifact = args.artifact
state = load_state(artifact, args.state_dir)
rounds = state["rounds"]
refusals = gate_refusals(state, sidecar_for(artifact, args.sidecar))
# A waiver overrides objections a human actually raised. It cannot manufacture
# a review that never happened — "nobody looked" is not a finding to accept,
# it is the absence of the thing this gate exists to require. Waiving G1 would
# make the whole skill opt-out with one flag, which is the most tempting
# shortcut for an agent under time pressure.
unwaivable = [r for r in refusals if r.startswith("G1 ")]
if unwaivable and args.waive:
print("GATE REFUSED — %s" % os.path.basename(artifact))
for refusal in unwaivable:
print("%s (NOT WAIVABLE)" % refusal)
print("")
print("--waive accepts objections a reviewer raised; it cannot stand in for")
print("review itself. Open a round and get one collected first.")
return 2
if refusals and args.waive:
state["waiver"] = {
"reason": args.waive,
"waived_at": time.time(),
"refusals": list(refusals),
}
refusals = []
elif not refusals:
if args.waive:
print("Note: nothing to waive — the gate passes on its own.")
# A pass that needed no waiver must not inherit an earlier one, or a
# genuinely clean round N+1 reports round N's waiver as if it applied.
state["waiver"] = None
if refusals:
print("GATE REFUSED — %s" % os.path.basename(artifact))
for refusal in refusals:
print("%s" % refusal)
print("")
print("Do not report this as done. Either resolve the items above and")
print("collect another round, or record an explicit --waive \"<reason>\".")
return 2
state["closed"] = True
save_state(state, artifact, args.state_dir)
print("GATE PASSED — %s" % os.path.basename(artifact))
if rounds:
last = rounds[-1]
print(" reviewer: %s" % (last.get("reviewer") or "(none — waived)"))
print(" rounds: %d" % len(rounds))
if state.get("waiver"):
print(" WAIVED: %s" % state["waiver"]["reason"])
for refusal in state["waiver"]["refusals"]:
print(" (over: %s)" % refusal)
return 0
def cmd_reset(args):
path = state_path(args.artifact, args.state_dir)
# Only ever remove this skill's own state file. The name is a 16-hex digest
# of the artifact's realpath, so a hostile artifact name cannot steer the
# delete anywhere, and a mistyped --state-dir cannot make it hit a real file.
name = os.path.basename(path)
if not re.fullmatch(r"[0-9a-f]{16}\.json", name):
sys.stderr.write("Refusing to delete unexpected state file: %s\n" % path)
return 1
if os.path.exists(path):
os.remove(path)
print("Gate state cleared for %s" % os.path.basename(args.artifact))
else:
print("No gate state to clear for %s" % os.path.basename(args.artifact))
return 0
def main(argv=None):
parser = argparse.ArgumentParser(
description="Human-verification gate for an agent loop.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--sample", action="store_true", help="run a scripted end-to-end demo and exit"
)
sub = parser.add_subparsers(dest="command")
def shared(sp):
sp.add_argument("artifact", help="the file under review")
sp.add_argument("--sidecar", help="override the sidecar path")
sp.add_argument("--state-dir", help="override the .human-gate state directory")
sp.add_argument(
"--max-rounds", type=int, default=None,
help="round cap before escalation (default %d). Recorded in gate "
"state on the first open and reused by every later command, so "
"the cap cannot drift between calls; passing it again changes "
"the agreed cap and says so." % DEFAULT_MAX_ROUNDS,
)
return sp
opener = shared(sub.add_parser("open", help="build the review page, start a round"))
opener.add_argument("--page", help="where to write the review page")
opener.add_argument("--launch", action="store_true", help="open a browser if possible")
opener.add_argument("--reopen", action="store_true", help="reopen a closed gate")
opener.set_defaults(func=cmd_open)
status = shared(sub.add_parser("status", help="is feedback waiting? (non-blocking)"))
status.add_argument("--output", choices=["human", "json"], default="human")
status.set_defaults(func=cmd_status)
collect = shared(sub.add_parser("collect", help="parse the sidecar, record a round"))
collect.add_argument("--output", choices=["human", "json"], default="human")
collect.set_defaults(func=cmd_collect)
close = shared(sub.add_parser("close", help="the gate — refuses unless satisfied"))
close.add_argument("--waive", metavar="REASON",
help="close over open refusals, recording why")
close.set_defaults(func=cmd_close)
reset = shared(sub.add_parser("reset", help="discard gate state"))
reset.set_defaults(func=cmd_reset)
args = parser.parse_args(argv)
if args.sample:
return run_sample()
if not getattr(args, "command", None):
parser.print_help()
return 1
return args.func(args)
def run_sample():
"""End-to-end demo in a temp dir: open -> status -> collect -> close."""
import shutil
import tempfile
work = tempfile.mkdtemp(prefix="human-gate-sample-")
try:
artifact = os.path.join(work, "plan.md")
with open(artifact, "w", encoding="utf-8") as handle:
handle.write("# Plan\n\nWe expect a 40% lift in activation.\n\n## Risks\n\nNone identified.\n")
state = os.path.join(work, ".human-gate")
base = ["--state-dir", state]
print("=" * 62)
print("1. open — build the review page and start round 1")
print("=" * 62)
main(["open", artifact] + base)
print("\n" + "=" * 62)
print("2. status — before any feedback exists")
print("=" * 62)
code = main(["status", artifact] + base)
print("exit=%d (4 = awaiting review)" % code)
print("\n" + "=" * 62)
print("3. close — attempted with no review at all")
print("=" * 62)
code = main(["close", artifact] + base)
print("exit=%d (2 = gate refused)" % code)
sidecar = os.path.join(work, "plan.review.md")
with open(sidecar, "w", encoding="utf-8") as handle:
handle.write(
"# Review feedback: plan.md\n"
"<!-- human-gate:v1 target=plan.md round=1 -->\n\n"
"reviewer: reza\n\n"
"## BLOCKER b2\n"
"> We expect a 40% lift in activation.\n"
"No source for 40%. Cite it or cut it.\n\n"
"## NOTE\nRisks section is empty — that is itself a risk.\n"
)
print("\n" + "=" * 62)
print("4. collect — a human left one BLOCKER")
print("=" * 62)
main(["collect", artifact] + base)
print("\n" + "=" * 62)
print("5. close — still refused, the blocker is open")
print("=" * 62)
code = main(["close", artifact] + base)
print("exit=%d (2 = gate refused)" % code)
with open(sidecar, "w", encoding="utf-8") as handle:
handle.write(
"# Review feedback: plan.md\n"
"<!-- human-gate:v1 target=plan.md round=2 -->\n\n"
"reviewer: reza\n\n"
"## APPROVE\nSource added. Good to ship.\n"
)
print("\n" + "=" * 62)
print("6. collect round 2, then close — passes")
print("=" * 62)
main(["collect", artifact] + base)
code = main(["close", artifact] + base)
print("exit=%d (0 = gate passed)" % code)
return 0
finally:
# `work` came from mkdtemp() above; re-confirm it is still under the
# system temp root before recursing, so this can never walk a real tree.
if os.path.realpath(work).startswith(os.path.realpath(tempfile.gettempdir())):
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,883 @@
#!/usr/bin/env python3
"""Build a single-file HTML review page for a Markdown or HTML artifact.
The page opens straight off the filesystem (file://) there is no server, no
socket, and no network fetch of any kind. Every block of the artifact gets a
stable id; the reviewer clicks one, picks a severity, and types. Work is kept
in localStorage, and "Export feedback" writes the sidecar Markdown that
feedback_parser.py reads back.
That export format is hand-writable on purpose. A reviewer with no browser can
skip this script entirely and write the sidecar in an editor the loop still
closes. This page is the comfortable path, not the required one.
Exit codes
----------
0 page written
1 usage error / unreadable input
2 artifact has no reviewable blocks
"""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import sys
from html.parser import HTMLParser
BLOCK_TAGS = {
"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "pre", "blockquote",
"table", "hr", "section", "figure", "div",
}
# Dropped entirely from a reviewed HTML artifact. script/style/head/link/meta
# are chrome the review page supplies itself; iframe/object/embed/frame would
# execute or fetch third-party content inside a page the reviewer trusts.
DROP_TAGS = {
"script", "style", "head", "link", "meta",
"iframe", "object", "embed", "frame", "frameset", "base", "applet",
}
# Attributes carrying a URL, which must clear the same scheme allowlist the
# Markdown path uses. Everything named on* is an event handler and is dropped.
# xlink:href is the legacy SVG form and is still honoured by browsers, so an
# <svg><a xlink:href="javascript:..."> would bypass a plain "href" check.
URL_ATTRS = {
"href", "src", "action", "formaction", "poster", "cite", "background",
"xlink:href", "xlink:role", "xlink:arcrole",
}
# `style` joins these because `background-image:url(https://...)` fires a request
# the moment the reviewer opens the page — no script needed. The <style> *tag* was
# already dropped, so keeping the attribute was inconsistent as well as leaky.
DROP_ATTRS = {"srcdoc", "srcset", "style"}
# data-hg is this tool's own block anchor. If a reviewed artifact carries one,
# the builder would append a second, and the browser keeps the FIRST — so the
# click handler reads the artifact's value, not ours. Attribute values may hold
# raw newlines, so a crafted one becomes extra "## APPROVE" lines in the
# exported sidecar: a forged sign-off smuggled in by the content under review.
# The page's own element ids are reserved for the same collision reason.
RESERVED_ATTRS = {"data-hg"}
RESERVED_IDS = {
"doc", "items", "hint", "counts", "reviewer", "add-note", "copy",
"export", "toast",
}
SAMPLE_HTML = """<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<link rel="stylesheet" href="style.css">
<title>Landing</title>
</head><body>
<h1>Ship faster</h1>
<p>One usable slice per week.</p>
<ul><li>Onboarding rewrite</li><li>Retire the importer</li></ul>
<p><a href="//cdn.example.com/pricing">protocol-relative link</a> and an
<img src="https://img.example.com/hero.png" alt="own asset"> both survive on
purpose, while <a href="javascript:alert(1)">this</a> does not.</p>
</body></html>
"""
SAMPLE_MD = """# Quarterly plan
We expect a 40% lift in activation.
## Bets
- Ship the onboarding rewrite
- Retire the legacy importer
## Risks
The team will endeavour to deliver incremental value. The page template uses
__TITLE__ and __CONFIG__ as slots, and reviewing a doc that says so must not
corrupt the page that is a regression this fixture guards.
"""
# --------------------------------------------------------------- markdown
INLINE_CODE = re.compile(r"`([^`]+)`")
BOLD = re.compile(r"\*\*([^*]+)\*\*")
ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
LINK = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)\s]+)\)")
def _safe_href(url, image=False):
"""Allow relative URLs and http/https/mailto; data: only for inline images.
Control characters are stripped before the scheme is read, so
`java\\tscript:alert(1)` cannot smuggle a scheme past the check.
"""
probe = re.sub(r"[\x00-\x20\x7f]+", "", url)
match = re.match(r"^([a-zA-Z][a-zA-Z0-9+.\-]*):", probe)
if not match:
# No scheme: a relative or protocol-relative ("//host/x") URL. Both are
# allowed deliberately — neither can execute, and a reviewed artifact's
# own assets must still resolve for the review to be faithful.
return url
scheme = match.group(1).lower()
if scheme in ("http", "https", "mailto"):
return url
if image and re.match(
r"^data:image/(?:avif|gif|jpe?g|png|webp);base64,", probe, re.IGNORECASE
):
return url
return None
def inline(text):
"""Escape first, then apply inline markup. Never trusts raw HTML."""
out = html.escape(text, quote=False)
out = INLINE_CODE.sub(lambda m: "<code>%s</code>" % m.group(1), out)
out = BOLD.sub(lambda m: "<strong>%s</strong>" % m.group(1), out)
out = ITALIC.sub(lambda m: "<em>%s</em>" % m.group(1), out)
def image(match):
src = _safe_href(html.unescape(match.group(2)), image=True)
if src is None:
return match.group(1)
return '<img src="%s" alt="%s">' % (
html.escape(src, quote=True), html.escape(match.group(1), quote=True)
)
out = IMAGE.sub(image, out)
def link(match):
href = _safe_href(html.unescape(match.group(2)))
if href is None:
return match.group(1)
return '<a href="%s" rel="noreferrer noopener">%s</a>' % (
html.escape(href, quote=True),
match.group(1),
)
return LINK.sub(link, out)
def markdown_blocks(text):
"""Split Markdown into renderable block dicts. Deliberately a subset."""
lines = text.replace("\r\n", "\n").split("\n")
blocks = []
index = 0
total = len(lines)
while index < total:
line = lines[index]
stripped = line.strip()
if not stripped:
index += 1
continue
if stripped.startswith("```"):
lang = stripped[3:].strip()
body = []
index += 1
while index < total and not lines[index].strip().startswith("```"):
body.append(lines[index])
index += 1
index += 1
blocks.append({"type": "code", "lang": lang, "text": "\n".join(body)})
continue
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
blocks.append(
{
"type": "heading",
"level": len(heading.group(1)),
"text": heading.group(2).strip(),
}
)
index += 1
continue
if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
blocks.append({"type": "hr", "text": ""})
index += 1
continue
if stripped.startswith(">"):
body = []
while index < total and lines[index].strip().startswith(">"):
body.append(re.sub(r"^\s*>\s?", "", lines[index]))
index += 1
blocks.append({"type": "quote", "text": "\n".join(body).strip()})
continue
if stripped.startswith("|") and index + 1 < total and re.match(
r"^\s*\|[\s:|-]+\|\s*$", lines[index + 1]
):
rows = []
while index < total and lines[index].strip().startswith("|"):
rows.append(lines[index].strip())
index += 1
blocks.append({"type": "table", "rows": rows, "text": "\n".join(rows)})
continue
bullet = re.match(r"^\s*([-*+]|\d+[.)])\s+", line)
if bullet:
ordered = not bullet.group(1) in ("-", "*", "+")
items = []
while index < total and re.match(r"^\s*([-*+]|\d+[.)])\s+", lines[index]):
items.append(re.sub(r"^\s*([-*+]|\d+[.)])\s+", "", lines[index]))
index += 1
blocks.append(
{"type": "list", "ordered": ordered, "items": items,
"text": "\n".join(items)}
)
continue
body = []
while index < total and lines[index].strip() and not re.match(
r"^\s*(#{1,6}\s|>|```|\||[-*+]\s|\d+[.)]\s|-{3,}$)", lines[index]
):
body.append(lines[index].strip())
index += 1
if not body: # defensive: never spin on an unconsumed line
body = [stripped]
index += 1
blocks.append({"type": "para", "text": " ".join(body)})
return blocks
def render_block(block, block_id):
attr = ' data-hg="%s"' % block_id
kind = block["type"]
if kind == "heading":
return "<h%d%s>%s</h%d>" % (
block["level"], attr, inline(block["text"]), block["level"]
)
if kind == "para":
return "<p%s>%s</p>" % (attr, inline(block["text"]))
if kind == "code":
return '<pre%s><code>%s</code></pre>' % (
attr, html.escape(block["text"], quote=False)
)
if kind == "quote":
return "<blockquote%s>%s</blockquote>" % (attr, inline(block["text"]))
if kind == "hr":
return "<hr%s>" % attr
if kind == "list":
tag = "ol" if block["ordered"] else "ul"
items = "".join("<li>%s</li>" % inline(i) for i in block["items"])
return "<%s%s>%s</%s>" % (tag, attr, items, tag)
if kind == "table":
rows = [
[cell.strip() for cell in row.strip().strip("|").split("|")]
for row in block["rows"]
]
head, body = rows[0], rows[2:]
out = ["<table%s><thead><tr>" % attr]
out += ["<th>%s</th>" % inline(c) for c in head]
out.append("</tr></thead><tbody>")
for row in body:
out.append("<tr>" + "".join("<td>%s</td>" % inline(c) for c in row) + "</tr>")
out.append("</tbody></table>")
return "".join(out)
return "<p%s>%s</p>" % (attr, inline(block.get("text", "")))
# ------------------------------------------------------------------- html
def sanitize_attrs(attrs):
"""Strip event handlers and unsafe URL schemes from a reviewed HTML tag.
The Markdown path scheme-allowlists every link through _safe_href. Raw HTML
input has to clear the same bar: without this an artifact containing
`<img src=x onerror=...>` or `<a href="javascript:...">` executes inside the
review page the moment the reviewer opens it and reviewing a landing-page
draft is a documented use of this skill.
"""
kept = []
names = {n.lower() for n, _ in attrs}
blank_target = any(
n.lower() == "target" and (v or "").lower() == "_blank" for n, v in attrs
)
for name, value in attrs:
lower = name.lower()
if lower.startswith("on") or lower in DROP_ATTRS:
continue
if lower in RESERVED_ATTRS:
continue
if lower == "id" and (value or "").strip().lower() in RESERVED_IDS:
continue
if lower in URL_ATTRS:
safe = _safe_href(value or "", image=(lower in ("src", "poster", "background")))
if safe is None:
continue
value = safe
kept.append((name, value))
# Same hardening the Markdown path already applies to its own anchors.
if blank_target and "rel" not in names:
kept.append(("rel", "noreferrer noopener"))
return kept
class BlockTagger(HTMLParser):
"""Re-emit an HTML body, tagging top-level block elements with data-hg ids."""
# The complete HTML void-element set. A hand-picked subset is how <base>
# kept swallowing the body after the <meta>/<link> fix: any DROP_TAGS entry
# missing from here never fires handle_endtag, so its skip counter is never
# released. Spec list, so the class cannot recur one tag at a time.
VOID = (
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr", "frame",
)
def __init__(self, fragment=False):
super().__init__(convert_charrefs=False)
self.out = []
self.depth = 0
self.count = 0
self.blocks = []
self._capture = None
# A fragment with no <body> is all body: start capturing immediately.
self._in_body = fragment
self._skip = 0
# Name of the last unterminated drop-tag, for a diagnosable warning
# rather than a silent truncation (an unclosed <script> legitimately
# swallows the rest per HTML parsing, but the user deserves to know).
self._skipping = []
def handle_starttag(self, tag, attrs):
if tag == "body":
self._in_body = True
return
if tag in DROP_TAGS:
# A void drop-tag (<meta>, <link>, <base>) never fires a matching
# handle_endtag, so bumping _skip here would strand it above zero
# for the rest of the parse and silently swallow the entire body.
# Every real HTML5 head has a bare <meta charset>, so this is the
# common case, not an edge case.
if tag not in self.VOID:
self._skip += 1
self._skipping.append(tag)
return
if self._skip:
return
# Depth is measured *inside* the body only. Counting the wrapping
# <html> would push every real block to depth 1, and nothing would
# ever be tagged.
if not self._in_body:
return
rebuilt = "".join(
' %s="%s"' % (k, html.escape(v or "", quote=True))
for k, v in sanitize_attrs(attrs)
)
if self.depth == 0 and tag in BLOCK_TAGS:
self.count += 1
block_id = "b%d" % self.count
self._capture = {"id": block_id, "text": []}
rebuilt += ' data-hg="%s"' % block_id
self.out.append("<%s%s>" % (tag, rebuilt))
if tag not in self.VOID:
self.depth += 1
def handle_endtag(self, tag):
if tag == "body":
self._in_body = False
return
if tag in DROP_TAGS:
self._skip = max(0, self._skip - 1)
if self._skipping and self._skipping[-1] == tag:
self._skipping.pop()
return
if self._skip or not self._in_body:
return
if tag in self.VOID:
return
self.depth = max(0, self.depth - 1)
self.out.append("</%s>" % tag)
if self.depth == 0 and self._capture:
self.blocks.append(
{"id": self._capture["id"],
"text": " ".join("".join(self._capture["text"]).split())}
)
self._capture = None
def handle_startendtag(self, tag, attrs):
if self._skip or not self._in_body:
return
if tag in DROP_TAGS:
return
rebuilt = "".join(
' %s="%s"' % (k, html.escape(v or "", quote=True))
for k, v in sanitize_attrs(attrs)
)
if self.depth == 0 and tag in BLOCK_TAGS:
self.count += 1
rebuilt += ' data-hg="b%d"' % self.count
self.blocks.append({"id": "b%d" % self.count, "text": ""})
self.out.append("<%s%s>" % (tag, rebuilt))
def handle_data(self, data):
if self._skip or not self._in_body:
return
self.out.append(html.escape(data, quote=False))
if self._capture:
self._capture["text"].append(data)
def handle_entityref(self, name):
if self._in_body and not self._skip:
self.out.append("&%s;" % name)
def handle_charref(self, name):
if self._in_body and not self._skip:
self.out.append("&#%s;" % name)
def build_content(source_text, is_markdown):
"""Return (content_html, [{'id','text'}, ...])."""
if is_markdown:
blocks = markdown_blocks(source_text)
rendered, index = [], []
for position, block in enumerate(blocks, start=1):
block_id = "b%d" % position
rendered.append(render_block(block, block_id))
index.append({"id": block_id, "text": " ".join(block.get("text", "").split())})
return "\n".join(rendered), index
fragment = not re.search(r"<body\b", source_text, re.IGNORECASE)
tagger = BlockTagger(fragment=fragment)
tagger.feed(source_text)
tagger.close()
if tagger._skipping:
sys.stderr.write(
"Warning: unterminated <%s> in the source — everything after it was "
"dropped. Close the tag if that content should be reviewable.\n"
% tagger._skipping[0]
)
body = "".join(tagger.out).strip()
if not tagger.blocks and body:
body = '<div data-hg="b1">%s</div>' % body
tagger.blocks = [{"id": "b1", "text": " ".join(re.sub(r"<[^>]+>", " ", body).split())}]
return body, tagger.blocks
# -------------------------------------------------------------- page shell
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review __TITLE__</title>
<style>
:root {
--bg:#fdfcfa; --surface:#fff; --border:#e4e2db; --text:#1b1a16; --muted:#6b6862;
--accent:#295fcc; --blocker:#b3261e; --major:#a45c00; --minor:#3d6b2e; --nit:#6b6862;
}
@media (prefers-color-scheme: dark) {
:root { --bg:#16151a; --surface:#1e1d24; --border:#33313c; --text:#eceaf2;
--muted:#a5a1b0; --accent:#8ab0ff; --blocker:#ff8a80; --major:#f0b357;
--minor:#8fd07a; --nit:#a5a1b0; }
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:16px/1.65 -apple-system,
BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
header { position:sticky; top:0; z-index:20; display:flex; gap:12px; align-items:center;
flex-wrap:wrap; padding:10px 16px; background:var(--surface);
border-bottom:1px solid var(--border); }
header h1 { font-size:15px; margin:0; font-weight:600; }
header .sp { flex:1; }
input, select, textarea, button { font:inherit; color:inherit; background:var(--surface);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
button { cursor:pointer; }
button.primary { background:var(--accent); border-color:var(--accent); color:#fff;
font-weight:600; }
#counts { font-size:13px; color:var(--muted); }
main { display:grid; grid-template-columns:minmax(0,1fr) 380px; gap:0; align-items:start; }
#doc { padding:32px 28px 120px; max-width:78ch; }
#doc [data-hg] { position:relative; border-radius:6px; transition:background .12s; }
#doc [data-hg]:hover { background:color-mix(in srgb, var(--accent) 7%, transparent); }
#doc [data-hg].tagged { box-shadow:inset 3px 0 0 var(--accent); padding-left:10px; }
#doc [data-hg].active { background:color-mix(in srgb, var(--accent) 14%, transparent); }
#doc h1,#doc h2,#doc h3 { line-height:1.25; margin:1.5em 0 .5em; }
#doc h2 { border-bottom:1px solid var(--border); padding-bottom:.2em; }
#doc pre { background:color-mix(in srgb, var(--text) 6%, transparent); padding:12px 14px;
border-radius:8px; overflow-x:auto; }
#doc code { font:.88em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
#doc table { border-collapse:collapse; width:100%; }
#doc th,#doc td { border:1px solid var(--border); padding:6px 10px; text-align:left; }
#doc blockquote { margin:1em 0; padding:.1em 1em; border-left:3px solid var(--border);
color:var(--muted); }
#doc img { max-width:100%; height:auto; }
aside { position:sticky; top:53px; height:calc(100vh - 53px); overflow-y:auto;
border-left:1px solid var(--border); background:var(--surface); padding:14px; }
.item { border:1px solid var(--border); border-radius:8px; padding:10px; margin-bottom:10px; }
.item .row { display:flex; gap:6px; align-items:center; margin-bottom:6px; }
.item .row select { flex:0 0 auto; }
.item .blk { font:12px ui-monospace,monospace; color:var(--muted); flex:1; }
.item textarea { width:100%; min-height:64px; resize:vertical; }
.item blockquote { margin:0 0 6px; padding:4px 8px; border-left:2px solid var(--accent);
font-size:13px; color:var(--muted); max-height:4.5em; overflow:hidden; }
.sev-BLOCKER { color:var(--blocker); font-weight:700; }
.sev-MAJOR { color:var(--major); font-weight:600; }
.sev-MINOR { color:var(--minor); }
.sev-NIT, .sev-EDIT, .sev-NOTE { color:var(--nit); }
.empty { color:var(--muted); font-size:14px; padding:8px 4px; }
#toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
background:var(--text); color:var(--bg); padding:9px 16px; border-radius:8px;
font-size:14px; opacity:0; pointer-events:none; transition:opacity .2s; }
#toast.on { opacity:1; }
@media (max-width:900px) {
main { grid-template-columns:1fr; }
aside { position:static; height:auto; border-left:0; border-top:1px solid var(--border); }
}
</style>
</head>
<body>
<header>
<h1>Review: __TITLE__</h1>
<span id="counts">no feedback yet</span>
<span class="sp"></span>
<label>Reviewer <input id="reviewer" placeholder="your name" size="14"></label>
<button id="add-note">Overall note</button>
<button id="copy">Copy</button>
<button id="export" class="primary">Export feedback</button>
</header>
<main>
<div id="doc">__CONTENT__</div>
<aside><div id="items"></div><div class="empty" id="hint">
Click any block in the document to leave feedback on it.</div></aside>
</main>
<div id="toast"></div>
<script>
const CFG = __CONFIG__;
const KEY = "human-gate:" + CFG.target;
const SEV = ["BLOCKER","MAJOR","MINOR","NIT","EDIT"];
let state = { reviewer:"", items:[], note:"" };
try { const saved = localStorage.getItem(KEY); if (saved) state = JSON.parse(saved); }
catch (e) { /* private mode or corrupt entry start clean */ }
const save = () => { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch(e){} };
const blockText = id => (CFG.blocks.find(b => b.id === id) || {}).text || "";
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg; el.classList.add("on");
setTimeout(() => el.classList.remove("on"), 1600);
}
function render() {
const wrap = document.getElementById("items");
wrap.innerHTML = "";
document.querySelectorAll("#doc [data-hg]").forEach(el => el.classList.remove("tagged"));
state.items.forEach((item, i) => {
const node = document.getElementById("doc").querySelector('[data-hg="'+item.block+'"]');
if (node) node.classList.add("tagged");
const box = document.createElement("div");
box.className = "item";
const row = document.createElement("div");
row.className = "row";
const sel = document.createElement("select");
SEV.forEach(s => {
const opt = document.createElement("option");
opt.value = s; opt.textContent = s;
if (s === item.severity) opt.selected = true;
sel.appendChild(opt);
});
sel.className = "sev-" + item.severity;
sel.onchange = () => { item.severity = sel.value; save(); render(); };
const blk = document.createElement("span");
blk.className = "blk"; blk.textContent = item.block;
blk.onclick = () => node && node.scrollIntoView({behavior:"smooth", block:"center"});
const del = document.createElement("button");
del.textContent = "×"; del.title = "remove";
del.onclick = () => { state.items.splice(i,1); save(); render(); };
row.append(sel, blk, del);
box.appendChild(row);
if (item.quote) {
const q = document.createElement("blockquote");
q.textContent = item.quote;
box.appendChild(q);
}
if (item.severity === "EDIT") {
const before = document.createElement("textarea");
before.placeholder = "before (exact current text)";
before.value = item.before || "";
before.oninput = () => { item.before = before.value; save(); };
const after = document.createElement("textarea");
after.placeholder = "after (your exact replacement — carried across verbatim)";
after.value = item.after || "";
after.oninput = () => { item.after = after.value; save(); };
box.append(before, after);
} else {
const body = document.createElement("textarea");
body.placeholder = "what needs to change, and why";
body.value = item.feedback || "";
body.oninput = () => { item.feedback = body.value; save(); update(); };
box.appendChild(body);
}
wrap.appendChild(box);
});
const noteBox = document.createElement("div");
noteBox.className = "item";
const noteLabel = document.createElement("div");
noteLabel.className = "row"; noteLabel.innerHTML = "<span class='blk'>OVERALL NOTE</span>";
const note = document.createElement("textarea");
note.placeholder = "unanchored commentary (optional)";
note.value = state.note || "";
note.oninput = () => { state.note = note.value; save(); };
noteBox.append(noteLabel, note);
wrap.appendChild(noteBox);
document.getElementById("hint").style.display = state.items.length ? "none" : "block";
update();
}
function update() {
const tally = {};
state.items.forEach(i => { tally[i.severity] = (tally[i.severity]||0) + 1; });
const parts = SEV.filter(s => tally[s]).map(s => tally[s] + " " + s);
document.getElementById("counts").textContent =
parts.length ? parts.join(" · ") : "no feedback yet";
}
function addItem(blockId, quote) {
state.items.push({ block:blockId, severity:"MINOR", quote:quote||"", feedback:"",
before:"", after:"" });
save(); render();
const boxes = document.querySelectorAll("#items .item textarea");
if (boxes.length) boxes[boxes.length - 2] && boxes[boxes.length - 2].focus();
}
document.getElementById("doc").addEventListener("click", event => {
if (event.target.closest("a")) return;
const node = event.target.closest("[data-hg]");
if (!node) return;
const picked = String(window.getSelection());
addItem(node.getAttribute("data-hg"), picked.trim() || "");
});
document.getElementById("reviewer").oninput = e => { state.reviewer = e.target.value; save(); };
document.getElementById("add-note").onclick = () => {
document.querySelector("#items .item:last-child textarea").focus();
};
function sidecar() {
const lines = [];
lines.push("# Review feedback: " + CFG.target);
lines.push("<!-- human-gate:v1 target=" + CFG.target + " round=" + CFG.round + " -->");
lines.push("");
lines.push("reviewer: " + (state.reviewer || ""));
lines.push("");
state.items.forEach(item => {
lines.push("## " + item.severity + " " + item.block);
if (item.quote) {
String(item.quote).split("\\n").forEach(l => lines.push("> " + l));
}
if (item.severity === "EDIT") {
lines.push("- before: " + (item.before || ""));
lines.push("+ after: " + (item.after || ""));
if (item.feedback) lines.push(item.feedback);
} else if (item.feedback) {
lines.push(item.feedback);
}
lines.push("");
});
if (state.note && state.note.trim()) {
lines.push("## NOTE");
lines.push(state.note.trim());
lines.push("");
}
return lines.join("\\n");
}
document.getElementById("copy").onclick = async () => {
const text = sidecar();
try { await navigator.clipboard.writeText(text); toast("Copied — paste into " + CFG.sidecar); }
catch (e) {
const ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand("copy"); ta.remove();
toast("Copied — paste into " + CFG.sidecar);
}
};
document.getElementById("export").onclick = () => {
const blob = new Blob([sidecar()], {type:"text/markdown"});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = CFG.sidecar.split("/").pop();
a.click();
URL.revokeObjectURL(a.href);
toast("Saved — move it next to the artifact as " + CFG.sidecar);
};
document.getElementById("reviewer").value = state.reviewer || "";
render();
</script>
</body>
</html>
"""
def build_page(source_text, target_name, sidecar_name, is_markdown, round_number=1):
content, blocks = build_content(source_text, is_markdown)
if not blocks:
return None, []
config = {
"target": target_name,
"sidecar": sidecar_name,
"round": round_number,
"blocks": blocks,
}
# One pass, so a value that happens to contain another token — a document
# about this very skill mentioning __TITLE__, or a block whose text lands
# in the JSON config — can never be re-substituted. Sequential replaces
# injected the whole config object into the visible body for such a doc.
slots = {
"__CONTENT__": content,
"__TITLE__": html.escape(target_name, quote=False),
# json.dumps output is embedded in a <script>; neutralise any "</script>"
"__CONFIG__": json.dumps(config).replace("</", "<\\/"),
}
page = re.sub(
r"__(?:CONTENT|TITLE|CONFIG)__", lambda m: slots[m.group(0)], PAGE
)
return page, blocks
def run_sample(output_dir=None):
"""Build both built-in fixtures and report block counts.
The HTML fixture is a full DOCTYPE document with a bare <meta charset> and
<link rel=stylesheet> the shape every real HTML5 page has, and the exact
shape that once silently produced zero blocks. Exercising it here means a
regression fails loudly instead of surfacing as "No reviewable blocks".
Writes into a temp dir unless --output names one, so a sample run never
litters the caller's working directory.
"""
import tempfile
target_dir = output_dir or tempfile.mkdtemp(prefix="human-gate-sample-")
os.makedirs(target_dir, exist_ok=True)
# Each fixture asserts a block count plus the URL-handling contract: what
# must survive sanitization and what must not. The allowances are as much a
# decision as the blocks are, so they are pinned here rather than left to
# prose — a reviewed artifact's own assets have to load for the review to be
# faithful, and a protocol-relative URL cannot execute.
fixtures = [
("quarterly-plan.md", SAMPLE_MD, True, 6, [], []),
("landing.html", SAMPLE_HTML, False, 4,
['href="//cdn.example.com/pricing"', 'src="https://img.example.com/hero.png"'],
["javascript:"]),
]
failures = []
for name, source, is_md, expected, must_keep, must_drop in fixtures:
sidecar = os.path.splitext(name)[0] + ".review.md"
page, blocks = build_page(source, name, sidecar, is_md, 1)
out_path = os.path.join(target_dir, os.path.splitext(name)[0] + ".review.html")
if page is None:
print(" %-20s FAIL — no reviewable blocks" % name)
failures.append(name)
continue
with open(out_path, "w", encoding="utf-8") as handle:
handle.write(page)
problems = []
if len(blocks) != expected:
problems.append("expected %d blocks" % expected)
problems += ["dropped %s" % k for k in must_keep if k not in page]
problems += ["kept %s" % d for d in must_drop if d in page]
status = "ok" if not problems else "FAIL — " + "; ".join(problems)
if problems:
failures.append(name)
print(" %-20s %d blocks, %5.1f KB %s"
% (name, len(blocks), len(page.encode("utf-8")) / 1024.0, status))
print("")
print("Wrote to %s" % target_dir)
if failures:
sys.stderr.write("Sample regression: %s\n" % ", ".join(failures))
return 2
return 0
def main(argv=None):
parser = argparse.ArgumentParser(
description="Build a single-file HTML review page for a Markdown or HTML artifact.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("artifact", nargs="?", help="path to the .md or .html file")
parser.add_argument("--output", help="where to write the review page")
parser.add_argument("--round", type=int, default=1, help="review round number")
parser.add_argument(
"--sidecar", help="sidecar filename to advertise (default <artifact>.review.md)"
)
parser.add_argument(
"--launch", action="store_true", help="open the page in the default browser"
)
parser.add_argument(
"--sample", action="store_true",
help="build both built-in fixtures (Markdown + a full HTML5 doc) and check them"
)
args = parser.parse_args(argv)
if args.sample:
return run_sample(args.output)
if args.artifact:
try:
with open(args.artifact, "r", encoding="utf-8") as handle:
source = handle.read()
except OSError as err:
parser.error("cannot read %s: %s" % (args.artifact, err))
return 1
name = os.path.basename(args.artifact)
is_md = os.path.splitext(name)[1].lower() in (".md", ".markdown")
base = os.path.splitext(args.artifact)[0]
out_path = args.output or (base + ".review.html")
else:
parser.error("provide an artifact path or --sample")
return 1
sidecar = args.sidecar or (os.path.splitext(name)[0] + ".review.md")
page, blocks = build_page(source, name, sidecar, is_md, args.round)
if page is None:
sys.stderr.write("No reviewable blocks found in %s\n" % name)
return 2
try:
with open(out_path, "w", encoding="utf-8") as handle:
handle.write(page)
except OSError as err:
parser.error("cannot write %s: %s" % (out_path, err))
return 1
size_kb = len(page.encode("utf-8")) / 1024.0
print("Review page: %s" % out_path)
print("Blocks: %d" % len(blocks))
print("Size: %.1f KB (single file, no network requests)" % size_kb)
print("Sidecar: %s <- export lands here, or write it by hand" % sidecar)
if args.launch:
import webbrowser
webbrowser.open("file://" + os.path.abspath(out_path))
return 0
if __name__ == "__main__":
sys.exit(main())