Skip to content

chore: adopt the agent-harness copier template v0.7.0-3-g0fa3c56 - #4

Open
egparedes wants to merge 10 commits into
mainfrom
ao/devmm-4/template-simplification
Open

egparedes wants to merge 10 commits into
mainfrom
ao/devmm-4/template-simplification

Conversation

@egparedes

@egparedes egparedes commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Feature

Harness maintenance — no development/work/ unit. Supersedes and closes #3,
which took the repo to template v0.6.0; this branch carries those five commits
forward and lands on template v0.7.0-3-g0fa3c56 — the v0.7.0 release
plus the three post-release fixes this adoption prompted upstream.

docs/ and work/ move into development/, leaving docs/ for user
documentation (docs/api.md) as the new convention reserves it, and the harness
tracks template v0.7.0-3-g0fa3c56.

Why one branch instead of merging #3 first

The three upstream releases arrived while #3 was open, and two of them rewrote
the same files #3 touched. Stacking the reconciliation on top of #3 keeps a
single reviewable end state instead of a merge-then-immediately-rewrite pair.
#3's own CI stayed green throughout; nothing in it is discarded except patches
upstream has since superseded (below).

The migration is a separate first commit, deliberately

A bare copier update exits 0, reports no conflict, and deletes
docs/architecture.md, docs/style.md, docs/testing.md and
docs/tool-bootstrap.md, substituting template scaffolds at the new path —
_skip_if_exists does not cover the delete side of a rename. Moving the tree
first (ae0c46d) puts those files where _skip_if_exists can see them.
Verified both ways on throwaway clones before touching a real branch, and
re-verified after each of the three template bumps.

Line counts against 1beb326: architecture.md 78 → 78, style.md 133 → 133,
tool-bootstrap.md 122 → 126, testing.md 107 → 121 (grafted sections).

Absorbed from v0.6.0 → v0.7.0

  • report.md as a fifth work-unit artifact; decision register in
    development/adr/README.md, seeded with the two decisions this repo had
    already granted; development/glossary.md; document-liveness and authority
    rules.
  • Simplification wave: eleven copier answers dropped (license, mode,
    project_slug, pr_merge_strategy, the include_example_* gates, cursor,
    mcp, …), generate_scripts derived from verify_command, 13 questions
    left. The four role playbook skills fold into their subagent files, leaving
    design-principles as the only skill. The verify skill is gone — capability
    not replaced, accepted deliberately.
  • One hand-back convention: HANDBACK(<spike|explore|replan>): with flat
    per-kind caps each role restates in its own reply, so the bound survives
    description-match invocation.
  • Hook payload reader (v0.7.0): all three Claude hooks read input via
    .agents/hooks/hook-input.sh (jq, then python3) and branch on its exit
    code.

Eight defects this repo reported upstream, all fixed there

Found while adopting the template; filed rather than patched locally, so the
fixes ship for every downstream repo. The last four were found reviewing this
branch, and their fixes are what this PR now pins to.

Upstream Was
#25 plan.md's Review checklist had unbounded authority over the reviewer's verdict rules
#26 Hand-back loops unserviced and uncapped outside the slash commands
#27 Loop round caps lived only in the slash commands, which the subagents state may never have been read
#31 jq an undocumented hard requirement: Bash guard denied everything, Stop loop-guard defeated, format-on-edit silently off
#33 _skip_if_exists matched a bare README.md at every depth, freezing the template's own nested READMEs downstream
#34 The reader's two backends disagreed on objects, arrays and numbers, contradicting its documented parity contract
#35 The reader committed to jq on command -v alone, so a broken jq failed the read; the Stop hook skipped the gate on any reader failure
#36 The deny-list matched inside quoted arguments, blocking read-only commands that merely mention a pattern

The local patches for these are retired in favour of upstream's, which are
better in three specific ways this repo got wrong:

  • Fail-open hook skips must exit 1, not 0 — Claude Code surfaces only
    non-zero stderr, so the local warnings would never have been seen.
  • The local block-destructive.sh used grep -o to name the matched pattern.
    -o is non-POSIX and GNU grep suppresses its stdout on binary-classified
    input, so it could match and report nothing — failing open on exactly the
    input a deny-list matters for. Upstream keeps the decision on POSIX
    grep -qE and names the whole deny-list instead.
  • hook-input.sh probes python3 by running it: stock macOS ships a CLT stub
    that passes command -v but fails at runtime.

.claude/settings.json, .opencode/opencode.jsonc and all three
.agents/hooks/* scripts are byte-identical to a pristine render of the pinned
template commit — verified file by file, not assumed.

Absorbed from v0.7.0 → v0.7.0-3-g0fa3c56

  • _skip_if_exists is root-anchored. Under gitignore semantics a bare
    README.md matched at every depth, so .agents/README.md,
    .claude/rules/README.md and development/README.md were silently frozen
    downstream with no conflict reported in either direction. .agents/README.md
    here was 63 lines stale because of it.
  • The deny-list matches operations outside quotes, so grep -rn 'rm -rf' .
    passes while cd x && rm -rf y is denied, with a fallback for the string a
    nested shell runs (sh -c, ssh, eval, su, or a pipe into a shell). The
    SQL pattern still matches anywhere: it has no unquoted form, so its mention
    and its use are indistinguishable.
  • The payload reader probes each backend by running it, so a jq that
    resolves but is broken falls through to python3; and the Stop gate
    reports instead of skipping
    — a gate that cannot be blocked still runs and
    says so, rather than ending the session unverified.

.agents/README.md takes the template's version wholesale: upstream's rewrite
generalises the local caveat this branch carried (hooks are template-owned) to
all of .agents/, and adds the safe-to-edit list.
development/harness-usage.md and development/tool-bootstrap.md are
_skip_if_exists, so the new behaviour is ported by hand. The Stop-hook
paragraph is rewritten rather than replaced — upstream covers the reader path
but not the uv-unavailable path, which still skips the gate on exit 1.

Deliberate divergence (six items)

All in territory ADR 0012
states was left untouched: the decision-register row owner (no role could write
it), the marker-less-row exemption (both seeded rows are that kind), the
no-work-unit review axes (a harness PR like this one had no defined behaviour),
the location-scoped DECISION-PENDING: definition, gate-output pointers to
development/testing.md (which records the sanctioned DEVMM_GPU skips), and
the architect's Write-to-create exception for scratch.md.

Plus AGENTS.md's BSD-3-Clause line and squash-merge guidance — both lost to
deleted questions — and devmm's stricter rule that a required runtime
dependency needs an ADR, since the empty required-dependency set is an invariant
tests/test_packaging.py enforces.

Definition of done

  • Gate green: make verify, exit 0 — 900 passed, 75 skipped
  • Every success criterion evidenced — checks below
  • report.md written — n/a, no work unit; deviations declared here
  • No test, tolerance or assertion weakened. The 75 skips are the CUDA and
    ROCm suites off hardware (ADR 0003), unchanged from 1beb326; the
    tests/ diff is prose-only path references
  • Every DECISION-PENDING: line has a register row — none added; the
    register is seeded with two pre-existing accepted decisions
  • New structural decisions have an ADR — none; this adopts an upstream
    layout rather than deciding one

Also verified: every relative Markdown link in every tracked .md resolves;
.claude/.opencode symlinks intact; docs/api.md doctests green; the hook
wiring exercised across all parser scenarios (jq, python3-only, broken jq,
neither) for allow, deny and the Stop loop guard; AGENTS.md at 127 lines.

Every claim this branch adds to harness-usage.md and tool-bootstrap.md was
checked against the scripts themselves rather than read off the upstream diff:
13 deny-list cases (quoted mention, bare operation, nested-shell runner, pipe
into a shell, \rm alias bypass, the push-ends-in-sh non-match) and the
reader's exit codes for broken jq (falls through), python3-only, no working
parser (3) and an empty payload (4).

Deviations & notes for the reviewer

  • .copier-answers.yml loses ten answers. Expected — those questions no
    longer exist upstream.
  • The verify skill is gone. Gate failures now have no triage helper;
    make verify runs the gate and nothing summarises the failure.
  • development/tool-bootstrap.md and development/harness-usage.md are
    _skip_if_exists
    , so upstream changes to them never arrive on update. Both
    ported by hand, twice now.
  • development/README.md is template-owned and not _skip_if_exists. It
    carries local content here (the sdist/wheel note). Upstream did not change it
    in this bump, so nothing was lost; a future bump that does will surface as a
    merge conflict rather than a silent overwrite.
  • Two problems surfaced only in verification and are fixed here: 65 links broke
    because work units gained a directory level (../ → ../../), and the
    .gitignore managed block briefly held a duplicate entry once post_gen.py
    became incremental.

🤖 Generated with Claude Code

egparedes and others added 6 commits July 25, 2026 22:44
Adopts the template's development/ tree: the agent-facing docs, ADRs and
per-feature work units move out of docs/ and work/, leaving docs/ for user
documentation (docs/api.md) as the new convention reserves it.

The move is done as a pre-step so _skip_if_exists protects the
project-authored docs; a bare `copier update` deletes them and substitutes
the template scaffolds, because _skip_if_exists does not cover the delete
side of a rename.

Harness changes absorbed from v0.6.0:

- report.md as a fifth work-unit artifact, owned by the Developer and
  audited by the Reviewer for honesty.
- DECISION-PENDING: escalation marker plus the decision register in
  development/adr/README.md, seeded with the two decisions already granted
  (the ADR 0003 GPU-suite waiver and the coverage thresholds).
- development/glossary.md, the document-liveness table, and the
  architecture > spec > plan > tasks authority order.
- Role playbook skills (product-owner, architect, developer, reviewer) over
  a shared design-principles core.
- PreToolUse hook fails closed when jq cannot parse the tool input.
- .github/PULL_REQUEST_TEMPLATE.md with the definition-of-done checklist.

AGENTS.md keeps devmm's stricter rule that a required runtime dependency
needs an ADR, rather than the template's softer ADR-bar wording: the empty
required-dependency set is a design invariant tests/test_packaging.py
enforces.

Gate: make verify green — 900 passed, 75 skipped (GPU suites, off hardware).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… contract

Review of #3 found the hardened PreToolUse guard bricks a session on any host
without jq: it fails closed, jq is not installed by the bootstrap, and the
denial covers the `apt-get install jq` that would fix it. Verified — with jq off
PATH, `make verify` returns exit 2. The pre-update wiring failed *open*
instead, allowing destructive commands through unchecked, so neither form was
right.

Keep fail-closed and make it self-explanatory:

- The guard checks for jq up front and names it as the reason, with the install
  command; the other two deny paths explain themselves too.
- block-destructive.sh reports which deny-list pattern matched, instead of a
  bare exit 2 that reads as an unexplained refusal and invites a reword-retry
  loop.
- ensure-toolchain.sh warns at SessionStart when jq is absent, so the problem
  surfaces before the first Bash call rather than as a mystery denial. Warning
  only — it must not abort the uv bootstrap.
- tool-bootstrap.md states jq is required, not merely standard.

The decision-register contract was self-defeating: it keyed on the literal
`DECISION-PENDING:` text, which this PR itself adds 15 times as documentation,
so `/verify` would have raised a dozen fabricated MAJOR defects while a real
escalation hid among the quotes. A marker is now defined by location — its own
line inside a development/work/*/report.md — with the scan command to match.
Also: register rows sourced from an ADR or a human grant are marker-less by
construction and no longer read as out-of-scope (both seeded rows are of that
kind); the Developer is named owner of the paired row, the only role that can
write it; and a PR with no work unit has the spec/plan/register axes marked n/a
rather than failed.

Remaining: gate-output rules now point at development/testing.md, which is
authoritative and records the sanctioned DEVMM_GPU skips; the Developer gets the
Architect's scratch.md clobber guard; development/README.md drops a
scaffold-marker section describing artifacts this repo no longer has and no
longer claims development/ is unpublished (the sdist ships it, the wheel does
not); README.md uses absolute links, as it is also the PyPI project page.

Gate: make verify green — 900 passed, 75 skipped, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tracks the template's post-v0.6.0 simplification wave (`v0.6.0-3-g1802347`,
untagged — a release is imminent, so re-running once it lands should be a
near-no-op). Three upstream commits: two are the issues filed from the #3
review (grAItools/harness-copier-template#28 bounds plan.md's Review checklist
to add-only; #29 binds and services every hand-back loop), plus the breaking
simplification wave itself.

Built on the v0.6.0 branch rather than main: the template still uses
`development/`, so that migration — the tree move, the reference rewrite, the
link-depth fixes — remains correct, and `_skip_if_exists` only protects the
project-authored docs because those files now exist. Re-doing it from main
would re-trigger the content deletion that migration exists to avoid.

Absorbed from upstream:

- Eleven copier answers dropped (license, mode, project_slug,
  pr_merge_strategy, the include_example_* gates, cursor, mcp,
  copilot_code_review_skill); generate_scripts is now derived from
  verify_command. 13 questions remain.
- The four role playbook skills are folded into their subagent files;
  `design-principles` is the only remaining skill. The `verify` skill is gone —
  its capability is not replaced, accepted deliberately.
- One hand-back convention, `HANDBACK(<spike|explore|replan>):` with flat
  per-kind caps that each role states in its own reply, so the bound survives
  description-match invocation.
- harness-usage.md loses its restatement sections (308 -> 187 lines) and is now
  the single home of the liveness table; the glossary states its promotion rule
  once.

Kept as deliberate divergence, because upstream has not fixed these:

- The PreToolUse jq guard and its refusal messages. settings.json.jinja is
  unchanged upstream, so the session-bricking deadlock from the #3 review is
  still live there; verified again here that a jq-less PATH denies `make verify`
  with a reason rather than silently.
- The register-row owner, the marker-less-row exemption, the no-work-unit review
  axes, and the location-scoped `DECISION-PENDING:` definition. ADR 0012 states
  the register contract was left untouched.
- development/README.md's publication accuracy (the sdist ships this tree, the
  wheel does not) and its scaffold-marker state.
- AGENTS.md's BSD-3-Clause line and squash-merge commit guidance, both lost to
  the deleted questions; and devmm's stricter required-dependency rule.

Upstream's developer.md Handoff supersedes the scratch.md clobber guard added
here in e245a65 — it carries the same guard plus the serviced explore
hand-back — so that patch is dropped in favour of theirs.

Gate: make verify green — 900 passed, 75 skipped, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l jq patch

Updates to template `v0.6.0-4-gfe487cc`, which fixes the jq deadlock reported
from the #3 review (grAItools/harness-copier-template#31, ADR 0013). Upstream
took the direction this repo argued for — remove the hard dependency rather than
document it — so the local patch is retired in favour of theirs.

`.claude/settings.json`, `.agents/hooks/block-destructive.sh` and
`.agents/hooks/ensure-toolchain.sh` are now byte-identical to a pristine
upstream render; the local jq guard and the SessionStart warning added here in
e245a65 are gone, superseded by:

- `.agents/hooks/hook-input.sh`, a canonical payload reader that tries `jq` then
  `python3`, with distinct exit codes (3 no parser, 4 unparseable) so each hook
  picks its own posture.
- Per-hook postures: PreToolUse fails closed naming the real cause, Stop and
  PostToolUse fail open. Their skips exit 1, not 0 — Claude Code surfaces
  non-zero stderr, while exit-0 stderr is transcript-only. The local patch got
  that wrong.
- `block-destructive.sh` naming the deny-list on POSIX `grep -qE`. The local
  version extracted the matched pattern with `grep -o`, which upstream rejected
  for good reason: `-o` is non-POSIX and GNU grep suppresses its stdout for
  binary-classified input, so the extraction-as-decision would fail open.

Verified across parser scenarios: `python3`-only now allows a benign command
where it previously denied every Bash call; no parser at all still denies, with
the install remedy; a destructive command is still denied under either backend;
and the Stop loop guard fires again under `python3`.

`development/tool-bootstrap.md` is `_skip_if_exists`, so upstream's new
required-tools bullet does not arrive on update — added by hand, per their
upgrade notes, and the stale "jq is required, not optional" wording this repo
had is corrected to name the `python3` fallback.

Unchanged local divergences, all in territory ADR 0012 states was deliberately
left untouched: the register-row owner, the marker-less-row exemption, the
no-work-unit review axes, the location-scoped `DECISION-PENDING:` definition,
the gate-output pointers to development/testing.md, and the architect's
Write-to-create exception for scratch.md.

Gate: make verify green — 900 passed, 75 skipped, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The simplification wave and the hook-payload fix are now released as v0.7.0,
which is the same commit (fe487cc) this branch already tracked — so this
records the tag in place of the pseudo-version and changes nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@egparedes egparedes left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: approving — ready to merge, no blocking findings

Verdict: ready to merge. Three MINOR findings below, all documentation/contract accuracy; none blocks the merge. (Posted as COMMENT because GitHub does not accept APPROVE on your own PR.)

Scope reviewed: 4a2dab3f against origin/main — 6 commits, 89 files, +1230/-492. I re-derived the claims in the description rather than taking them on trust.

Verified independently

  • Gate green. Exported the head tree and ran make verify: exit 0, format/lint/mypy --strict clean, 900 passed, 75 skipped in 9.65s. The 75 are exactly the DEVMM_GPU suites (36 CUDA + 6 integrations + 33 ROCm), unchanged from 1beb326 and sanctioned by ADR 0003. Nothing weakened — the tests/ diff is prose-only path references, with no marker, tolerance or assertion touched.
  • Links. Every relative Markdown link in every tracked .md at the head commit resolves: 0 broken, checked tree-wide.
  • No stale paths. No surviving reference to docs/{architecture,style,testing,tool-bootstrap,harness-usage,adr} or to a top-level work/<unit>; the three deleted .agents/*/README.md files have no remaining referrers; removing the verify skill leaves no dangling mention.
  • Hook wiring, exercised rather than reasoned about. Ran hook-input.sh under jq 1.6 and again under a PATH containing only python3, across: string, boolean true, boolean false, absent key, explicit null, path through a non-object, top-level array, unparseable payload, empty payload. Value and exit code agree on every scalar case, and both failure cases exit 4 under both backends. The wiring posture is right: the PreToolUse Bash guard denies on every non-zero reader exit (fails closed), while PostToolUse-fmt and Stop exit 1 (fail open, stderr surfaced). The old failure mode is not hypothetical — a command issued during this review that merely quoted a deny-listed pattern was refused with literally No stderr output, which is exactly what the new message fixes.
  • sdist claim. development/README.md asserts the tree travels in the sdist; I built one — 53 development/ entries in devmm-0.1.0.tar.gz, wheel unaffected (packages = ["src/devmm"]).
  • Internal consistency. The hand-back caps in AGENTS.md match the ones each role restates in architect.md, developer.md and product-owner.md; the OpenCode deny globs still mirror all four block-destructive.sh patterns.

Findings (MINOR, non-blocking)

  1. .agents/hooks/hook-input.sh — the documented backend-parity contract does not hold for objects, arrays or numbers. Inline below.
  2. development/glossary.md — ships empty while this PR wires it up as the vocabulary authority, with no route in for terms the code already uses. Inline below.
  3. .agents/README.md (not in this diff, so no inline anchor) — the Caveats section still lists .agents/hooks/* among the net-new files that are safe from copier update. This PR makes block-destructive.sh and ensure-toolchain.sh byte-identical to a pristine v0.7.0 render and adds hook-input.sh from the template, and 91d9e4b shows copier update rewriting ensure-toolchain.sh on its own. Hook scripts are template-owned now, so local edits there will be reverted — the opposite of what the caveat tells a contributor. Move .agents/hooks/* into the template-owned sentence above it.

Notes

  • Doing the tree move as a separate first commit (ae0c46d) so _skip_if_exists can see the project-authored docs is the right call, and the preserved line counts back it up. Same for retiring the local hook patches in favour of upstream: the grep -o reasoning in particular is correct, and failing open on binary-classified input is precisely the case a deny-list exists for.
  • Losing the verify skill is a real capability regression, but it is declared, /verify and make verify both survive, and CLAUDE.md was updated to stop advertising it. Fine as an accepted cost.
  • The description says AGENTS.md is 127 lines; it is 130. Immaterial.

Comment thread .agents/hooks/hook-input.sh Outdated
Comment thread development/glossary.md Outdated
Addresses the two actionable findings from the #4 review.

Glossary: it shipped empty while the same PR made it an authority — AGENTS.md
lists it under "Where things live" and the Reviewer is told to flag names
drifting from it, both pointing at a file with no terms. Worse, there was no
route in: entries may only be promoted from a reviewed spec's Glossary section,
and none of the 13 shipped work units has one, so the vocabulary already
load-bearing in the code could never enter under the stated rule.

Seeded from devmm-design.md §3 as an explicit one-time baseline, with the
promotion rule left governing everything after it. All 22 code identifiers cited
were checked against the package: 21 are in the public API, the rest resolves in
src/. This also makes development/README.md's claim that every document in its
table carries real devmm content true.

.agents/README.md was 63 lines stale — missing the entire Layout section the
simplification wave merged in from the four deleted per-directory READMEs.
Root cause: copier's `_skip_if_exists` lists a bare `README.md`, which matches
every README at any depth rather than only the root one, so this file has not
been updated since the initial scaffold. The same glob silently skipped
development/README.md during the v0.7.0 update, which is why that one had to be
copied from a fresh render by hand.

Refreshed from the v0.7.0 render, and corrected the Caveats bullet the review
flagged: it listed `.agents/hooks/*` among files safe from `copier update`,
while this PR makes block-destructive.sh and ensure-toolchain.sh byte-identical
to the template render and takes hook-input.sh from it. Hook scripts are
template-owned; local edits there get reverted.

Not fixed here: the hook-input.sh backend-parity gap (objects, arrays and
non-integral numbers differ between jq and python3). Confirmed but out of scope
for a PR whose thesis is retiring local hook patches — reported upstream.

Gate: make verify green — 900 passed, 75 skipped, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@egparedes

Copy link
Copy Markdown
Contributor Author

All three findings handled — pushed 9f27b8f.

F2 (glossary) — fixed here. Seeded with 12 terms from devmm-design.md §3, marked as an explicit one-time baseline so the promotion rule still governs everything after it. All 22 cited identifiers verified against the built package. Detail in the thread.

F1 (hook-input.sh parity) — reproduced, routed upstream as grAItools/harness-copier-template#34, per your recommendation. Not patched locally, since a divergence in a hook file works against this PR's thesis.

F3 (.agents/README.md caveat) — fixed here, and it had a bigger root cause.

Chasing it turned up why that file could carry a stale claim at all: our copy was 63 lines behind and had never been updated since the initial scaffold. It was byte-identical to the v0.5.0 render, missing the entire ## Layout section the simplification wave merged in from the four deleted per-directory READMEs.

The cause is copier.yml's _skip_if_exists, which lists a bare README.md. That has no path anchor, so it matches every README in the render, not just the root one — .agents/README.md and development/README.md included. It also explains something from earlier in this branch: development/README.md silently did not update during the v0.7.0 bump and had to be installed from a fresh render by hand. Same glob, and the skip is silent in both directions — copier update reports no conflict and no change.

So this PR now refreshes .agents/README.md from the v0.7.0 render (the Layout section arrives with it) and applies your caveat fix on top: .agents/hooks/* moves into the template-owned sentence, since this PR is exactly what makes those files template-owned. Filed upstream as grAItools/harness-copier-template#33 — the glob, plus the caveat, which is wrong in the template too.

Gate green at 9f27b8f: 900 passed, 75 skipped, unchanged.

Two notes on your review, both accepted: AGENTS.md is 130 lines, not the 127 I claimed, and losing the verify skill is a real regression rather than a neutral simplification.

… claims

Addresses the locally-owned findings from the xhigh review of #4. The
hook-script findings are upstream's and are reported there instead.

The decision-register scan command shipped without a revision range, so
`git diff` compared the worktree to the index and found nothing on any
committed branch. Verified: a `DECISION-PENDING:` line committed to a report
was missed by the documented command and caught by the range form. The check
that was meant to make an unregistered escalation an automatic MAJOR silently
passed instead — and since the report freezes at merge and the register alone
records the outcome, the decision would have been lost with nothing marked
pending.

Register-row ownership was assigned twice, to different actors: `/build` step 5
has the caller add the row while servicing the hand-back, and the local text
added in e245a65 claimed the Developer was the only role that could. Upstream's
version is right and now covers what that patch was written for, so the local
claim is removed rather than reconciled — which also retires its "sole
sanctioned exception to leaving development/ alone", contradicted two lines
later by the Developer's duty to draft ADRs under development/adr/.

Also corrected, all claims this branch made false:

- The no-work-unit review carve-out exempted the spec/plan axes and both
  register contracts but not the scope check, so a repo-layout PR touching every
  work unit — this one — tripped an automatic MAJOR nothing could clear.
- The CHANGELOG said the pre-fix guard denied every Bash call. It failed *open*:
  the pipeline's status was the matcher's, so commands passed unchecked. The
  fail-closed behaviour was a v0.6.0 intermediate that never reached main.
- harness-usage.md said a non-zero Stop hook blocks the stop. Its skip paths
  exit 1, which is non-blocking, so a host with no JSON parser can end a session
  with the gate unrun.
- The subagent table presented the reviewer's read-only bash as enforced. The
  `permission:` map that would enforce it is OpenCode-only — Claude Code honours
  `tools:` alone, which grants unrestricted Bash.
- Register IDs used `2026-07-p12`, while the legend says `<feature-slug>` and
  the slug is `2026-07-p12-conformance-docs-release`.

Gate: make verify green — 900 passed, 75 skipped, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread .agents/hooks/hook-input.sh Outdated
exit 4
fi

if command -v jq >/dev/null 2>&1; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ⏭️ Not fixed here

jq is selected by command -v alone and any jq failure exits 4 instead of falling through to the python3 fallback — the exact "probe by running, not command -v" rule the file's own header (lines 15-18) states, applied only to python3. [same root cause also at: .agents/hooks/hook-input.sh:32, .agents/hooks/hook-input.sh:32, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:37, .claude/settings.json:31]

Failure scenario

On a host where jq resolves but fails when executed (an asdf/mise shim with no version set, a half-removed package, a wrapper), command -v jq succeeds, the out=$(... | jq ...) substitution returns non-zero, and hook-input.sh exits 4 without ever trying python3. The PreToolUse guard in .claude/settings.json maps rc!=0 to exit 2, so EVERY Bash tool call is denied with "could not read the tool input (hook-input.sh exit 4; payload unreadable ...)" — the agent cannot run a single command for the whole session, and the message blames the payload rather than jq. Reproduced: with a stub jq that exits 126 and a fully working python3 on PATH, the benign command ls -la was denied (hook exit 2); the same payload parses fine when jq is simply absent.

Upstream-owned (v0.7.0 render, byte-identical). Routing to the template rather than re-diverging — this PR's thesis is retiring local hook patches, and the human reviewer endorsed that split. Adding to grAItools/harness-copier-template#34.

Comment thread .claude/settings.json Outdated
{
"type": "command",
"command": "INPUT=$(cat); [ \"$(printf \u0027%s\u0027 \"$INPUT\" | jq -r \u0027.stop_hook_active // false\u0027)\" = \u0027true\u0027 ] \u0026\u0026 exit 0; export PATH=\"$HOME/.local/bin:$PATH\"; command -v uv \u003e/dev/null 2\u003e\u00261 || { echo \u0027verify skipped: uv unavailable (run .agents/hooks/ensure-toolchain.sh; see docs/tool-bootstrap.md)\u0027 \u003e\u00262; exit 0; }; make verify || exit 2"
"command": "export PATH=\"$HOME/.local/bin:$PATH\"; flag=$(sh \"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/hook-input.sh\" .stop_hook_active); rc=$?; [ \"$rc\" -eq 3 ] \u0026\u0026 { echo \u0027verify skipped: no JSON parser on PATH (install jq: apt-get install jq / brew install jq)\u0027 \u003e\u00262; exit 1; }; [ \"$rc\" -ne 0 ] \u0026\u0026 { echo \"verify skipped: could not read the hook input (hook-input.sh exit $rc)\" \u003e\u00262; exit 1; }; [ \"$flag\" = \u0027true\u0027 ] \u0026\u0026 exit 0; command -v uv \u003e/dev/null 2\u003e\u00261 || { echo \u0027verify skipped: uv unavailable (run .agents/hooks/ensure-toolchain.sh; see development/tool-bootstrap.md)\u0027 \u003e\u00262; exit 1; }; make verify || exit 2"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ⏭️ Not fixed here

The rewritten Stop hook exits 1 (non-blocking) whenever hook-input.sh cannot be read or run, so make verify never executes — the old hook ran the gate regardless of payload-parse trouble. [same root cause also at: .claude/settings.json:63, .claude/settings.json:63, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.claude/settings.json:63, .claude/settings.json:63]

Failure scenario

On a host with neither jq nor a working python3 (the exact macOS-stub case hook-input.sh's own comment calls out), or when CLAUDE_PROJECT_DIR is unset and the agent's cwd is a subdirectory so ./.agents/hooks/hook-input.sh does not resolve, hook-input.sh returns 3 or 127. The hook prints verify skipped: ... and exits 1, which is non-blocking, so the session ends with the agent claiming "done" and make verify never run — unformatted, lint-failing, type-failing or test-failing code is committed and only caught in CI. The previous command had no such dependency: INPUT=$(cat); [ "$(... jq -r '.stop_hook_active // false')" = 'true' ] && exit 0; ... make verify || exit 2 — with jq missing the substitution was empty, the guard fell through, and the gate still ran (and a broken make invocation exited 2, blocking the stop loudly). The sibling PreToolUse hook in the same diff deliberately fails closed (exit 2) for the identical condition; Stop fails open.

Upstream-owned, and a deliberate ADR 0013 choice (fail open on Stop to avoid the loop). The finding is still a fair challenge to it — the old hook ran the gate anyway — so it goes upstream as evidence against that trade, not as a local patch.

Comment thread .agents/hooks/block-destructive.sh Outdated
#
# See .agents/README.md for the single-source-of-truth rationale.
grep -qE 'rm -rf|push --force|reset --hard|DROP TABLE' && exit 2 || exit 0
if grep -qE 'rm -rf|push --force|reset --hard|DROP TABLE'; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ⏭️ Not fixed here

The deny regex matches anywhere in the command string, including inside quoted arguments and search patterns, and the newly added message asserts the command "matches the destructive deny-list".

Failure scenario

Read-only commands that merely mention a deny-list token are refused: grep -rn 'rm -rf' ., git log --grep='reset --hard', or any command whose arguments quote one of the four patterns. The agent is blocked from grepping or testing its own deny-list, and the new stderr text tells it the command is destructive rather than that a substring matched, so it retries the same class of command. Reproduced twice in this session against the live hook: a grep -rn -E 'rm -rf|push --force|...' repository search and a printf of a JSON test fixture were both denied with "block-destructive: denied - the command matches the destructive deny-list".

Upstream-owned and pre-existing: the regex was substring-matching long before this PR; only the message is new. Hit twice in this session, so it is real. Reporting upstream.

Comment thread development/adr/README.md Outdated
whole tree:

```sh
git diff --unified=0 "$(git merge-base origin/main HEAD)"...HEAD \

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The decision-register scan command git diff --unified=0 -- 'development/work/*/report.md' has no revision range, so it inspects only unstaged working-tree changes and always finds nothing on a committed branch.

Failure scenario

The Reviewer follows this file's contract ("a marker added in the diff without a row here is a defect") and runs the documented pipeline against the feature branch after the Developer has committed. git diff with no range compares the worktree to the index, which is clean, so the grep exits 1 with no output and the check silently passes. A PR that added a DECISION-PENDING: line to report.md with no register row gets a GO instead of the automatic MAJOR that reviewer.md:180 requires. After merge report.md freezes and, per this same file, "this register alone records the outcome" — so the escalated decision is permanently lost with nothing marked pending. Verified: on the current clean tree the documented command produces no output (grep exit 1), while the range form git diff origin/main...HEAD -- ... is the one that would actually inspect the change.

Comment thread .agents/hooks/hook-input.sh Outdated
# Usage: hook-input.sh <dot.path> (stdin: the hook's JSON payload)
# e.g. hook-input.sh .tool_input.command
#
# Prints the field's value on stdout, identically under either backend:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ⏭️ Not fixed here

The documented contract "Prints the field's value ... identically under either backend" is false: jq and python3 emit different text for objects, arrays and numbers. [same root cause also at: .agents/hooks/hook-input.sh:7, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:33, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:41, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:36, .agents/hooks/hook-input.sh:33]

Failure scenario

Verified on this machine (jq 1.6 vs python3): {"a":{"b":1,"c":[1,2]}} with path .a yields jq's pretty-printed multi-line {\n "b": 1,\n "c": [\n 1,\n 2\n ]\n} but python's compact {"b": 1, "c": [1, 2]}; {"a":[1,2]} yields multi-line vs [1, 2]; {"a":1.0} yields 1 vs 1.0; and {"a":12345678901234567890} yields 12345678901234567000 (jq 1.6 coerces through a C double) vs the exact integer. Any future hook that reads a non-string field — e.g. a numeric id or a .tool_response object — silently gets different values on a jq host than on a python-only host, and the multi-line jq form breaks the v=$(...)/[ "$v" = ... ] single-line comparison idiom every caller in .claude/settings.json uses.

Already filed as grAItools/harness-copier-template#34 before this review ran.

Comment thread CHANGELOG.md

- Claude Code hooks no longer depend on `jq` alone: they read their payloads via
`.agents/hooks/hook-input.sh` (`jq`, then `python3`) and branch on its exit
code. Previously, on a host without `jq`, the destructive-command guard failed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The Fixed entry inverts the pre-fix behaviour of the destructive-command guard: without jq the old hook failed OPEN (allowed everything), not "denied every Bash call", and line 43's "still fails closed" asserts a property the old hook never had. [same root cause also at: CHANGELOG.md:40]

Failure scenario

A maintainer auditing whether jq-less hosts or CI images were protected before this change reads "Previously a host without jq had every Bash call denied" and "the Bash guard still fails closed", concludes the deny-list was enforced on those hosts, and skips any audit or remediation. In reality the old PreToolUse body (jq -r '.tool_input.command // empty' | sh block-destructive.sh) fed empty stdin to grep when jq was missing, so the pipeline exited 0 and rm -rf, push --force, reset --hard and DROP TABLE all ran unblocked. Verified by replaying the origin/main hook body with no JSON parser on PATH against the payload git push --force origin main: exit 0 (ALLOWED); the new body denies the same payload with exit 2.

@@ -94,20 +81,23 @@ Claude Code only). You cannot prompt around the hooks:
edited file after every write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The harness guide states the Stop hook's "non-zero blocks the stop", but the Stop hook it documents deliberately uses exit 1 for its skip paths, which is non-blocking in Claude Code. [same root cause also at: development/harness-usage.md:69]

Failure scenario

An agent or contributor reads harness-usage.md:78-79 and concludes that a session which stopped must have had a green gate. In fact .claude/settings.json:63 exits 1 (not 2) when uv is missing or the payload is unreadable, so the session stops normally with make verify never executed. Work is handed off as "done, gate green" on a tree that was never linted, type-checked, or tested, and only a stderr line no one reads records the skip.

honours what the plan explicitly called for.
- **Scope check first.** Run `git diff --stat` against the integration
branch. Every touched file must be plausibly required by the plan.
Touching another feature's `development/work/` directory, a merged feature's

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The new "change with no work unit" carve-out exempts the spec/plan axes and both register contracts but not the scope check, so a repo-layout PR that moves other features' development/work/ directories trips an automatic MAJOR that nothing can clear.

Failure scenario

Running /verify on this very PR: reviewer.md:100-104 says "Touching another feature's development/work/ directory, a merged feature's report.md ... is an automatic MAJOR defect unless the plan explicitly called for it." This diff moves all 13 work/2026-07-p* directories (including merged reports) into development/work/, and reviewer.md:113-117 declares only the conformance axes and register contracts n/a for a work-unit-less change - the scope rule still applies, and with no plan.md there is no way for a plan to have "explicitly called for it." The Reviewer returns NEEDS-WORK with a MAJOR the Developer cannot fix, and the /build -> /verify loop can never reach GO for any layout refactor.

Comment thread .agents/commands/build.md
hand-backs on the same phase, the phase is scoped too wide — stop
and put it to the user.
- `DECISION-PENDING:` in `report.md` — put the question to the user,
add the register row (`development/adr/README.md`), re-invoke the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

Ownership of the decision-register row is assigned to two different actors: /build step 5 tells the main agent to add it, while developer.md:83-90 and development/adr/README.md:75 say the row is the Developer's alone to append.

Failure scenario

Developer hits a decision beyond its authority, writes DECISION-PENDING: in report.md plus its register row in development/adr/README.md, and stops. The main agent, following build.md:40-42, appends a second row for the same decision before re-invoking. The register now carries two rows with different IDs for one escalation, so the documented "scan the table for pending rows" procedure double-counts open decisions. In the mirror case each side assumes the other owns the row, no row is written, and the Reviewer's rule (reviewer.md:180-181) makes the missing row an automatic MAJOR -> NEEDS-WORK on a build that was actually correct.

the marker and its row land in the same change.
- Work **one phase at a time**. Do not begin phase N+1 until phase N's
tests pass and its `tasks.md` boxes are ticked.
- Write the test **first** when the plan calls for behaviour change —

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The Developer is told appending one register row is "the sole sanctioned exception to leaving development/ alone", but line 103 of the same file and architect.md:128 both require the Developer to author ADR files under development/adr/.

Failure scenario

A phase needs a test skipped. developer.md:102-104 says "draft an ADR under development/adr/ and ask before proceeding", and architect.md:126-129 hands ADR authoring to "the human or the Developer", but the Constraints block the Developer reads first declares the register row the only permitted write under development/. The Developer either refuses to draft the ADR and stalls the phase, or silently skips the test without the ADR that /verify (verify.md:31-33) expects - and the Reviewer then flags the undeclared skip as a defect. Same collision applies to the Architect's ADR needed: markers, which have no writer that both files agree on.

| `architect` | yes | no | author `plan.md` + `tasks.md`; stop before code |
| `developer` | yes | yes | implement phase-by-phase, verify, tick tasks |
| `reviewer` | no | read-only + verify/test/lint | GO / NEEDS-WORK verdict, file:line defects |
| `reviewer` | no | read-only + verify/test/lint\* | GO / NEEDS-WORK verdict, file:line defects |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The subagent capability table advertises the reviewer as "read-only + verify/test/lint" bash, but .agents/README.md states in this same diff that the permission: map that enforces that is OpenCode-only and ignored by Claude Code.

Failure scenario

Under Claude Code the reviewer's frontmatter tools: Read, Grep, Glob, Bash grants unrestricted Bash - the bash: allow-map in reviewer.md:14-30 is ignored (per the new .agents/README.md Layout section: "OpenCode only - Claude Code ignores this field"), and .claude/settings.json denies only three git/rm patterns. A reviewer subagent run in an unattended Claude Code session can therefore execute sed -i, git checkout, or uv run against the tree it is meant to judge independently, despite reviewer.md:129-130 ("Never edit files ... Never auto-fix defects yourself") and the table row claiming otherwise. The same gap applies to the explorer row on line 50, which the /build flow invokes routinely.

@egparedes

Copy link
Copy Markdown
Contributor Author

.agents/skills/verify/SKILL.md (file deleted — no inline anchor)

[PLAUSIBLE] correctness — ⏭️ Not fixed here

Deleting the verify skill removes the only description-triggered path that ran make verify outside the four-phase loop; the replacement in the docs is a manual instruction to run it yourself. [same root cause also at: CHANGELOG.md:29]

Failure scenario

The skill fired on "verify", "is this ready", "ready to commit", "check this", or after any non-trivial edit. Its trigger phrases were not folded into reviewer.md's new description (which adds only "is this good", "review this", "find problems"), and development/harness-usage.md deletes both the "Just run the gate and triage failures" table row and the section documenting the trigger, replacing them with "edit directly, then run make verify". Under OpenCode — which the same file states has no session-end gate ("there is nothing equivalent to Claude Code's Stop hook") — a user who makes a small fix and asks "is this ready to commit?" now gets an answer from the model's memory with no gate run and no backstop, and pushes lint/type/test-failing code.

Intended: removing the verify skill was an explicit decision by the repo owner, taken with the capability loss stated. Declared in the PR description and CHANGELOG.

Comment thread development/adr/README.md
step 5, which puts the question to the user and appends the row in the same
change as the marker. The Developer writes the marker and stops; it does not
append the row itself. Rows whose Source is an ADR or a direct human grant have
no marker by construction, and are equally valid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] correctness — ✅ Fixed in 17232e3

The register's ID legend says ID = <feature-slug>.<k> with the example 2026-07-user-auth.1, but both seeded rows use 2026-07-p12.N while the actual work-unit slug is 2026-07-p12-conformance-docs-release, so the stated convention and the shipped data disagree.

Failure scenario

The Developer appends the next row for that same work unit. Following the legend it writes 2026-07-p12-conformance-docs-release.1, which collides in meaning with the existing 2026-07-p12.1/.2 while sorting and reading as a different feature; following the existing rows it writes 2026-07-p12.3, which contradicts the legend the Reviewer checks against. Either way the register's IDs stop being a reliable key back to a work-unit directory, which is the whole point of the <feature-slug> prefix.

Comment thread .claude/settings.json
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); S=\"${CLAUDE_PROJECT_DIR:-.}/scripts/fmt-file.sh\"; [ -n \"$f\" ] && [ -x \"$S\" ] && \"$S\" \"$f\" || true"
"command": "f=$(sh \"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/hook-input.sh\" .tool_input.file_path); rc=$?; [ \"$rc\" -eq 3 ] && { echo 'fmt skipped: no JSON parser on PATH (install jq: apt-get install jq / brew install jq)' >&2; exit 1; }; [ \"$rc\" -ne 0 ] && { echo \"fmt skipped: could not read the hook input (hook-input.sh exit $rc)\" >&2; exit 1; }; S=\"${CLAUDE_PROJECT_DIR:-.}/scripts/fmt-file.sh\"; [ -n \"$f\" ] && [ -x \"$S\" ] && \"$S\" \"$f\" || true"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] cleanup — ⏭️ Not fixed here

The same rc=$? / -eq 3 / -ne 0 dispatch block is copy-pasted into three hooks (lines 42, 53, 63), each re-printing an install-jq message hook-input.sh already wrote to stderr. [same root cause also at: .claude/settings.json:31]

Failure scenario

Every failure prints two messages to the operator (hook-input.sh's "no working JSON parser (jq or python3) on PATH; ... Install jq (apt-get install jq / brew install jq)." plus the hook's own near-identical line), and the three copies must be edited in lockstep: adding an exit code to hook-input.sh means editing three escaped-JSON one-liners, so one gets missed and that hook mis-classifies the new code as a generic failure. Simpler: let hook-input.sh own the diagnostics (it already does) and have each hook branch only on rc to pick its exit code, or move the whole dispatch into a second tiny wrapper script the three hooks share.

Upstream-owned. A local dedupe would re-diverge .claude/settings.json, which this PR just made byte-identical to the template.

Comment thread AGENTS.md
- For a new architectural choice (dependency, framework, persistence, auth),
add an ADR in `docs/adr/`. ADRs are append-only; supersede with a new file.
- Subagent hand-back loops are bounded: honour the cap the role states in its
hand-back reply (three spike hand-backs per plan, three explore hand-backs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONFIRMED] cleanup — ⏭️ Not fixed here

The four hand-back cap numbers are restated verbatim in nine places across six files instead of having one authoritative home. [same root cause also at: .agents/subagents/reviewer.md:171, development/README.md:25]

Failure scenario

The caps ("three spike hand-backs per plan, three explore hand-backs per phase, three replan hand-backs per feature, five question rounds per spec") appear at AGENTS.md:55-56, development/harness-usage.md:59-60, .agents/commands/plan.md:27, .agents/commands/build.md:37 and :44, .agents/commands/spec.md:29, .agents/subagents/architect.md:205, .agents/subagents/developer.md:163 and :173, .agents/subagents/product-owner.md:152. Raising the explore cap to five means nine coordinated edits; miss two and the developer subagent self-stops at three while /build keeps re-invoking it to five, so the loop the caps exist to bound behaves differently depending on which document the acting agent happened to read. This is the DRY rule the diff's own new skill states (.agents/skills/design-principles/SKILL.md: "Every piece of knowledge — code, schema, doc, config — has one authoritative representation").

Mostly upstream text across six files; a local single-source refactor would diverge from the template in five of them for a DRY win that the template is better placed to make.

@egparedes

Copy link
Copy Markdown
Contributor Author

xhigh multi-agent review: 15 findings — 8 fixed in 17232e3, 7 routed upstream or intended. Inline on each.

Fixed here (all locally-owned; the harness-instruction ones were introduced by this branch):

  • development/adr/README.md — the decision-register scan command had no revision range, so git diff compared worktree to index and found nothing on any committed branch. Verified: a DECISION-PENDING: line committed to a report was missed by the documented command and caught by the range form. The check meant to make an unregistered escalation an automatic MAJOR silently passed instead — and since the report freezes at merge and the register alone records outcomes, the decision would have been lost with nothing marked pending. This was the sharpest finding, and it was mine.
  • Register-row ownership was assigned twice. /build step 5 has the caller add the row; the text I added in e245a65 claimed the Developer was the only role that could. Upstream's version now covers exactly what that patch was written for, so I removed the local claim rather than reconciling it — which also retires its "sole sanctioned exception to leaving development/ alone", contradicted 15 lines later by the Developer's duty to draft ADRs.
  • The no-work-unit review carve-out exempted the spec/plan axes and both register contracts but not the scope check — so a repo-layout PR touching every work unit (this one) tripped an automatic MAJOR nothing could clear.
  • Three false doc claims, all made false by this branch: the CHANGELOG said the pre-fix guard denied every Bash call when it in fact failed open; harness-usage.md said a non-zero Stop hook blocks the stop, when its skip paths exit 1 (non-blocking); and the subagent table presented the reviewer's read-only bash as enforced, when the permission: map is OpenCode-only and Claude Code honours tools: alone. Register IDs also disagreed with their own legend.

Routed upstream — the hook scripts are byte-identical to the v0.7.0 render, and re-diverging works against this PR's thesis (the same split you recommended for the parity finding):

Intended, not fixed: removing the verify skill (your call, capability loss stated); and two DRY findings in .claude/settings.json / cap numbers, both upstream-owned.

Gate green at 17232e3: 900 passed, 75 skipped, unchanged.

Picks up the three upstream fixes this adoption prompted, all now closed:

- _skip_if_exists is root-anchored (#33). A bare "README.md" matched at
  every depth under gitignore semantics, silently freezing the template's
  own .agents/README.md, .claude/rules/README.md and development/README.md
  downstream with no conflict reported in either direction.
- The payload reader probes each backend by running it, and the Stop gate
  reports instead of skipping (#34, #35). A jq that resolves but cannot run
  now falls through to python3; a gate that cannot be blocked still runs and
  says so, rather than ending the session unverified.
- The deny-list matches operations outside quotes (#36), so a read-only
  command that merely mentions one is allowed. The SQL pattern still matches
  anywhere: it has no unquoted form, so its mention and its use are
  indistinguishable.

.agents/README.md takes the template's version wholesale — upstream's
rewrite generalises the local caveat this branch carried (hooks are
template-owned) to all of .agents/, and adds the safe-to-edit list.

development/{harness-usage,tool-bootstrap}.md are _skip_if_exists, so copier
leaves them alone and the new behaviour is ported by hand. The Stop-hook
paragraph is rewritten rather than replaced: upstream covers the reader path
but not the uv-unavailable path, which still skips the gate on exit 1.

Every claim added to those two documents was checked against the scripts
themselves — 13 deny-list cases, and the reader's exit codes for a broken
jq, python3-only, no working parser (3) and an empty payload (4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@egparedes egparedes changed the title chore: adopt the agent-harness copier template v0.7.0 chore: adopt the agent-harness copier template v0.7.0-3-g0fa3c56 Jul 29, 2026
@egparedes

Copy link
Copy Markdown
Contributor Author

Re-pinned to template v0.7.0-3-g0fa3c56 (06940b4)

The template moved three commits past v0.7.0, and all three are fixes for
issues this branch reported. All eight upstream issues from this adoption
(#25–#36) are now closed.

Upstream fix Effect here
_skip_if_exists root-anchored .agents/README.md was frozen by a bare README.md matching at every depth; it now updates from the template
Reader probes each backend by running it A jq that resolves but is broken falls through to python3 instead of failing the read
Stop gate reports instead of skipping A gate that cannot be blocked still runs, rather than the session ending unverified
Deny-list matches outside quotes grep -rn '<pattern>' . passes; cd x && <operation> y is still denied

Conflict resolution

One conflict, in .agents/README.md — upstream rewrote the exact bullet this
branch had patched. Taken wholesale from the template: upstream's version
generalises the local claim (hooks are template-owned) to all of .agents/,
and adds the safe-to-edit list. Nothing local was lost; the file is now
byte-identical to a pristine render.

development/harness-usage.md and development/tool-bootstrap.md are
_skip_if_exists, so copier left them untouched and the new behaviour was
ported by hand. The Stop-hook paragraph was rewritten, not replaced —
upstream's new text covers the reader path but not the uv-unavailable path,
which still skips the gate on exit 1. Both paths are now stated.

Verification

The dry run happened on a throwaway clone first, and every template-owned file
in the end state was compared byte-for-byte against a pristine render of the
pinned commit before the real branch was touched.

Every claim added to the two hand-ported documents was checked against the
scripts rather than read off the upstream diff — 13 deny-list cases (quoted
mention, bare operation, nested-shell runner, pipe into a shell, \rm alias
bypass, and the push-ends-in-sh non-match) and the reader's exit codes for
broken jq, python3-only, no working parser (3) and empty payload (4). All
13 matched; all four exit codes matched.

One note on the new deny rule, from using it: committing this change was
itself denied, because the commit message quotes the SQL pattern and rule 3
matches that anywhere. That is documented behaviour, not a regression — the
message now lives in a file passed to git commit -F.

Gate: 900 passed, 75 skipped — unchanged. CI 9/9 green.

The template's quote-aware guard rewrite fixed the false positives it aimed
at, but eight command shapes the previous matcher denied are now allowed.
Confirmed by feeding the same text to both versions; the guard only reads
stdin, so nothing was executed. Filed upstream as template#40 rather than
patched here, keeping .agents/hooks/* byte-identical to the render.

What lands instead is the check that was missing: tests/test_harness_deny_list.py
pins the verdict for 24 shapes. The guard is template-owned, so copier update
rewrites it wholesale and a regression arrives as a clean, conflict-free
update with nothing to review. The nine known-bad shapes are
xfail(strict=True) against template#40, so an upstream fix fails the gate as
an XPASS instead of passing unnoticed.

Three project-owned defects fixed:

- The decision-register scan substituted "$(git merge-base origin/main HEAD)",
  which expands to nothing when origin/main does not resolve — a fresh repo, a
  shallow CI clone, a fork on another integration branch — degenerating the
  range to HEAD...HEAD and reporting "no escalations" for any diff. The
  three-dot form resolves the same merge base (verified byte-identical output)
  and exits 128 on a ref it cannot find. This is the second defect in this one
  snippet: it is the register contract's only stated check, and both times it
  failed by looking clean.
- harness-usage.md documented one SessionStart hook; this branch added a
  second, the payload-reader probe whose warning is what precedes every Bash
  call being denied.
- The wheel-vs-sdist invariant lived only in development/README.md, which is
  template-owned and unprotected by _skip_if_exists. Moved to
  architecture.md#distribution-boundary, which is protected; the README now
  points there and says why.

Also filed upstream as template#41: the jq probe proves parsing but not query
compatibility, so a jq older than 1.5 denies every Bash call while blaming a
damaged reader; and the PreToolUse proxy checks the guard is readable, not
that it runs, so a truncated guard exits 0 and allows everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# The operation has to sit inside what the shell will run — after the runner's
# opening quote, or before the pipe into a shell — not merely somewhere on the
# same line as one.
if matches "${runner}[^'\"]*($operations)" || matches "($operations)$piped"; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule 2's [^'\"]* halts at a quote nested inside the runner's script, so an operation preceded by a quoted word is allowed.

Reproduced independently against both versions: bash -c 'git fetch && echo "resetting" && git reset --hard origin/main' → allowed here, denied on main. Rule 1 has already classified the outer span as a mention, so nothing catches it.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

# argument opens. `[^;&|]*` keeps the runner and the quote in one simple command,
# so `ssh host uptime && grep '<pattern>' .` is not read as handing the pattern
# to ssh.
runner="(^|[^[:alnum:]_./-])((/[a-z/]*)?${shells}[[:space:]]+-[[:alnum:]]*c|(ssh|eval|su)[[:space:]])[^;&|]*['\"]"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -…c flag must be adjacent to the shell name, so a long option or option cluster escapes rule 2.

${shells}[[:space:]]+-[[:alnum:]]*c cannot match a second -. Reproduced: bash -euo pipefail -c '…' and bash --norc -c '…' → allowed here, denied on main; the adjacent sh -c form still denies.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

# line — grep is line-oriented, and each line of a multi-line command starts a
# new command. A bare backslash stays an ordinary character here, so the
# alias-bypass form (\rm -rf) is still read as the operation it is.
outside_quotes="^([^'\"]|$escaped|$squoted|$dquoted)*"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outside_quotes is ^-anchored per line, which is unsound for a command carrying a multi-line quoted argument.

The continuation line begins inside the string, so its closing quote is unmatched and nothing after it is reachable. Reproduced with the ordinary multi-line commit-message form chained to a delete: allowed here, denied on main.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

# Complete quoted spans, escape-aware: to the shell a backslash-escaped quote is
# a literal character, not a delimiter, so consuming it as one would flip the
# in/out-of-quote classification for the rest of the line.
squoted="'[^']*'" # '…' — POSIX: no escapes inside

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

squoted models POSIX single quotes only; $'…' permits \' inside and desynchronises the parity model.

Reproduced: git commit -m $'fix don\'t break' && rm -rf build → allowed here, denied on main. Every operation later on the line becomes unreachable.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

# …and shells that take their script from stdin, which the operation reaches by
# being piped into one. `ssh`/`eval`/`su` are absent here on purpose: they run an
# argument, not stdin, so `grep '<pattern>' . | ssh host tee f` is a mention.
piped="[^|]*\\|[[:space:]]*(sudo[[:space:]]+)?(/[a-z/]*)?${shells}([[:space:]]|\$)"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

piped cannot cross a second pipe and allows only a sudo prefix, so an extra stage lets the operation reach a shell.

Reproduced: | tee /tmp/x | sh and | env sh → allowed here, denied on main; bare | sh still denies. The header claims rule 2 covers an operation ahead of a pipe into a shell.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

exit 4
fi

if command -v jq >/dev/null 2>&1 && printf '{}' | jq -e . >/dev/null 2>&1; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The printf '{}' | jq -e . probe proves parsing, not query compatibility.

The real query uses try … catch, which jq gained in 1.5. A jq 1.4 passes the probe, then fails on a syntax error that 2>/dev/null swallows → exit 4 → .claude/settings.json maps it to exit 2, denying every Bash call while blaming a damaged reader and never mentioning the working python3 on the same PATH.

Skipped here — filed upstream as template#41. Template-owned; same reasoning as the guard findings.

Comment thread .claude/settings.json
{
"type": "command",
"command": "S=\"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/block-destructive.sh\"; [ -r \"$S\" ] || exit 2; jq -r '.tool_input.command // empty' | sh \"$S\""
"command": "H=\"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks\"; [ -r \"$H/block-destructive.sh\" ] || { echo 'PreToolUse: block-destructive.sh is missing - denying Bash. Restore .agents/hooks, then retry.' >&2; exit 2; }; c=$(sh \"$H/hook-input.sh\" .tool_input.command); rc=$?; [ \"$rc\" -eq 3 ] && { echo 'PreToolUse: no working JSON parser on PATH, so the destructive-command guard cannot read the tool input - denying Bash. Install jq (apt-get install jq / brew install jq) from a shell outside the agent, then retry.' >&2; exit 2; }; [ \"$rc\" -ne 0 ] && { echo \"PreToolUse: could not read the tool input (hook-input.sh exit $rc; payload unreadable or .agents/hooks/hook-input.sh damaged) - denying Bash.\" >&2; exit 2; }; [ -n \"$c\" ] || { echo 'PreToolUse: no command found in the tool input - denying Bash.' >&2; exit 2; }; printf '%s' \"$c\" | sh \"$H/block-destructive.sh\""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy checks the guard is readable, not that it runs, so a truncated guard exits 0 and allows everything.

[ -r ... ] passes for a present-but-damaged script; running an empty file exits 0 and the hook reports success. The SessionStart probe exercises hook-input.sh only, so nothing signals that the deny-list has stopped working — while .agents/README.md advertises it as failing closed.

Skipped here — filed upstream as template#41. Template-owned; same reasoning as the guard findings.

exit 2
}

if matches "$outside_quotes($operations)"; then

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule 1 is an anchored regex of bracket expressions, so one byte invalid in the current locale defeats it.

Under LANG=en_US.UTF-8, cd \377x && rm -rf y is allowed; the identical input under LC_ALL=C is denied, and the pre-rewrite one-liner matched it. Locale-dependent, so it will not reproduce on a C-locale CI runner.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

Comment thread development/adr/README.md

# --- deny-list (mirror any change into .opencode/opencode.jsonc) -------------
# Destructive operations: denied when run, allowed when quoted (rules 1 and 2).
operations='rm -rf|push --force|reset --hard'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push --force prefix-matches push --force-with-lease, hard-denying the safe form.

git push --force-with-lease origin main → exit 2. Unlike a quoted mention there is no rephrase — quoting it stops it being a command — and permissions.deny's Bash(git push --force:*) denies it a second time. Pre-dates this PR; it is not a regression.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

Comment thread .agents/hooks/block-destructive.sh
#
# Blind spots, unchanged in kind from the earlier plain-substring form: `eval`
# of a variable, "$(…)" command substitution, aliases, encoded payloads, a
# heredoc body whose lines read as commands, a shell run from a file it wrote,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header calls "a shell run from a file it wrote" a blind spot "unchanged in kind" — the earlier form denied it.

Reproduced: printf 'rm -rf /tmp/x' > s.sh; sh s.sh → allowed here, denied on main. A maintainer reading the header would believe this exposure pre-dates the change and needs no re-evaluation.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

Comment thread .agents/README.md

cmd=$(cat)

matches() { printf '%s\n' "$cmd" | grep -qE "$1"; }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matches() forks a fresh grep per rule, scanning the same short string four times on every Bash call.

Four call sites, on top of the five processes hook-input.sh already spawns, on the critical path of every Bash invocation. Rule 2's two patterns could be one alternation and rules 1 and 3 could share a pass.

Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.

Comment thread development/harness-usage.md
@egparedes

Copy link
Copy Markdown
Contributor Author

Review round 3 (xhigh) — 15 findings, 4 fixed here, 11 routed upstream

The headline: the template's quote-aware guard rewrite fails open on eight
command shapes the previous matcher denied.
I re-derived this independently
of the review agents by feeding identical text to both versions of the script
— it only reads stdin, so nothing was executed:

Command shape v0.7.0 pinned
nested quote inside a bash -c script deny allow
-c not adjacent to the shell name (-euo pipefail -c, --norc -c) deny allow
multi-line quoted argument, then a chained operation deny allow
$'…' ANSI-C quoting, then a chained operation deny allow
an extra pipeline stage before the shell (| tee x | sh, | env sh) deny allow
write a script, then run it deny allow

Five distinct causes, all in the one regex grammar. Filed as
template#40
rather than patched here: .agents/hooks/* is byte-identical to the pinned
render, and re-diverging it is the thing this PR exists to undo. A second
issue, template#41,
covers two health checks that establish less than they conclude — the jq probe
proves parsing but not query compatibility (a jq older than 1.5 denies every
Bash call while blaming a damaged reader), and the PreToolUse proxy checks the
guard is readable rather than that it runs.

What that leaves this branch

Merging before upstream fixes #40 means accepting a guard weaker than main's
for those eight shapes. permissions.deny does not compensate: its patterns
(Bash(rm -rf:*)) are prefix matches, so they never fired on the embedded
forms in the first place.

The mitigation that is in scope landed instead — tests/test_harness_deny_list.py,
pinning the verdict for 24 shapes: 15 assertions locking correct behaviour, 9
xfail(strict=True) for the known gaps. Strict matters both ways. An upstream
fix fails the gate as an XPASS instead of passing unnoticed, and a future
copier update that weakens the guard fails instead of arriving conflict-free
with nothing to review — which is exactly how these eight shapes got in.

Fixed here

  • The register scan was vacuous on any repo where origin/main does not
    resolve.
    "$(git merge-base origin/main HEAD)" expands to nothing, the
    range degenerates to HEAD...HEAD, and the scan reports clean for any diff.
    Now origin/main...HEAD, which resolves the same merge base (verified
    byte-identical output) and exits 128 rather than returning nothing. This is
    the second defect found in this one snippet, and both times it failed by
    looking clean — the failure mode a contract check can least afford.
  • The SessionStart bullet documented one hook after this PR added a second.
  • The wheel-vs-sdist invariant lived only in development/README.md, which is
    template-owned and unprotected; moved to
    development/architecture.md#distribution-boundary, which is not.

Skipped, one line each

.agents/hooks/* and .claude/settings.json findings (8 of them) are all
byte-identical template files → #40 / #41. --force-with-lease being
prefix-denied by push --force pre-dates this PR → #40. The per-rule grep
fork count is real but minor, and lives in the same upstream file → #40.

Gate: 915 passed, 75 skipped, 9 xfailed. CI 9/9 green.

egparedes added a commit to grAItools/harness-copier-template that referenced this pull request Jul 31, 2026
Closes #40.

Every claim in the issue reproduced against current `main` before
patching: all eight shapes fail open, `--force-with-lease` is
hard-denied, and the high-byte case flips between `LANG=en_US.UTF-8`
(allow) and `LC_ALL=C` (deny). The same table was run against the v0.7.0
script to confirm its column too. Nothing was already fixed.

Rebased onto merged `main` (#49, `3cec00a`). The only conflict was the
ADR index table, resolved keeping both B's 0016 row and this PR's 0017
row; ADR 0013's row now also records that its `grep -qE` constraint is
retired by 0017.

## Approach: the tokenizer route ([ADR
0017](docs/decisions/0017-deny-list-tokenizer-in-python3.md))

Widening the ERE prefix classes would fix only causes 2 and 5
(non-adjacent `-c`, pipe chains). Causes 1, 3, 4 and the locale case are
structural to a line-oriented POSIX-grep grammar: it cannot carry quote
state across lines, cannot express "the same rules, one level down", and
matches bytes through locale-defined classes. So `block-destructive.sh`
keeps its interface, deny-list variables and message contract, but
computes the verdict in an embedded `python3` program — a hand-rolled
quoting-aware scanner (not `shlex`, which mis-tokenizes `$'…'`, raises
on unterminated quotes, and discards the quote structure rule 1 needs).

The trade the header used to defend (pure-POSIX `grep -qE`) is given up
deliberately: `python3` is already the hooks' mandated fallback JSON
parser (ADR 0013), and it is probed here the same way — by running it.
The CHANGELOG carries an upgrade note: hosts that ran the hooks on `jq`
alone now need python3.

**The dependency fails closed in both directions.** A missing python3
denies with an install remedy. So does an interpreter that starts and
then cannot reach a verdict — raised in review of #49, and a real
fail-open in the first version of this PR: a python2 shim or a stripped
standard library fails on the program itself and exits 1, which
PreToolUse treats as a non-blocking error and runs the command. The
program now prints a token on its allow path; the wrapper accepts an
allow only as exit 0 carrying that token, a deny as exit 2, and denies
every other status with a diagnostic naming it. No extra process, and
the guard's stdout stays empty either way.

## Now denied again (the eight #40 rows, plus generalisations)

| Shape | Mechanism |
| --- | --- |
| `bash -c '… echo "resetting" && git reset --hard …'` | rule 2 recurses
into the runner string; the inner quoted word is consumed as a span, the
operation after it is reachable |
| `bash -euo pipefail -c '…'`, `bash --norc -c '…'` | `-c` is found
anywhere among the shell's option tokens |
| multi-line `git commit -m "…" && rm -rf build` | quote state carries
across lines in the joined pass |
| `git commit -m $'fix don\'t break' && rm -rf build` | `$'…'` is
modelled, so `\'` no longer desynchronises parity |
| `… \| tee /tmp/x \| sh`, `… \| env sh` (also `cat \|`, `nohup`,
`timeout 5`) | the pipe rule crosses any number of segments and strips
wrapper prefixes |
| `printf '…' > s.sh; sh s.sh` | new rule-2 form: an operation anywhere
in a command that also runs a shell on a file |
| `cd \xffx && rm -rf y` under any locale | stdin decoded with
`surrogateescape`; the verdict is byte-deterministic |

## Now allowed (dead-end and false-positive fixes)

- `git push --force-with-lease` / `--force-if-includes` — a `push
--force` match continued by `-` is a longer, lease-checked flag. Applied
**in the guard only**: `.opencode/opencode.jsonc` keeps the substring
`*push --force*` glob, because splitting it under-covers `git push
--force;` and `(git push --force)`, and an allow rule for the lease
flags would un-deny compound commands (OpenCode resolves overlaps by
last matching rule). The mirror stays stricter than the script by design
— this reverses the hand-off I was given, with the reasoning in ADR 0017
Decision 4 and the review comment on this PR.
- `bash -c "grep 'rm -rf' ."` — a mention nested in a runner string
recurses to a mention (was a documented ADR-0015 false positive).
- The `'…'\''…'` idiom — parses as the single word it is (ditto).

**Every false positive #39 fixed stays allowed**, verified case-by-case:
quoted grep/rg patterns, `git log --grep`, printf'd fixtures, commit
messages naming a pattern, `ssh host uptime && grep …`, `grep … | ssh
host tee f`, `grep … | git push`, `git push -c k=v && grep …`,
escaped-quote commit messages, `grep -c '…' file`. Two documented
*surviving* false positives are kept on purpose and pinned by tests: a
multi-line quoted string whose lines read as commands (the per-line pass
exists so an unpaired quote on one line can't hide a bare operation on
the next), and a quoted mention alongside a shell-on-a-file in the same
command (the price of re-denying write-then-run; recovery is splitting
into two calls).

The header's false claim — that "a shell run from a file it wrote" was a
blind spot "unchanged in kind" — is corrected: the same-command form is
denied again; the genuinely unchanged blind spot (a file written in an
*earlier* tool call) is stated as such. The header also now records
`block-destructive: denied` as a cross-file contract, since #49's
SessionStart self-test keys on that prefix.

## `permissions.deny` needs no change (corrected)

An earlier revision of this PR claimed `.claude/settings.json`'s
`Bash(git push --force:*)` had the same lease-flag over-match and was
being fixed under #41/#44. That is wrong, and B's revert on #49 is
right. Verified independently against the shipped CLI (v2.1.220) rather
than taken on faith — the matcher's prefix case is `cmd === prefix ||
cmd.startsWith(prefix + " ")` (plus the same two forms behind `xargs`),
and the permissions reference says a trailing `*` after a space
"enforces a word boundary". So the rule already denies `git push
--force` and `git push --force <args>` and never matched
`--force-with-lease`; writing the space into the rule would end the
prefix in a space and deny nothing — an under-deny in the
`include_claude_hooks=false` configuration where that list is the only
layer. ADR 0017 Decision 4 and the CHANGELOG now say so and point at ADR
0016 Decision 5.

## Tests

`tests/test_block_destructive.py` — table-driven, stdlib `unittest`, no
dependencies: `python3 -m unittest discover tests`. It pins true
positives, the eight #40 shapes, the fail-opens self-review found, the
#36-fixed mentions, the ADR-0017 fixes, the documented surviving false
positives, the documented blind spots, locale determinism, and the
fail-closed postures: python3 absent, an interpreter that fails on the
program (exit 1) or exits 0 without a verdict, each deny-list variable
emptied, the pre-tokenizer `shells` spelling, and the recursion budget.
It lives in **this repo**, not `template/`: the guard ships verbatim (no
Jinja), so the template source is byte-identical to the rendered
artifact, and generated repos are language-arbitrary — the template
cannot assume a Python test runner downstream. The downstream table in
grAItools/devmm#4 (whose `xfail(strict=True)` rows will XPASS on the
next template bump) was used as the reference.

## Validation

- 16 tests green; guard behaviour identical under `dash`, `bash`, `sh`,
and BusyBox `sh` (deny 2 / allow 0, deny message on stderr, stdout
empty).
- Rendered from a clone with `--vcs-ref HEAD` under two answer
combinations (defaults; `task_runner=just` +
`copilot_code_review=true`): no leftover Jinja, rendered guard
byte-identical to the template source and denying/allowing the fixtures,
rendered `opencode.jsonc` parses as JSON after comment stripping with
the four substring deny globs present.
- The 45-case cross-check harness was run against v0.7.0, pre-fix
`main`, the new guard, and the rendered output; the only verdict changes
are the ones listed above.
- B ran #49's SessionStart guard self-test against this guard with and
without python3 on PATH: all three probes deny with the expected prefix,
so the two PRs are compatible in either merge order.

## Self-review

A multi-agent review of this PR's own diff found 15 issues, 14 fixed in
`658ee08` (nine of them fail-opens the replaced matcher had caught:
compound producers before a pipe into a shell, `2>&-` swallowing the
next token, `cat s.sh | sh` and `sh < s.sh`, backticks, assignment
prefixes, an unpaired quote disabling rule 2 below it, exponential
runner recursion, plus the OpenCode glob and the omitted `TEXT_PATTERNS`
posture check). The one not fixed — a word spliced across a quote — is
now pinned as a documented blind spot instead of being implied covered.
Details in the review comment on this PR.

## Belongs to other workers / follow-ups

- `template/development/harness-usage.md` and `tool-bootstrap.md`
(another worker's area, and `_skip_if_exists`): the guard description
there still reflects ADR 0015's matcher, and "Required tools" should
note the guard itself now needs python3 (not just the payload reader's
fallback). Needs a follow-up by their owner.
- `template/.agents/README.md` (also not this PR's file): its deny-list
note describes a two-rule script and omits the nested-shell rule, and
its "kept in sync by hand" line undercounts the mirrors. Same owner as
the item above.
- ~~`AGENTS.md` says "there are no unit tests"~~ — folded into this PR
per orchestrator: *Validating changes* now names the suite and its
runner (`python3 -m unittest discover tests`) ahead of the render check.
- ~~`.claude/settings.json.jinja` `permissions.deny` has the same
lease-flag over-match~~ — it does not; see the corrected section above.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant