mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
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:
commit
4d7df8adeb
20 changed files with 3498 additions and 12 deletions
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
155
CHANGELOG.md
155
CHANGELOG.md
|
|
@ -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 **G1–G7**: 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 ``
|
||||
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 7–8 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
18
README.md
18
README.md
|
|
@ -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>.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#personas)
|
||||
[](#commands)
|
||||
[](#commands)
|
||||
[](https://github.com/alirezarezvani/claude-skills/stargazers)
|
||||
[](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.
|
||||
|
|
|
|||
179
audit/human-review-2026-08/AUDIT.md
Normal file
179
audit/human-review-2026-08/AUDIT.md
Normal 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.
|
||||
12
engineering/human-gate/.claude-plugin/authoring-notes.json
Normal file
12
engineering/human-gate/.claude-plugin/authoring-notes.json
Normal 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."
|
||||
}
|
||||
}
|
||||
15
engineering/human-gate/.claude-plugin/plugin.json
Normal file
15
engineering/human-gate/.claude-plugin/plugin.json
Normal 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"
|
||||
]
|
||||
}
|
||||
151
engineering/human-gate/README.md
Normal file
151
engineering/human-gate/README.md
Normal 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).
|
||||
104
engineering/human-gate/agents/cs-human-gate.md
Normal file
104
engineering/human-gate/agents/cs-human-gate.md
Normal 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.
|
||||
128
engineering/human-gate/commands/cs-human-gate.md
Normal file
128
engineering/human-gate/commands/cs-human-gate.md
Normal 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.
|
||||
100
engineering/human-gate/skills/human-gate/SKILL.md
Normal file
100
engineering/human-gate/skills/human-gate/SKILL.md
Normal 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.
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
-->
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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())
|
||||
639
engineering/human-gate/skills/human-gate/scripts/human_gate.py
Normal file
639
engineering/human-gate/skills/human-gate/scripts/human_gate.py
Normal 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())
|
||||
|
|
@ -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())
|
||||
Loading…
Add table
Reference in a new issue