From 8136700c996785b1cfdf1276e699604603656980 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 19:15:14 -0600 Subject: [PATCH 01/23] =?UTF-8?q?docs(spec):=20harvest=204=20design=20?= =?UTF-8?q?=E2=80=94=20the=20kit=20as=20applied,=20what=20syncing=20e92e9c?= =?UTF-8?q?4=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design spec for landing, in the kit, every finding on issue #58 that carries a proven fix: the two target-project syncs of e92e9c4 (you-are-hear #42/PR #43, echosphere ECH-40/PR #37) and their post-merge repairs (echosphere PR #38, you-are-hear PR #82 and 39ed311). Seven clusters H1–H7, decisions D30–D40 settled with the operator 2026-09-21, one PR with atomic commits per item. Preceded by a 64-agent read of the sources (seven sonnet readers, two opus verifiers per plan-changing fact, one opus critic): 28 facts verified, 21 confirmed, 3 refuted, 4 contested and adjudicated by direct reads of the kit tree and the installed Claude Code 2.1.278 bundle. Co-Authored-By: Claude Fable 5.1 --- ...2026-09-21-cascade-kit-harvest-4-design.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-21-cascade-kit-harvest-4-design.md diff --git a/docs/superpowers/specs/2026-09-21-cascade-kit-harvest-4-design.md b/docs/superpowers/specs/2026-09-21-cascade-kit-harvest-4-design.md new file mode 100644 index 0000000..a931d09 --- /dev/null +++ b/docs/superpowers/specs/2026-09-21-cascade-kit-harvest-4-design.md @@ -0,0 +1,236 @@ +# Harvest 4 design — the kit as applied: what syncing `e92e9c4` into real projects found + +**Date:** 2026-09-21 +**Status:** Approved for execution (operator, 2026-09-21) +**Type:** Design spec for evolving `context-builder-kit` itself +**Prior:** harvest 1 (`2026-07-04-…`, PR #4) · harvest 2 (`2026-08-09-cascade-kit-harvest-2-design.md`, PR #5) · axis parity (`2026-08-09-axis-parity-pass-design.md`, PR #7) · harvest 3 (`2026-09-06-cascade-kit-harvest-3-design.md`, PRs #54–#57) + +## What this is + +Harvest 3 landed on 2026-09-06 as `e92e9c4`. Within a week two projects applied that exact +commit to already-customized `.claude/` trees — `j4th/you-are-hear` (#42 → PR #43, github-issues +axis, 2026-09-07) and `j4th/echosphere` (ECH-40 → PR #37, Linear axis, 2026-09-13) — and a third, +private target wrote its own sync spec against the same sha. The syncs themselves were mechanical +where the kit intended them to be: byte-identical copies, the executor pair re-spliced, the +verification block adopted wholesale. What they found is recorded on issue #58 (the body plus five +dated comments) and in the post-merge fixes each project shipped on its own tree. None of it has +reached the kit: `main` is still `e92e9c4`, and #58 is open. + +Three of those findings are not lint. A hook-shaped object at the top level of `settings.json` — +the shape the kit's two `_example_PostToolUse_*` stanzas ship in — makes Claude Code discard the +file's hook configuration; read against the installed 2.1.278 bundle, the parser marks the +diagnostic fatal and returns no settings at all, so `enabledMcpjsonServers` and `enabledPlugins` go +inert beside every guard, silently, with the verification block green (echosphere PR #38, #58's +2026-09-13 comment). Eight of the nine kit hooks check a dependency before draining stdin (the +knowledge-backend gate drains first and has none), and three sites decide with +`printf … | grep -q`, so a body past the pipe buffer turns a real match into a silent bypass of a +HARD-DENY guard (you-are-hear #81 → PR #82, measured 2026-09-18). And the Stop-tier +fork detector reads `claude-code-action`'s staging copy of a committed memory tree as a fork and +tells the reviewer to delete it (you-are-hear `39ed311`, #58's 2026-09-07 comment). + +This pass lands, in the kit, every #58 finding that carries a proven fix — a patch already merged +on a public target, or a reproduction whose repair is one line — so the next sync of any target is +a merge against a kit that has already absorbed what applying it taught. It was preceded by a +64-agent read of the sources (seven sonnet readers over #58, #33, the two public syncs and their +follow-up PRs, the Claude Code changelog 2.1.215→2.1.278, and one target's tree; two opus verifiers +per plan-changing fact, refute-by-default; one opus completeness critic): 28 facts verified, +21 confirmed, 3 refuted, 4 contested and adjudicated by direct reads. The refutations matter as +much as the confirmations and are recorded in § Decisions where they changed the design. + +## Governing constraints — all five carry + +1. **Portability invariant.** No project-specific identifier in kit content; the two public runs + are named only where #58 already names them; the private target is never named. +2. **Framework-not-tooling, per file on merits.** A fixture that makes a policy the kit already + mandates runnable ships beside the existing ones. +3. **Sanitized only.** Every ported hunk is re-authored generic; project stack names (a Dart + cache, a Rust workspace) survive only as the illustration the source comment carries. +4. **One-way.** The kit changes; no instance is modified by this pass. +5. **Defer-to-exercised.** Where two exercised forms differ, the later wins unless the earlier + states why it must not generalize. One application here: echosphere's fork detector (kit base + plus you-are-hear's hunks, no shared helper library) is the later form and is the one ported. + +## Decisions (settled with the operator, 2026-09-21) + +Numbering continues from harvest 3 (D17–D29). + +| # | Decision | Outcome | +|---|---|---| +| D30 | Exemplar stanzas in `settings.json` | **Omit them; never a string.** #58's cheapest fix (stringify) is refuted for targets: a string still carries `hooks/.sh`, which a target's raw-text registration count reads as a registration, and the loader's fatal diagnostic (2.1.278, read from the bundle) covers `matcher` *or* a non-empty `hooks` at depth ≤ 3 under any top-level key outside a nine-key allowlist. The stanza text lives in each hook's header. The block asserts no top-level key besides `hooks` holds such an object. | +| D31 | Verification block shape | **Fail-fast stays.** #58's shape 3 (collect, then exit non-zero) is not adopted; the two checks that were wrong on every real target are fixed in place — the advisory-exemplar arm becomes project-conditional, the `.mcp.json.example` operand is guarded — and the extraction gains two fail-loud rails (an empty extraction is red; exit 0 without the done sentinel is red). The conventions state that a target wires the block into its check task; the kit's own CI runs it. | +| D32 | Fork detector body | **echosphere's merged body**: ignore-driven prune from `git ls-files --others --ignored --exclude-standard --directory \| git check-ignore --stdin` with the three safety rules, the stderr writability probe, `# Depends:` in the header, `.claude-pr` pruned root-anchored, the "a tooling path is not a fork" remediation paragraph, and the residual (a tree under an ignored directory exits 0) named in the header, not only in a scenario table. | +| D33 | Hook stdin/exit contract | **Drain first and never decide on a pipe whose reader can exit first**, in every kit hook, stated once in § Hook authoring with you-are-hear's measured evidence, and enforced by a kit fixture carrying the two portable structural checks plus one over-buffer probe per decision site. The masking half needs a piping caller under `pipefail` (the block's dry-runs, the fixture) — the harness's registered call is not one — and the contract text says so rather than overclaiming. | +| D34 | Sequencing | **One PR**, atomic commits per item. These are defects in one commit's application, not new capability; four passes would be ceremony. | +| D35 | Meta-issue titles at the executor | `/finish` Step 1 admits `[:meta] …` beside the three existing forms. A meta reaches the executor either framed then roughed-in (`cascade-depth:roughed-in`, the § Contribution intake route) or as `[::R<#>]` children; the label the meta *template* applies at framing (`cascade-depth:framed`) is the pre-rough-in state and is not what the executor checks. | +| D36 | Cost reader pricing | Cache reads priced **per tier**: 0.025× for the top tier (Fable 5.1 / Mythos 5.1 — `platform.claude.com/docs/en/build-with-claude/prompt-caching` § Pricing, read 2026-09-07), 0.1× otherwise; the fixture gains a top-tier row. The reference half's quoted list-price row is unchanged (it carries input/output only), so the block's price diff is untouched. | +| D37 | Review-workflow templates | `checks: read` named twice (job `permissions:` and the action's `additional_permissions`, echosphere `93c450a`); the base branch fetched in the checkout step so `origin/main` exists (#58 addendum item 12); a degrade-explicitly clause in the prompt for a diff the turn cap cannot walk (item 11). A file-count-scaled `--max-turns` is **not** adopted — the label path stays the operator's. | +| D38 | Knowledge-backend matcher | Widened to the live Notion tool set: `create\|update\|move\|duplicate\|convert\|delete\|upload\|spawn\|send\|stop` (`upload-skill` replaces a page's body; `spawn-session` / `send-message-to-session` drive an agent that writes; `stop-session` is run-state), dated. A matcher is a dated observation of a vendor's tool names; the comment says so. | +| D39 | ADR index relation form | `adr-new` **reads the index's existing rows and matches their form** before writing one; the kit's own starter keeps the `·` separator as *its* convention with one sentence saying a target's index sets its own. Two targets already contradicted the pinned form in two different ways (#58, 2026-09-07 comment; 2026-09-13 comment item 1). | +| D40 | Record the sha, sync by merge | Scaffold's Cascade metadata table gains a **Kit commit** row; the conventions gain a short § Syncing the kit: three-way `git merge-file` against the recorded install sha (echosphere's method — 47 of 48 `copy` rows auto-resolved), the file-by-file table as the reusable artifact, and the note that a re-homed project sub-block must split its own literals. | + +## What lands — cluster summary + +Clusters are in the order the commits land; each edits surfaces the next assumes, and the block +must be green after every commit. + +### H1 · Settings liveness — closes #58's 2026-09-13 comment (D30) + +- `settings.json`: both `_example_PostToolUse_*` keys deleted; `_comment_hooks` rewritten so the + ADVISORY tier points at the hook headers for the stanza and says why no exemplar object may + sit in this file (mechanism, version, date); the STOP tier names both memory scopes + (`agent-memory`, `agent-memory-local` — 2026-09-13 comment item 5). +- `analyze-on-edit.sh` and `format-on-edit.sh` headers carry the registration stanza verbatim + as the one home ("copy this object into `hooks.PostToolUse` …"). +- `cbk-conventions-reference.md § Hook authoring`: the "exemplar stanzas ship commented out" + bullet replaced by the invariant — no top-level key in `settings.json` may hold an object + carrying `matcher` or a non-empty `hooks`; JSON has no comments, so an exemplar lives in the + hook's header. The evidence paragraph names the fatal diagnostic and the whole-file discard. +- Block: one `jq` line asserting the invariant with a vacuous-read rail; the registry loop's + `..\|objects\|select(has("command"))` walk keeps working (fewer commands to check). + +### H2 · The hook stdin/exit contract — closes #58 item 4 and the you-are-hear #81 class (D33) + +- All nine hooks: `input="$(cat)"` is the first statement after `set -uo pipefail`, above the + dependency check and every early exit; every `printf '%s' "$x" \| grep -Eq …` decision becomes + `grep -Eq … <<<"$x"` with a one-line comment naming the reason; any report capped with + `\| head -N` moves the cap into its producer. Patterns stay byte-identical. +- § Hook authoring gains **the stdin / exit contract** bullet (you-are-hear's text, generalized: + who pays for the masking, why draining is the contract regardless, the here-string rule, the + measured bypasses) and the header shape gains a `Depends:` line naming each dependency and + what its absence costs (fail open, degrade unpruned, degrade unmonitored). +- `.claude/workflows/tests/hook-contract-fixture.sh`: the two structural checks (drain-first + signature; no pipeline into an early-exiting reader, continuations joined, full-line comments + dropped, stated as a tripwire over known spellings) over `.claude/hooks/*.sh`, plus one + over-buffer multi-line payload per decision site against a throwaway `git init` tree on + `main` — the deny must fire, the ask-gate must emit. Run from the block's kit sub-block; never + dependent on the launch directory (#58 item 4's own caution). +- § Verify by payload: the fixture is the durable home; the PR-body table is for branches the + fixture cannot reach (the mutation-only ones, named). + +### H3 · The fork detector — closes #58 items 1, 2 and the 2026-09-07 comment's new defect (D32) + +- `detect-forked-agent-memory.sh` replaced by the exercised body: the ignore-driven prune list + with glob metacharacters escaped before splicing into `-path`, the three protected basenames + never pruned, `git check-ignore` keeping a collapsed ancestor walkable; `find`'s stderr + captured behind a writability probe, degrading to an unmonitored walk that says so; `.git`, + the worktrees directory and `./.claude-pr` pruned unconditionally; the BLOCKED remediation + saying that a harness staging copy, a container workspace or a vendored checkout is not a + fork and nothing is moved or deleted; the header's `Depends:` line and the residual sentence. +- `github-starter-templates.md`: the `.gitignore` starter gains anchored `/.claude-pr/` beside + the other harness transients; `cbk-conventions-reference.md § .gitignore anchoring` names it. +- Block: the Stop-hook live check keeps its stderr; the launch-root dry-runs stop discarding the + hook's own diagnostic (#58 item 11) — stderr captured and printed on failure. + +### H4 · The verification block as a target runs it — closes #58 item 5, the 2026-09-13 comment items 1 and 6 (D31) + +- The advisory-exemplar arm moves to the **project** sub-block behind the target's stated + disposition: the kit tree asserts the two exemplars unregistered; a target that wired one + records `ADVISORY_WIRED="format-on-edit.sh"` (or both) at the top of its sub-block and the + arm asserts exactly that set is registered, once each. A project that follows § Hook + authoring no longer loses the back half of the suite. +- `absent grep … .mcp.json.example` guarded: the operand is included only when the file exists; + a target that commits `.mcp.json` names that instead, and the re-homing note beside the + check says a pre-split project sub-block's unsplit literal trips this very line. +- Two fail-loud rails documented in the block's preamble and shipped as the canonical + extraction: an empty extraction is red; an exit 0 that never printed `verification: done` is + red. The kit's own CI (`.github/workflows/verify.yml`, pinned action SHA, no path filter) + runs the extraction on every PR to `main`; the conventions tell a target to wire the same + extraction as a task its check command calls (echosphere's `cbk-verify` leg is the exemplar). +- Always-loaded budget: the printed total is unchanged in meaning; the README's customization + list states the kit's own figure at this sha as the number a fresh target starts from. + +### H5 · Harness and workflow defects — closes #58 items 6, 7, 8, 9, 10 and the 2026-09-13 comment items 3, 4 (D36) + +- `agent-cost.py`: cache reads priced per tier (`CACHE_READ = {'fable': 0.025}` default 0.10), + docstring corrected; the fixture gains a top-tier transcript row and keeps the opus row at + `$14.25`; the `minutes=None` path, the empty-events transcript and the `--json` + missing-argument exit gain assertions (the smaller-items test gaps). +- `review-sweep.js`: hint match is `f === h \|\| f.startsWith(h + "/")`; the roster prompt keeps + asking for trailing-slash prefixes and the code stops stripping the boundary it needs; the + roster `agent()` call takes `.catch(() => null)` so a throw degrades into the existing + "roster read failed" branch rather than ending the run with no gate line. The design note + records that a segment anchor cannot be expressed as a prefix — enumerate directories. +- `finish-ab.js`: `arm` labels checked distinct beside `anon`; `hallucinations ?? []` at both + dereference sites; `j.effort ?? "high"` evaluated once; the reindex idiom converged with + `review-sweep.js`'s and named once. +- The two stub harnesses stop swallowing exceptions from their own mocks: a thrown mock is a + test failure, and only a *returned* `null` models the runtime's failed-agent path. +- `.claude/workflows/tests/*`: a scenario for `unattributedFlags`; the least-loaded-owner bound + exercised with three reporters. + +### H6 · Records, the executor, the small rot — closes #58's smaller items 1–4, 6, the 2026-09-07 comment's separator item, the 2026-09-13 comment items 1–5 (D35, D39) + +- `commands/finish.md` Step 1 and the bundled `finish-command.md` (re-spliced byte-parallel): + the title precondition admits `[:meta] …`, with the two routes a meta takes to the + executor stated in one sentence; the procedure half unchanged. +- `adr-new/SKILL.md`: the row-writing step reads the index's existing rows and matches their + relation form; the count in "one of two relationships" replaced by the list; `Promotes:` + defined in the § it is filled from. `adr-starters/README.md` keeps its separator and says a + target's index sets its own; `adr-conformance-reviewer.md` cites a correction "by the + register's own id form". +- `framing/references/procedure.md`: "See rough-in's the rough-in skill's" → "See the rough-in + skill's". `rough-in-spec-template.md`: one spelling of the measurement variant marker. +- `cbk-conventions.md`: the Quick-reference row that names a frame's `## Pre-flight checks` + table as append-only is retired (the mutation row names `## Rough-in events` only; no skill + appends to pre-flight). `logging.md` / `testing.md`: the path-scoping callout is written for + the *stamped* state ("this rule loads on the globs above; a stack with inline unit tests + stamps the test directories alone") and the bootstrap row says to rewrite it. +- `require-repo-root-for-agents.sh` header: the Timing paragraph carries both readings of the + payload `cwd` (the header's dated observation; the hooks page's "cwd follows Claude") and + names the disagreement as the re-verify trigger; the guard judges the `cwd` the payload + carries under either. +- `protect-lock-files.sh`: `pubspec.lock` arm, and a `*.lock` fallback arm with a generic + remediation so the next ecosystem is covered by construction. +- Scaffold's Cascade metadata table: a **Kit commit** row; `cbk-conventions-reference.md` gains + § Syncing the kit (D40). + +### H7 · The review-workflow templates and the matcher — closes #58 addendum items 11, 12; (D37, D38) + +- `blueprint/references/templates/claude-review.yml` and `claude.yml`: `checks: read` in the job + `permissions:` block and in `additional_permissions`, with the measured note that + `statuses: read` is needed only when the rollup carries commit statuses; the checkout step + fetches the base ref so `origin/` resolves (or the prompt states that `gh pr diff` is + the only diff source); the prompt's every-file contract gains a degrade clause: past N changed + files, review the authored set the PR body names, post what was read, and say what was not. +- `settings.json` Notion matcher widened per D38, the comment dated; `knowledge-backend.md`'s + hook-enforcement paragraph names the verb set once. + +## Sequencing — one PR (D34) + +Branch `feat/harvest-4-applied-defects`. Commit order: H4's extraction rails and the two check +repairs first (every later commit is verified by the block); H1; H2 (contract text, then hooks, +then the fixture, then the block invocation); H3; H5; H6; H7. Each commit: Conventional Commits +naming the #58 item; the block, the four stub fixtures and the new hook fixture green; `bash -n` +on every hook. One dogfooded review sweep before the draft, its triage in the PR body; the body +carries `Closes #58` and `Closes #33` (its remaining open items are #58's), and the PR-body +`## Verification` list records the before/after always-loaded total. + +## Non-goals — explicitly NOT harvested + +- The collect-then-exit block (shape 3) — fail-fast with repaired checks, per D31. +- A shared hook helper library (`lib/repo-root.sh`) — one target's refactor over three call + sites the kit does not have; the kit's two root-resolving hooks inline it. +- Spellchecker configuration for the bracket-splitting idiom (`typos` and its `[type.*]` table) + — a one-line note in § Verification's preamble names the idiom's cost; no config ships. +- A file-count-scaled `--max-turns` or auto-selected deep-review path in the review template. +- Any target's own fills (globs, roster prefixes, plugin lists, reviewer bodies). +- Any reference to the private target, anywhere in kit content. + +## Verification — definition of done + +1. The block is **green on the kit tree** under literal `bash -e` after every commit, both + sentinels printed, and the CI workflow that runs it is green on the PR. +2. `settings.json` parses; `jq` finds no top-level hook-shaped object; every registered command + is in placeholder form, exists and is executable; the registry comment names every hook + under its tier and both memory scopes. +3. `hook-contract-fixture.sh` green: every hook drains first, no hook pipes into an + early-exiting reader, the two over-buffer probes deny and ask respectively. +4. The four stub fixtures green; `agent-cost-fixture.sh` asserts the opus row at `$14.25` and + the new top-tier row at its per-tier figure. +5. The executor pair byte-parallel with both bundled templates; `/finish` Step 1 names four title + forms. +6. Every hunk ported from a public target is re-authored generic (no crate, package or path + name survives except inside a quoted illustration), and the private-instance denylist grep + is clean. +7. #58 and #33 closed by marker; every #58 item is either landed (named in the PR body by + cluster) or explicitly repositioned as a non-goal above. From 10926006157658ab22695dbdf814f543e0ccd3ad Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 19:54:52 -0600 Subject: [PATCH 02/23] docs(plan): harvest 4 implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteen tasks in the spec's commit order (H4 runner and repairs first, then H1–H3 hooks and settings, H5 harness, H6 records, H7 templates), each ending in the verification block run through its new runner. Six planning decisions recorded for the PR body (D-P-1..6): the extraction rails live in a runner script; the advisory-exemplar arm is kit-tree-only with a project-side ADVISORY_WIRED declaration; the fixture probes the two decision sites the kit ships; review-sweep adopts finish-ab's reindex idiom; the review template already carries checks: read in the job block and lacks it in additional_permissions; the base ref is fetched by a step, not a checkout change. Co-Authored-By: Claude Fable 5.1 --- .../2026-09-21-harvest-4-applied-defects.md | 1337 +++++++++++++++++ 1 file changed, 1337 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md diff --git a/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md b/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md new file mode 100644 index 0000000..ae207cb --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md @@ -0,0 +1,1337 @@ +# Harvest 4 — the kit as applied — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land, in the kit, every issue-#58 finding with a proven fix — settings liveness, the hook stdin/exit contract with its fixture, the exercised fork detector, the verification-block repairs and rails, the harness defects, the records rot, the review-workflow templates — as one PR whose verification block is green after every commit. + +**Architecture:** Every change is an edit to a file the kit already ships, plus three new files (`hook-contract-fixture.sh`, `run-verification-block.sh`, `.github/workflows/verify.yml`). Hunks ported from the two public target projects are re-authored generic. The verification block in `cbk-conventions-reference.md § Verification` is the test for every task; it gains checks as the tasks land and is run through the new extraction runner. + +**Tech Stack:** Markdown, bash (hooks, fixtures, the block), JSON (`settings.json`), JavaScript (workflow scripts + `.mjs` stub harnesses), Python 3 (`agent-cost.py`), YAML (the review-workflow templates, the kit CI). Tools the block needs on the executing machine: `bash`, `jq`, `git`, `node`, `python3`. + +**Spec:** `docs/superpowers/specs/2026-09-21-cascade-kit-harvest-4-design.md` — read it in full before Task 1; each task names the cluster (H1–H7) and decision (D30–D40) it implements. + +## Global Constraints + +- **Portability / sanitized / one-way.** No project name, issue key, package, crate or path from any target lands in kit content; the two public runs (you-are-hear, echosphere) are named only where issue #58 already names them; the private target is never named. Ported hunks are re-authored generic. +- **The block is the gate, after every task.** Run it through the runner Task 1 creates: `bash .claude/workflows/tests/run-verification-block.sh` — green, both sentinels printed. Until Task 1 lands, run the documented extraction: `awk '/^## Verification/{p=1} p' .claude/rules/cbk-conventions-reference.md | awk '/^```bash/{c=1;next} /^```/{c=0} c' > /tmp/cbk-verify.sh && bash -e /tmp/cbk-verify.sh`. +- **New checks are plain lines inside the single fence**; must-be-absent checks use `absent` with a self-splitting literal (`opinionate[d]`); positive chains are wrapped `{ … } || { echo …; exit 1; }`. +- **Hooks:** `bash -n` on every hook after editing it; `set -uo pipefail`, never `set -e`; no bash-4-only builtins (`mapfile`, `readarray`) — a stock macOS bash is 3.2; every hook header carries `Tier:` and, from Task 3, `Depends:`. +- **Executor pair byte-parallel.** Any edit to `.claude/commands/finish.md` is made identically to `.claude/skills/rough-in/references/finish-command.md` below its `--- BEGIN TEMPLATE ---` marker (line 83); the block diffs them. +- **Starters byte-identical.** Any edit to `.claude/skills/scaffold/references/adr-starters/README.md` is made identically to `docs/adr/README.md`; the block diffs the two directories. +- **Never commit on `main`; branch is `feat/harvest-4-applied-defects` (exists).** One commit per task, Conventional Commits naming the #58 item, trailer `Co-Authored-By: Claude Fable 5.1 `. No CI-skip marker on any commit. +- **Platform claims carry a page and a date** (`https://code.claude.com/docs/en/hooks`, read 2026-09-21) or a dated field observation; none from memory. +- **Every `SKILL.md` stays under 500 lines** (the block enforces it). + +## Decisions taken while planning (for the PR body) + +- **D-P-1 — the extraction rails live in a runner script**, `.claude/workflows/tests/run-verification-block.sh`, not inside the block: a block cannot detect its own empty extraction or its own early exit. The kit's CI calls the runner; a target copies it as its check-task body (the conventions say so). +- **D-P-2 — the advisory-exemplar arm stays in the kit sub-block, guarded by "not a target"** (`[ ! -f docs/cbk/scaffold.md ]`), and the project sub-block asserts the target's declared `ADVISORY_WIRED` set instead — one variable, declared at the top of the project sub-block, default empty. +- **D-P-3 — the fixture's over-buffer probes cover the two decision sites the kit ships** (`protect-main-branch.sh`, `guard-pr-state.sh`) plus the detector's second stop; the kit has no close-keyword guard. +- **D-P-4 — the review-sweep retry reindex adopts finish-ab's index-list idiom** (the O(n) one); finish-ab's stays. +- **D-P-5 — `claude-review.yml` template already carries `checks: read` in the job block at `e92e9c4`**; the missing half is `additional_permissions`. `claude.yml` template lacks both. The plan edits exactly those. +- **D-P-6 — the base-ref fetch is a step, not a checkout change**: `ref: head.sha` stays (it is what the reviewer must review); a following step fetches `origin/` so `git diff origin/...HEAD` resolves. + +--- + +### Task 0: Baseline and the plan commit + +**Files:** +- Create: `docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md` (this file) + +- [ ] **Step 1: Confirm the branch and a clean tree** + +Run: `cd /home/j4th/Code/create/context-builder-kit && git branch --show-current && git status --short` +Expected: `feat/harvest-4-applied-defects`, and only this plan file untracked. + +- [ ] **Step 2: Run the block at baseline and record the always-loaded total** + +Run: +```bash +awk '/^## Verification/{p=1} p' .claude/rules/cbk-conventions-reference.md | awk '/^```bash/{c=1;next} /^```/{c=0} c' > /tmp/cbk-verify.sh && bash -e /tmp/cbk-verify.sh 2>&1 | tail -12 +``` +Expected: `always-loaded total: 130405 bytes`, `verification: kit sub-block complete`, `verification: done`, exit 0. Record the total for the PR body. + +- [ ] **Step 3: Run the four stub fixtures** + +Run: `node .claude/workflows/tests/load-workflow-shape.mjs && node .claude/workflows/tests/review-sweep-accounting.mjs && node .claude/workflows/tests/finish-ab-shape.mjs && bash .claude/workflows/tests/agent-cost-fixture.sh; echo "exit=$?"` +Expected: `exit=0`. + +- [ ] **Step 4: Commit the plan** + +```bash +git add docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md +git commit -m "docs(plan): harvest 4 implementation plan + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 1: Verification block — the runner with two rails, the two repaired checks, stderr kept, the kit's own CI (H4; D31, D-P-1, D-P-2) + +**Files:** +- Create: `.claude/workflows/tests/run-verification-block.sh` +- Create: `.github/workflows/verify.yml` +- Modify: `.claude/rules/cbk-conventions-reference.md` — prose above the fence (§ Verification, line 468–471), the fence's preamble comment (lines 473–478), line 493 (`.mcp.json.example`), line 603 (the hook loop), lines 610–612 (launch-root dry-runs), the project sub-block (lines 669–699) + +**Interfaces:** +- Produces: `bash .claude/workflows/tests/run-verification-block.sh` — exit 0 only when the extraction is non-empty, the block exits 0 **and** printed `verification: done`. Every later task's test step uses it. +- Produces: the project sub-block variable `ADVISORY_WIRED` (space-separated basenames; default empty). + +- [ ] **Step 1: Write the runner** + +```bash +#!/usr/bin/env bash +# Runs the verification block (cbk-conventions-reference.md § Verification) with two fail-loud +# rails the block cannot carry itself: an EMPTY extraction is red (the heading or the fence +# moved — a plain `bash -e` on an empty file exits 0), and an exit 0 that never printed the +# closing sentinel is red (the block ended early). The kit's CI calls this; a target project +# copies it as the body of the task its check command runs (cbk-conventions-reference.md +# § Verification › Run it). Run from the repository root: bash .claude/workflows/tests/run-verification-block.sh +set -uo pipefail +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel 2>/dev/null) || { echo "run-verification-block: not inside a git checkout"; exit 1; } +cd "$root" || exit 1 +tmp=$(mktemp) || exit 1 +trap 'rm -f "$tmp"' EXIT +awk '/^## Verification/{p=1} p' .claude/rules/cbk-conventions-reference.md | awk '/^```bash/{c=1;next} /^```/{c=0} c' > "$tmp" +[ -s "$tmp" ] || { echo "verification: EMPTY EXTRACTION — '## Verification' or its bash fence moved in cbk-conventions-reference.md"; exit 1; } +rc=0; out=$(bash -e "$tmp" 2>&1) || rc=$? +printf '%s\n' "$out" +[ "$rc" -eq 0 ] || { echo "verification: block exited $rc"; exit "$rc"; } +grep -q '^verification: done$' <<<"$out" || { echo "verification: exit 0 WITHOUT the done sentinel — the block ended early"; exit 1; } +``` + +Then: `chmod +x .claude/workflows/tests/run-verification-block.sh` + +- [ ] **Step 2: Prove both rails by mutation, then restore** + +Run (empty extraction): `cp .claude/rules/cbk-conventions-reference.md /tmp/ref.bak && sed -i 's/^## Verification$/## Verificationx/' .claude/rules/cbk-conventions-reference.md && bash .claude/workflows/tests/run-verification-block.sh; echo "exit=$?"; cp /tmp/ref.bak .claude/rules/cbk-conventions-reference.md` +Expected: `verification: EMPTY EXTRACTION …`, `exit=1`. + +Run (missing sentinel): `sed -i 's/^echo "verification: done"$/echo "verification: dne"/' .claude/rules/cbk-conventions-reference.md && bash .claude/workflows/tests/run-verification-block.sh | tail -2; cp /tmp/ref.bak .claude/rules/cbk-conventions-reference.md && git diff --stat .claude/rules/cbk-conventions-reference.md` +Expected: `verification: exit 0 WITHOUT the done sentinel …`; the restore leaves no diff. + +- [ ] **Step 3: Repair the `.mcp.json.example` operand (block line 493)** + +Replace: +```bash +absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md .mcp.json.example +``` +with: +```bash +# The example env file is an operand only where it exists: a target that commits `.mcp.json` instead +# names that file here, and a project sub-block re-homed from a pre-split contract must split its own +# `opinionated profile` literal or this very line matches it (#58, second application, item 1). +mcpx=""; [ -f .mcp.json.example ] && mcpx=.mcp.json.example +absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md $mcpx +``` + +- [ ] **Step 4: Make the advisory-exemplar arm kit-tree-only (block line 603) and add the project-side arm** + +Replace, inside the `for h in .claude/hooks/*.sh; do … done` line, the arm: +```bash +case "$b" in format-on-edit.sh|analyze-on-edit.sh) [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered"; exit 1; };; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac +``` +with: +```bash +case "$b" in format-on-edit.sh|analyze-on-edit.sh) if [ ! -f docs/cbk/scaffold.md ]; then [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered (the kit tree ships it unregistered; a target declares ADVISORY_WIRED in its sub-block)"; exit 1; }; fi;; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac +``` +and change the comment line above the loop from "the advisory exemplars stay unregistered" to "the advisory exemplars stay unregistered on the kit tree (a target asserts its own wiring in the project sub-block)". + +In the project sub-block, directly after the `if [ -f docs/cbk/scaffold.md ]; then` line, insert: +```bash + + # Advisory hooks the project wired (§ Hook authoring): name each registered one here, space-separated. + # Each named hook must be registered exactly once; each unnamed one zero times. Default: none wired. + ADVISORY_WIRED="${ADVISORY_WIRED:-}" + for b in format-on-edit.sh analyze-on-edit.sh; do n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case " $ADVISORY_WIRED " in *" $b "*) [ "$n" -eq 1 ] || { echo "advisory hook $b is declared wired but registered $n times"; exit 1; };; *) [ "$n" -eq 0 ] || { echo "advisory hook $b is registered but not declared in ADVISORY_WIRED"; exit 1; };; esac; done +``` + +- [ ] **Step 5: Keep the launch-root dry-runs' diagnostic (block lines 610–612)** + +Replace the three lines: +```bash +rc=0; printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || rc=$? +[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks)"; exit 1; } +printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || { echo "launch-root guard blocked a root dispatch"; exit 1; } +``` +with: +```bash +# stderr is kept and printed on failure: the hook's own reason is the diagnostic (#58 item 11). +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks). The hook said:"; printf ' %s\n' "$err"; exit 1; } +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 0 ] || { echo "launch-root guard blocked a root dispatch (exit $rc). The hook said:"; printf ' %s\n' "$err"; exit 1; } +``` + +- [ ] **Step 6: State the runner in the prose above the fence** + +After the paragraph that precedes the fence in § Verification (line 468–471), add: +```markdown +**Run it** through `.claude/workflows/tests/run-verification-block.sh`: it performs the documented extraction and adds two fail-loud rails the block cannot carry for itself — an empty extraction is red (a plain `bash -e` on an empty file exits 0), and an exit 0 that never printed `verification: done` is red. The kit's CI runs it on every pull request; a target wires the same script as the body of a task its check command depends on (a check nobody re-runs is a belief with a date on it — #58, second application, item 7). The block stays fail-fast: every red is fixed, or the check is narrowed in the project's own copy with an inline comment saying why — a "recorded" red cannot reach the sentinels. +``` + +- [ ] **Step 7: The kit's own CI** + +Create `.github/workflows/verify.yml`: +```yaml +name: Verification block + +# Runs cbk-conventions-reference.md § Verification through its runner on every pull request to +# main — no path filter, so it can be a required check (the required-checks trap in +# cbk-conventions.md § `[skip ci]` rule). ubuntu-latest ships bash, jq, git, node and python3 +# (dated observation, 2026-09-21; the runner names any tool it cannot find). + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + verify: + name: Verification block + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Run the block through its runner + run: bash .claude/workflows/tests/run-verification-block.sh +``` + +- [ ] **Step 8: Run the runner and the mutation for the new arm** + +Run: `bash .claude/workflows/tests/run-verification-block.sh | tail -4; echo "exit=$?"` +Expected: both sentinels, `exit=0`. + +Run (mutation — a target that wired the formatter, undeclared): in a throwaway copy `cp -r . /tmp/kitcopy && cd /tmp/kitcopy && touch docs/cbk/scaffold.md 2>/dev/null || (mkdir -p docs/cbk && touch docs/cbk/scaffold.md)` then register `format-on-edit.sh` in `settings.json`'s `PostToolUse` with `jq`, run the runner, expect `advisory hook format-on-edit.sh is registered but not declared in ADVISORY_WIRED`; then export `ADVISORY_WIRED=format-on-edit.sh` and expect the project sub-block to pass that arm. `cd -; rm -rf /tmp/kitcopy`. + +- [ ] **Step 9: Commit** + +```bash +git add .claude/workflows/tests/run-verification-block.sh .github/workflows/verify.yml .claude/rules/cbk-conventions-reference.md +git commit -m "fix(verification): runner with two rails, target-safe advisory arm, guarded .mcp.json.example, diagnostics kept, kit CI (#58 items 5, 11; second application 1, 6, 7) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: Settings liveness — no exemplar object, the invariant asserted, the stanza in the hook headers (H1; D30) + +**Files:** +- Modify: `.claude/settings.json` — delete the `_example_PostToolUse_formatter` and `_example_PostToolUse_analyzer` keys (lines 62–79); rewrite the ADVISORY and STOP sentences of `_comment_hooks` (line 3) +- Modify: `.claude/hooks/format-on-edit.sh` and `.claude/hooks/analyze-on-edit.sh` — header gains `Register:` lines +- Modify: `.claude/rules/cbk-conventions-reference.md` — § Hook authoring bullet (line 402); block: the invariant beside the hook-registry checks +- Modify: `README.md` § Customization item 3 — one sentence + +- [ ] **Step 1: Delete the two exemplar objects from `settings.json`** + +Run: `jq 'del(._example_PostToolUse_formatter, ._example_PostToolUse_analyzer)' .claude/settings.json > /tmp/s.json && mv /tmp/s.json .claude/settings.json && jq -r 'keys[]' .claude/settings.json` +Expected: no `_example_` key listed. (jq re-indents with two spaces, matching the file.) + +- [ ] **Step 2: Rewrite the ADVISORY and STOP sentences in `_comment_hooks`** + +In the `_comment_hooks` string, replace the sentence beginning `ADVISORY (PostToolUse, exit 0 always) ships as unregistered exemplars with their stanzas beside \`hooks\`:` up to `— copy a stanza into hooks.PostToolUse after wiring its case arms to your stack.` with: + +``` +ADVISORY (PostToolUse, exit 0 always) ships as two unregistered exemplars, format-on-edit.sh and analyze-on-edit.sh (the analyzer runs the project's analyzer on the edited file's package, errors only; measured under a second per package on a real run, 2026-09-05); each hook's header carries the registration stanza to copy into hooks.PostToolUse once its case arms are wired. NO exemplar object lives in this file: a top-level key holding a matcher/hooks object makes the harness discard the whole settings file — every guard, enabledMcpjsonServers and enabledPlugins inert, silently (Claude Code 2.1.270–2.1.278; single-variable probe on a real application 2026-09-13, the parser's fatal diagnostic read from the 2.1.278 bundle 2026-09-21), and the verification block asserts none is present. +``` + +and replace `detect-forked-agent-memory.sh exits 2 while a \`.claude/agent-memory/\` tree exists anywhere but the root` with `detect-forked-agent-memory.sh exits 2 while a \`.claude/agent-memory/\` or \`.claude/agent-memory-local/\` tree exists anywhere but the root (both memory scopes fork the same way)`. + +Use `jq --arg c "" '._comment_hooks = $c'` or a careful editor replacement inside the JSON string (escape `"` as `\"`). Verify: `jq -r '._comment_hooks' .claude/settings.json | grep -c 'NO exemplar object'` → `1`. + +- [ ] **Step 3: The stanza in each advisory hook's header** + +In `.claude/hooks/format-on-edit.sh`, after the `# Tier: ADVISORY …` header line, add: +```bash +# Register: copy this object into hooks.PostToolUse in .claude/settings.json once the case +# arms are wired — never as a top-level key (cbk-conventions-reference.md § Hook +# authoring: a hook-shaped object outside `hooks` voids the whole settings file): +# { "matcher": "Edit|Write|MultiEdit", +# "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" } ] } +``` +The same six lines in `.claude/hooks/analyze-on-edit.sh` after its `# Tier: ADVISORY.` line, with `analyze-on-edit.sh` in the command. Then edit the analyzer header's existing sentence that says it "registers it via the `_example_…` stanza" (lines 2–4) to "the registration stanza is the `Register:` block below". + +- [ ] **Step 4: Rewrite § Hook authoring's exemplar bullet (reference.md line 402)** + +Replace the bullet `- **Exemplar stanzas ship commented out.** …` with: +```markdown +- **No hook-shaped object outside `hooks`, ever.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered, and its registration stanza lives in the hook's own header (`Register:`), copied into `hooks.PostToolUse` after the case arms are filled — so a fresh checkout never runs a formatter it does not have. JSON has no comments, and a "commented-out" stanza approximated by a live top-level object is a defect: any top-level key that is not `hooks` (nor one of the harness's own settings keys) whose value carries `matcher` or a non-empty `hooks` is a fatal settings diagnostic, after which the parser returns no settings at all — every guard, `enabledMcpjsonServers` and `enabledPlugins` inert, silently, with the verification block green (Claude Code 2.1.270–2.1.278: found on a real application by single-variable probe, 2026-09-13; the fatal path read from the 2.1.278 bundle, 2026-09-21; re-verify after upgrades). The block asserts the invariant on every run. +``` +Then grep the file for any remaining `_example_PostToolUse` mention (the analyzer bullet says "the exemplar ships with them commented, inside the `case`" — that is about the case arms, keep it) and fix wording that still says the stanza sits "beside `hooks`". + +- [ ] **Step 5: The invariant in the block** + +Directly after the line `hookcomment=$(jq -r '._comment_hooks' .claude/settings.json)` insert: +```bash +# No hook-shaped object at the top level of settings.json (§ Hook authoring): the harness discards the +# whole file on one, silently. An empty key read is red, never a vacuous pass. +[ "$(jq -r 'keys | length' .claude/settings.json)" -ge 1 ] || { echo "settings.json: no top-level keys read — the check below would pass vacuously"; exit 1; } +bad=$(jq -r 'to_entries | map(select(.key != "hooks" and (.value | type == "object") and ((.value | has("matcher")) or (.value | has("hooks"))))) | .[].key' .claude/settings.json); [ -z "$bad" ] || { echo "settings.json: hook-shaped top-level object(s) void the whole file: $bad"; exit 1; } +absent grep -n '"_example_PostToolUse_' .claude/settings.json +``` + +- [ ] **Step 6: README § Customization item 3** + +Append to item 3's sentence: ` Never add a hook-shaped object as a top-level key while doing so — the advisory hooks' registration stanzas live in their headers (see \`.claude/rules/cbk-conventions-reference.md\` § Hook authoring).` + +- [ ] **Step 7: Prove the invariant by mutation, then run the runner** + +Run: `cp .claude/settings.json /tmp/s.bak && jq '. + {"_example_PostToolUse_x": {"matcher":"Edit","hooks":[{"type":"command","command":"x"}]}}' /tmp/s.bak > .claude/settings.json && bash .claude/workflows/tests/run-verification-block.sh | grep 'void the whole file'; cp /tmp/s.bak .claude/settings.json` +Expected: `settings.json: hook-shaped top-level object(s) void the whole file: _example_PostToolUse_x`. + +Run: `bash -n .claude/hooks/format-on-edit.sh && bash -n .claude/hooks/analyze-on-edit.sh && bash .claude/workflows/tests/run-verification-block.sh | tail -3` +Expected: both sentinels, exit 0. + +- [ ] **Step 8: Commit** + +```bash +git add .claude/settings.json .claude/hooks/format-on-edit.sh .claude/hooks/analyze-on-edit.sh .claude/rules/cbk-conventions-reference.md README.md +git commit -m "fix(settings): no exemplar object in settings.json — the stanza moves to the hook headers and the block asserts the invariant (#58, the settings-liveness finding) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: § Hook authoring — the stdin/exit contract and the `Depends:` header line (H2; D33) + +**Files:** +- Modify: `.claude/rules/cbk-conventions-reference.md` § Hook authoring — the `**Header.**` bullet, the `**The stdin / exit contract.**` bullet, the `**Verify by payload.**` bullet + +- [ ] **Step 1: The header shape gains `Depends:`** + +In the `**Header.**` bullet, after the clause that names `Tier:`, add: `; a \`Depends:\` line naming each external dependency (jq, git, mktemp, a sourced helper) and what its absence costs — fail open, degrade unpruned, degrade unmonitored — so a rewrite that adds a dependency cannot leave the header claiming none (#58, 2026-09-07 comment).` + +- [ ] **Step 2: Replace the stdin/exit-contract bullet** + +Replace the bullet beginning `- **The stdin / exit contract.** JSON on stdin` with: +```markdown +- **The stdin / exit contract.** **Drain stdin before anything that can exit** — `input="$(cat)"` is the first statement after `set -uo pipefail`, above the dependency check and every other early exit. A hook that exits unread leaves its caller holding an open pipe with no reader, so the caller takes SIGPIPE; the masking needs a shell pipeline under `pipefail` (`printf … | hook`), where the writer's 141 becomes the *pipeline's* status and hides the hook's own exit code, so a guard that failed open correctly reads as a crash. That is exactly the shape of this file's § Verification dry-runs, the hook fixture, and any script that pipes a payload in; the harness's registered call is not that shape (a `command` entry with no `args` runs as one process with the payload written to its stdin — `https://code.claude.com/docs/en/hooks`, read 2026-09-21), and draining first is the contract regardless, because the test and verification surfaces are where a hook's exit code is read as its verdict. A payload smaller than the pipe buffer lets the writer finish first, so the race is one a small-payload test almost always wins — a regression test for a race must not itself be a race. **And never decide on the status of a pipeline whose reader can exit first.** `printf '%s' "$x" | grep -Eq …` reads like a string test, but grep exits on its first matching *line*, so a multi-line subject past the buffer leaves the writer to take SIGPIPE and `pipefail` turns a match into a 141 the `if` reads as *no match* — a guard bypassed with no warning, the one fail-open shape the next bullet forbids. A single long line hides it (grep must reach EOF to complete the line), so a filler-on-one-line probe reads clean. Feed the subject as a here-string (`grep -Eq … <<<"$x"`), which keeps grep's line semantics byte-for-byte and leaves nothing still writing when the reader exits (bash hands a small subject a pipe it has already filled and spills a large one to an unlinked temp file), and cap a report inside its producer (awk's own counter), never with `| head -N`. Measured on a real application, 2026-09-18: a >64 KiB multi-line commit body bypassed the main-branch deny outright and a long `gh pr merge --body` skipped the PR-state ask-gate (you-are-hear #81 → PR #82). Both halves are enforced by `.claude/workflows/tests/hook-contract-fixture.sh`, which reads every hook: one structural check asserts the drain is the first statement, the other that no hook pipes into an early-exiting reader — a tripwire over known spellings, not a proof, so a new hook still earns a behavioural case. Then the wire contract: JSON on stdin (`tool_name`, `tool_input`, and the common fields, `cwd` among them — `https://code.claude.com/docs/en/hooks-guide` § How hooks work). Exit 2 + stderr blocks (deny), exit 0 allows; an ask-gate prints `hookSpecificOutput.permissionDecision: "ask"` with a reason and exits 0 (`https://code.claude.com/docs/en/hooks`: allow / deny / ask / defer). Matching hooks in one group run in parallel — never rely on order between two hooks on the same event. `Stop` takes no matcher; `SubagentStop` matches agent types, and a finishing subagent has no hand-off — a repair-before-stop hook belongs on `Stop`. Verified 2026-09-06; re-verify after harness upgrades. +``` + +- [ ] **Step 3: Verify-by-payload names the fixture as the durable home** + +In the `**Verify by payload.**` bullet, replace `the table of expected and observed exits goes in the PR body` with `the branches a payload can reach are asserted in \`.claude/workflows/tests/hook-contract-fixture.sh\` (durable, run by the block); the PR body's table is for the branches only a mutation can reach, named as such (#58 item 4)`. + +- [ ] **Step 4: Run the runner and commit** + +Run: `bash .claude/workflows/tests/run-verification-block.sh | tail -3` → both sentinels, exit 0. + +```bash +git add .claude/rules/cbk-conventions-reference.md +git commit -m "docs(hooks): the stdin/exit contract — drain first, never decide on a pipe whose reader can exit first; Depends: in the header shape (#58 item 4; you-are-hear #81) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Every hook drains first; the three decision sites use here-strings (H2; D33) + +**Files:** +- Modify: `.claude/hooks/format-on-edit.sh` (drain at 23 → after line 16), `analyze-on-edit.sh` (32 → after 27), `guard-pr-state.sh` (27 → after 18; here-string at 37), `protect-immutable-adrs.sh` (38 → after 24), `protect-lock-files.sh` (40 → after 24), `protect-main-branch.sh` (37 → after 26; here-string at 57), `require-repo-root-for-agents.sh` (55 → after 44), `require-knowledge-backend-ok.sh` (line 20 `cat > /dev/null` → `input="$(cat)"`), `detect-forked-agent-memory.sh` (rewritten wholesale in Task 6 — skip here) + +- [ ] **Step 1: Move the drain in the seven jq-checking hooks** + +For each of `format-on-edit.sh analyze-on-edit.sh guard-pr-state.sh protect-immutable-adrs.sh protect-lock-files.sh protect-main-branch.sh require-repo-root-for-agents.sh`: delete the existing `input="$(cat)"` line and insert, immediately after the `set -uo pipefail` line: +```bash + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" +``` +A `sed` that does both per file (GNU sed): +```bash +for h in format-on-edit analyze-on-edit guard-pr-state protect-immutable-adrs protect-lock-files protect-main-branch require-repo-root-for-agents; do + f=.claude/hooks/$h.sh + sed -i '/^input="\$(cat)"$/d' "$f" + sed -i '/^set -uo pipefail$/a\ +\ +# Drain stdin before any early exit, or a piping caller'"'"'s SIGPIPE masks this hook'"'"'s own\ +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract).\ +input="$(cat)"' "$f" + bash -n "$f" || exit 1 +done +``` +Check: `grep -n -A4 '^set -uo pipefail' .claude/hooks/*.sh | grep 'input=' | wc -l` → `7` (the knowledge-backend gate and the detector are handled separately). + +- [ ] **Step 2: The knowledge-backend gate drains into a variable, first** + +In `require-knowledge-backend-ok.sh`, replace line 20 `cat > /dev/null # consume stdin; the decision is unconditional for matched tools` with: +```bash +# Drain stdin first (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract); +# the decision is unconditional for matched tools, so the payload is read and not inspected. +input="$(cat)" +``` +(`set -u` is on and `input` is unused: append `: "$input"` on the next line so a stricter shell never flags it.) + +- [ ] **Step 3: Here-strings at the two decision sites** + +`guard-pr-state.sh` line 37 — replace: +```bash +if printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])'; then +``` +with: +```bash +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])' <<<"$command"; then +``` +`protect-main-branch.sh` line 57 — replace: +```bash +if ! printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'; then +``` +with: +```bash +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if ! grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' <<<"$command"; then +``` +Patterns byte-identical; the here-string's trailing newline is immaterial (`$` is end-of-line). + +- [ ] **Step 4: Prove the bypass is closed (before Task 5's fixture makes it durable)** + +Run: +```bash +body='line of a long body wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww'; while [ ${#body} -lt 250000 ]; do body="$body +$body"; done +r=$(mktemp -d); git -c init.defaultBranch=main init -q "$r"; git -C "$r" -c user.email=f@x -c user.name=f commit -q --allow-empty -m init +printf "git commit -F - < "$r/pay" +.claude/hooks/protect-main-branch.sh < "$r/pay" >/dev/null 2>&1; echo "exit=$?"; rm -rf "$r" +``` +Expected: `exit=2` (before Step 3 the same probe exits 0 — run it once on `git stash` to see the bypass, then `git stash pop`). + +- [ ] **Step 5: Run the runner; commit** + +Run: `for h in .claude/hooks/*.sh; do bash -n "$h" || echo "SYNTAX $h"; done; bash .claude/workflows/tests/run-verification-block.sh | tail -3` +Expected: no SYNTAX line; both sentinels. + +```bash +git add .claude/hooks/ +git commit -m "fix(hooks): drain stdin first in every hook; decide on here-strings, never on a pipe whose reader can exit first (#58 item 4; you-are-hear #81) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: `hook-contract-fixture.sh` — the two structural checks and the over-buffer probes, run from the block (H2; D33, D-P-3) + +**Files:** +- Create: `.claude/workflows/tests/hook-contract-fixture.sh` +- Modify: `.claude/rules/cbk-conventions-reference.md` — block: invoke the fixture beside the other fixtures + +**Interfaces:** +- Consumes: the hooks as Task 4 left them; `detect-forked-agent-memory.sh` as Task 6 will leave it (the second-stop probe below passes on both bodies — the kit's and the exercised one — because both honour `stop_hook_active`). + +- [ ] **Step 1: Write the fixture** + +```bash +#!/usr/bin/env bash +# Fixture for the kit's hooks: the two properties every hook must have regardless of what it +# guards (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract), asserted +# structurally over every .claude/hooks/*.sh, plus one over-buffer behavioural probe per +# decision site the kit ships. Runs against throwaway `git init` trees under mktemp, never the +# real checkout, and never depends on the directory it is launched from. Run by the +# verification block; also: bash .claude/workflows/tests/hook-contract-fixture.sh +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +hooks="$here/../../hooks" +MAINBRANCH="$hooks/protect-main-branch.sh" +PRSTATE="$hooks/guard-pr-state.sh" +STOP="$hooks/detect-forked-agent-memory.sh" +for h in "$MAINBRANCH" "$PRSTATE" "$STOP"; do [ -x "$h" ] || { echo "hook-contract-fixture: $h is missing or not executable"; exit 1; }; done +d=$(mktemp -d) +cleanup() { chmod -R u+rwX "$d" 2>/dev/null || true; rm -rf "$d"; } +trap cleanup EXIT + +RC=0; ERR=""; OUT="" +want() { [ "$RC" -eq "$1" ] || { echo "FAIL: $2 (want exit $1, got $RC)"; [ -n "$ERR" ] && printf ' stderr: %s\n' "$ERR"; exit 1; }; } +says() { grep -q -- "$1" <<<"$ERR" || { echo "FAIL: $2 (stderr does not carry '$1')"; printf ' stderr: %s\n' "$ERR"; exit 1; }; } + +# ── structural check 1: the drain is the first statement after `set -uo pipefail` ── +# Comment and blank lines are skipped; the first two statements must be exactly these. +want_drain_first="$(printf 'set -uo pipefail\ninput="$(cat)"')" +for h in "$hooks"/*.sh; do + sig="$(awk '/^[[:space:]]*(#|$)/ { next } n < 2 { print; n++ }' "$h")" + [ "$sig" = "$want_drain_first" ] || { + echo "FAIL: $(basename "$h") does not drain stdin as its first statement" + printf '%s\n' "$sig" | sed 's/^/ reads instead: /' + exit 1 + } +done + +# ── structural check 2: no hook decides on a pipeline whose reader can exit first ── +# A TRIPWIRE over known spellings (grep -q/-l/--quiet/--silent/-m N, head, read, sed …q, awk … exit +# after a `|`), not a proof; full-line comments are dropped (hooks quote the idiom while explaining +# it) and backslash/pipe continuations are joined first. A new hook still earns a behavioural case. +early_readers='[|][[:space:]]*(([{(]|timeout|xargs|env|stdbuf|while)[[:space:]]*[^|]*)?(grep[^|]*([[:space:]]-[A-Za-z]*[ql][A-Za-z]*([[:space:]]|$)|--quiet|--silent|--max-count|[[:space:]]-m[[:space:]]*[0-9])|head([[:space:]]|$)|read([[:space:]]|$)|sed[^|]*[[:space:]]['"'"'"]?[0-9$]*q|awk[^|]*exit)' +join_pipelines=' + { line = $0 + if (line ~ /^[[:space:]]*#/) line = "" + buf = buf line + if (buf ~ /[|\\][[:space:]]*$/) { sub(/\\[[:space:]]*$/, "", buf); next } + print NR ": " buf; buf = "" + } + END { if (buf != "") print NR ": " buf } +' +for h in "$hooks"/*.sh; do + hit="$(awk "$join_pipelines" "$h" | grep -E "$early_readers" || true)" + [ -z "$hit" ] || { + echo "FAIL: $(basename "$h") decides on a pipeline whose reader can exit first" + printf '%s\n' "$hit" | sed 's/^/ /' + exit 1 + } +done + +# ── behavioural probes: a body of many lines, past any plausible reader ceiling ── +manybody='line of a long body wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww' +while [ ${#manybody} -lt 250000 ]; do manybody="$manybody +$manybody"; done +cmdpay() { jq -Rs --arg cwd "${2:-}" '{tool_name:"Bash",tool_input:{command:.},cwd:$cwd}' "$1"; } + +# protect-main-branch is a HARD-DENY: a long commit body must not buy a bypass. +mainrepo="$d/mainrepo"; mkdir -p "$mainrepo" +git -c init.defaultBranch=main init -q "$mainrepo" +git -C "$mainrepo" -c user.email=f@x -c user.name=f commit -q --allow-empty -m init +printf "git commit -F - < "$d/cmd-commit" +cmdpay "$d/cmd-commit" "$mainrepo" > "$d/pay-commit" +RC=0; ERR="$("$MAINBRANCH" < "$d/pay-commit" 2>&1 >/dev/null)" || RC=$? +want 2 "a commit on main is denied however long the command string is" +says "BLOCKED" "the long-command denial names itself" + +# guard-pr-state is an ASK-GATE: its deliverable is the decision on STDOUT. +printf 'gh pr merge 5 --squash --body "%s"' "$manybody" > "$d/cmd-merge" +cmdpay "$d/cmd-merge" > "$d/pay-merge" +RC=0; ERR=""; OUT="$("$PRSTATE" < "$d/pay-merge" 2>/dev/null)" || RC=$? +want 0 "the PR-state ask-gate still exits 0 on a long command" +grep -q '"permissionDecision": "ask"' <<<"$OUT" || { echo "FAIL: gh pr merge with a long body did not raise the ask-gate"; exit 1; } + +# The detector reads one field the same way; the cost there is the loop guard: a payload whose +# field sits early with the bulk after it must still be read as a second stop. +repo="$d/repo"; mkdir -p "$repo"; git -c init.defaultBranch=main init -q "$repo" +mkdir -p "$repo/pkg/a/.claude/agent-memory/reviewer" +printf '%s' "$manybody" > "$d/padfile" +jq -Rs '{stop_hook_active:true,pad:.}' "$d/padfile" > "$d/pay-stop" +RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$STOP" < "$d/pay-stop" 2>&1 >/dev/null)" || RC=$? +want 0 "a second stop proceeds however long the payload is" +says "still present after one fix attempt" "and warns instead of blocking again" + +echo "hook-contract-fixture: ok" +``` +Then `chmod +x .claude/workflows/tests/hook-contract-fixture.sh`. + +- [ ] **Step 2: Run it; prove each structural check by mutation** + +Run: `bash .claude/workflows/tests/hook-contract-fixture.sh` → `hook-contract-fixture: ok`. + +Mutation 1: `cp .claude/hooks/guard-pr-state.sh /tmp/g.bak && sed -i '0,/^input="\$(cat)"$/{/^input="\$(cat)"$/d}' .claude/hooks/guard-pr-state.sh && bash .claude/workflows/tests/hook-contract-fixture.sh; cp /tmp/g.bak .claude/hooks/guard-pr-state.sh` → `FAIL: guard-pr-state.sh does not drain stdin as its first statement`. + +Mutation 2: `sed -i "s|if ! grep -Eq '\(^|if ! printf '%s' \"\$command\" \| grep -Eq '(^|" .claude/hooks/protect-main-branch.sh` (re-introduce the pipe on the decision line, then run the fixture) → `FAIL: protect-main-branch.sh decides on a pipeline whose reader can exit first`; restore with `git checkout .claude/hooks/protect-main-branch.sh` — no, Task 4's edit is uncommitted only if you skipped its commit; use `cp` backups as in mutation 1. + +- [ ] **Step 3: Invoke it from the block** + +After the line `bash .claude/workflows/tests/agent-cost-fixture.sh || { echo "agent-cost.py regressed on the fixture"; exit 1; }` insert: +```bash +# Every hook honours the stdin/exit contract (§ Hook authoring): structural checks over .claude/hooks/*.sh +# plus one over-buffer probe per decision site. Runs in throwaway trees; never touches this checkout. +bash .claude/workflows/tests/hook-contract-fixture.sh || { echo "a hook violates the stdin/exit contract (the fixture names it)"; exit 1; } +``` + +- [ ] **Step 4: Run the runner; commit** + +Run: `bash .claude/workflows/tests/run-verification-block.sh | grep -E 'hook-contract-fixture: ok|verification: done'` → both lines. + +```bash +git add .claude/workflows/tests/hook-contract-fixture.sh .claude/rules/cbk-conventions-reference.md +git commit -m "test(hooks): hook-contract-fixture — drain-first and no-early-reader asserted structurally, over-buffer probes on the two decision sites, run by the block (#58 item 4) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: The fork detector — the exercised body, `.claude-pr` pruned, tooling paths are not forks (H3; D32) + +**Files:** +- Modify: `.claude/hooks/detect-forked-agent-memory.sh` — full replacement +- Modify: `.claude/rules/cbk-conventions-reference.md` § .gitignore anchoring — the harness-transients bullet (line 271) + +- [ ] **Step 1: Replace the hook body** + +Write `.claude/hooks/detect-forked-agent-memory.sh` as the exercised body (issue #58 items 1–2 and the 2026-09-07 comment, re-authored generic). The full file: + +```bash +#!/usr/bin/env bash +# Stop hook: refuse to finish while a reviewer's `memory: project` tree sits +# anywhere but the repository root. +# +# require-repo-root-for-agents.sh refuses the dispatch that causes a fork: a +# subagent starts in the main conversation's current working directory, and +# `memory: project` is the relative path `.claude/agent-memory//` +# (https://code.claude.com/docs/en/sub-agents, verified 2026-09-06). This hook +# catches the OUTCOME, whatever produced it — a second `.claude/agent-memory/` +# under a package, the shape a real run committed and repaired by hand. On the +# flip's auto-review it was found by a reviewer walking every changed file; +# here it is found before the hand-off, by the agent that made it. +# +# Blocked: the main conversation's stop, once, while a stray agent-memory or +# agent-memory-local directory exists — both memory scopes fork the same +# way (exit 2 + the remediation on stderr). +# Allowed: a clean tree; a second stop after one block (`stop_hook_active`); +# a working directory that is not a git checkout (fail-open with a warning); +# a tree under a directory the project's own ignore rules exclude, or under +# `./.claude-pr` — see Residual below. +# Event: Stop only. Not SubagentStop — a finishing subagent has no hand-off +# to repair the tree in, and `stop_hook_active` is documented for +# Stop; registering there would block every subagent of a review +# pass while a stray tree exists (demonstrated 2026-09-06). +# Loop: exit 2 on a Stop hook blocks the stop and feeds stderr to the +# agent (https://code.claude.com/docs/en/hooks — exit-code table). +# `stop_hook_active` is true when the agent is already continuing +# from this hook: warn and let it stop. The harness overrides a Stop +# hook after eight consecutive blocks without progress +# (https://code.claude.com/docs/en/hooks-guide § Limitations and +# troubleshooting › "Stop hook hits the block cap"; verified +# 2026-09-06, re-verify after upgrades), so this hook blocks at most +# once per stop. A payload without the field is treated as the first +# block (the assumption is stated, not silently defaulted). +# Residual: the walk prunes every directory the project's ignore rules exclude, so +# a tree placed by hand or by tooling UNDER an ignored directory (a build +# output, a tool cache) exits 0 — the dispatch that would create one is +# refused by require-repo-root-for-agents.sh, which is the guard for that +# case (#58 item 1's scenario table, reproduced independently 2026-09-13). +# `./.claude-pr` is pruned unconditionally: it is the staging copy a +# hosted review action makes of the branch's `.claude/` tree, committed +# memory included — a copy, not a fork (#58, 2026-09-07 comment). +# Path: registered as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… (handlers run +# in the current directory — https://code.claude.com/docs/en/hooks). +# Tier: STOP. +# Depends: NO jq — the one field this hook reads (`stop_hook_active`) is matched +# with grep, so this backstop works exactly when the jq-dependent guards +# have failed open. `git` is required for the root and used for the prune +# list: without it the guard above fails open, and a failing `git +# ls-files`/`check-ignore` leaves the prune list empty, so the scan runs +# unpruned — slower, never blinder. `mktemp` is optional: it holds the +# scan's stderr, and when no scratch file can be created the walk still +# runs and says so rather than reporting a clean tree it could not have +# verified. No bash-4-only builtins (a stock macOS bash is 3.2): the +# directory list is read with a while loop, not mapfile. + +set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" + +# The scan runs from the checkout's git top-level. CLAUDE_PROJECT_DIR is exported to the hook +# process (https://code.claude.com/docs/en/hooks — the same page documents the placeholder); +# when it is absent the process's own $PWD may be a subdirectory — the drift case — and a scan +# rooted there would treat /.claude/agent-memory as the canonical tree and miss the fork. +PROJECT_DIR="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2>/dev/null)" || { + echo "detect-forked-agent-memory: WARNING — ${CLAUDE_PROJECT_DIR:-$PWD} is not inside a git checkout; fork detection inactive for this stop." >&2 + echo " Backstop: the verification block's Stop-hook check (cbk-conventions-reference.md § Verification)." >&2 + exit 0 +} + +# Absent field ⇒ first block. Present and true ⇒ the agent is already continuing from this hook. +# Matched with grep, not jq, so fork detection has no environment dependency to fail open on. +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +active="" +grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' <<<"$input" && active="true" + +cd "$PROJECT_DIR" || { + echo "detect-forked-agent-memory: WARNING — cannot enter $PROJECT_DIR; fork detection inactive for this stop." >&2 + echo " Backstop: the verification block's Stop-hook check (cbk-conventions-reference.md § Verification)." >&2 + exit 0 +} + +# Every directory named agent-memory or agent-memory-local that is not the root's +# own (the `project` and `local` scopes fork identically). The walk prunes the root's +# own two trees, worktrees (each is its own checkout with its own root tree), .git, +# the hosted review action's staging copy (`./.claude-pr`), and every directory the +# project's own ignore rules exclude — `git ls-files --others --ignored +# --exclude-standard --directory` names them (build output, tool caches, vendored +# trees), so the skip list is .gitignore's, read at run time, never a hand-kept list +# of stack names (the hand-kept list missed a second project's tool cache on its first +# real application — #58 item 1). No -mindepth: as first written, -mindepth 2 +# exempted depth-1 directories from the prune test, so a top-level node_modules was +# walked and its contents flagged (reproduced 2026-09-06). A stray tree under an +# untracked but unignored directory is still found — that is the fork case. +# +# Three rules make an ignore-driven list safe (#58 item 1): +# 1. Never prune a path that could BE or CONTAIN the tree this hook hunts for. A +# project that ignores the memory directory by an unanchored name (`agent-memory/` +# — the natural spelling for the `local` scope) or ignores `.claude/` wholesale +# would otherwise have its own ignore rules hide the fork. +# 2. `-path` takes a glob, and `*`/`?` in it cross `/`. An ignored directory named +# `*` (a legal Unix name) would splice in as `-path './*'`, prune the first +# top-level entry the walk reaches, and report a clean tree with no stderr — +# the silent miss this hook exists to stop. Metacharacters are escaped so an +# entry prunes itself and nothing else. +# 3. `--directory` collapses a directory whose whole subtree is ignored into its +# topmost ancestor, and in a repo with nothing committed yet that ancestor can +# be `packages/` — a directory no ignore rule names. `git check-ignore` keeps +# only the paths the rules actually exclude, so a collapsed ancestor is walked. +prunes=() +while IFS= read -r d; do + d="${d%/}" + [ -n "$d" ] || continue + case "${d##*/}" in .claude|agent-memory|agent-memory-local) continue ;; esac + prunes+=(-o -path "./$(printf '%s' "$d" | sed 's/[][*?\\]/\\&/g')") +done < <( + git ls-files --others --ignored --exclude-standard --directory 2>/dev/null \ + | sed -n 's:/$::p' | git check-ignore --stdin 2>/dev/null || true +) +# find's stderr is kept, not discarded: an unreadable directory makes the scan partial, +# and a partial scan that reports "clean" is the silent miss this hook exists to stop +# (#58 item 2). A redirection to an uncreatable path would abort the walk before find +# ran, leaving forks empty and the tree reported clean — so the path is tested first +# and the walk degrades to unmonitored with a warning (#58, 2026-09-07 comment). +scan_err="$(mktemp 2>/dev/null || printf '%s/.detect-forked-agent-memory.%s.err' "${TMPDIR:-/tmp}" "$$")" +if ! : 2>/dev/null >"$scan_err"; then + echo "detect-forked-agent-memory: WARNING — no scratch file for the scan's stderr ($scan_err); the walk runs unmonitored, so a partial scan cannot be reported." >&2 + scan_err=/dev/null +fi +forks=() +while IFS= read -r d; do forks+=("$d"); done < <( + find . \( -path './.claude/agent-memory' -o -path './.claude/agent-memory-local' -o -path './.claude/worktrees' -o -path './.claude-pr' -o -name .git \ + ${prunes[@]+"${prunes[@]}"} \) -prune -o -type d \( -name agent-memory -o -name agent-memory-local \) -print 2>"$scan_err" | sort +) +if [ -s "$scan_err" ]; then + echo "detect-forked-agent-memory: WARNING — the scan was partial; find could not read:" >&2 + head -n 5 "$scan_err" >&2 + echo " A stray tree under an unreadable directory is missed; fix the permissions and stop again." >&2 +fi +[ "$scan_err" = /dev/null ] || rm -f "$scan_err" + +[ "${#forks[@]}" -eq 0 ] && exit 0 + +if [ "$active" = "true" ]; then + echo "detect-forked-agent-memory: WARNING — forked reviewer memory still present after one fix attempt; letting the stop proceed:" >&2 + printf ' %s\n' "${forks[@]}" >&2 + exit 0 +fi + +cat >&2 </ and +$PROJECT_DIR/.claude/agent-memory-local// (pr-review.md § Reviewer +precedent memory). Before stopping: move each / directory's files +into the root tree, append their pointer lines to the root MEMORY.md for that +reviewer, delete the forked tree, and say so in the hand-off. + +If the path above belongs to tooling rather than to a dispatched agent — a harness +staging copy, a container's workspace, a vendored checkout — it is not a fork and +nothing should be moved or deleted. Add it to .gitignore, anchored, and this scan +prunes it from then on (the review action's \`.claude-pr/\` staging copy is the +exercised case and is pruned unconditionally). +MSG +exit 2 +``` + +- [ ] **Step 2: Payload checks against throwaway trees** + +Run: +```bash +r=$(mktemp -d); git -c init.defaultBranch=main init -q "$r"; printf '/pkg/*/build/\n' > "$r/.gitignore"; mkdir -p "$r/pkg/a/.claude/agent-memory/x" "$r/pkg/b/build/.claude/agent-memory/y" "$r/.claude-pr/.claude/agent-memory/z" +printf '{"stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$r" .claude/hooks/detect-forked-agent-memory.sh >/dev/null 2>/tmp/e; echo "exit=$?"; grep -c 'pkg/a' /tmp/e; grep -c 'build\|claude-pr' /tmp/e +rm -rf "$r/pkg/a"; printf '{"stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$r" .claude/hooks/detect-forked-agent-memory.sh >/dev/null 2>&1; echo "clean-exit=$?"; rm -rf "$r" +``` +Expected: `exit=2`, `1` (pkg/a reported), `0` (the ignored build tree and `.claude-pr` are pruned), then `clean-exit=0`. Also `bash -n` clean and `bash .claude/workflows/tests/hook-contract-fixture.sh` → ok. + +- [ ] **Step 3: § .gitignore anchoring names `/.claude-pr/`** + +Replace the bullet at line 271 with: +```markdown +- **Harness transients are ignored by anchored path** — the agent's scratch and memory-local trees (`/.claude/agent-memory-local/`, the session scratchpad if it is ever placed in-tree) and a hosted review action's staging copy of the branch's tooling (`/.claude-pr/` — it carries a copy of the committed memory tree, which the Stop-tier fork detector prunes unconditionally and the ignore-driven prune covers once the entry exists; #58, 2026-09-07 comment), never by a bare name that would also hide a real directory. +``` + +- [ ] **Step 4: Run the runner; commit** + +Run: `bash .claude/workflows/tests/run-verification-block.sh | tail -3` → both sentinels. + +```bash +git add .claude/hooks/detect-forked-agent-memory.sh .claude/rules/cbk-conventions-reference.md +git commit -m "fix(hooks): fork detector reads the project's ignore rules, keeps find's stderr, prunes the review action's staging copy, and names a tooling path as not-a-fork (#58 items 1, 2; the 2026-09-07 comment) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 7: `agent-cost.py` — cache reads priced per tier; the fixture's missing rows (H5; D36) + +**Files:** +- Modify: `.claude/workflows/agent-cost.py` — docstring line 14, `PRICE` block (line 26–31), the cost line (91) +- Modify: `.claude/workflows/tests/agent-cost-fixture.sh` + +- [ ] **Step 1: Per-tier cache-read multipliers** + +After the `PRICE = { … }` block add: +```python +CACHE_READ = {'fable': 0.025} # cache hits and refreshes on the top tier are 0.025x base input +CACHE_READ_DEFAULT = 0.10 # every other tier: the standard 0.1x +# platform.claude.com/docs/en/build-with-claude/prompt-caching § Pricing, read 2026-09-07 (#58 item 6). +``` +Replace line 91: +```python + cost += (t['inp'] * pi + t['cw'] * pi * 1.25 + t['cr'] * pi * 0.10 + t['out'] * po) / 1e6 +``` +with: +```python + cost += (t['inp'] * pi + t['cw'] * pi * 1.25 + t['cr'] * pi * CACHE_READ.get(k, CACHE_READ_DEFAULT) + t['out'] * po) / 1e6 +``` +In the docstring replace `and cache reads at 0.1x input.` with `and cache reads at 0.1x input except on the top tier, where cache hits and refreshes are 0.025x (CACHE_READ).` + +- [ ] **Step 2: Fixture rows — a top-tier transcript, a zero-parseable-lines transcript, `minutes` null, the `--json` missing argument** + +After the `agent-ddd.jsonl` block add: +```bash +printf '%s\n' \ + '{"type":"user","timestamp":"2026-09-06T00:00:00Z","message":{"content":"arm five prompt"}}' \ + '{"type":"assistant","timestamp":"2026-09-06T00:03:00Z","message":{"model":"claude-fable-5-1","usage":{"input_tokens":1000000,"output_tokens":0,"cache_read_input_tokens":1000000}}}' \ + > "$d/agent-eee.jsonl" +printf '%s\n' '{"type":"assistant","timestamp":"2026-09-06T00:0' > "$d/agent-fff.jsonl" +printf '%s\n' \ + '{"type":"assistant","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":1000,"output_tokens":0}}}' \ + > "$d/agent-ggg.jsonl" +``` +Replace the three assertions on lines 30–33 with: +```bash +grep -qE '^aaa\b.*\b14\.25\b' <<<"$out" || { echo "expected aaa at \$14.25 (1M in @ \$5 + 1M cache write @ 1.25x + 1M cache read @ 0.1x + 100k out @ \$25); got:"; echo "$out"; exit 1; } +grep -qE '^ddd\b.*claude-opus-5,claude-sonnet-5.*\b7\.00\b' <<<"$out" || { echo "expected the mixed transcript priced per model at \$7.00 with both models named; got:"; echo "$out"; exit 1; } +grep -qE '^eee\b.*\b10\.25\b' <<<"$out" || { echo "expected eee at \$10.25 (1M in @ \$10 + 1M cache read @ 0.025x on the top tier — not \$11.00 at the 0.1x default); got:"; echo "$out"; exit 1; } +grep -q 'total list-price cost: \$31.50' <<<"$out" || { echo "expected a \$31.50 total (14.25 + 7.00 + 10.25 + 0.00 haiku); got:"; echo "$out"; exit 1; } +grep -q '4 of 7 agents priced' <<<"$out" || { echo "the priced/unpriced split is not printed (aaa, ddd, eee, ggg priced; bbb, ccc, fff not)"; echo "$out"; exit 1; } +``` +Amend the two `unpriced`/`unparsable` assertions to `bbb, ccc, fff` and `ccc (1), fff (1)`. Then add, before `assert_exit`: +```bash +grep -q '"agent": "ggg"' "$d/out.json" && grep -q '"minutes": null' "$d/out.json" || { echo "a transcript with fewer than two timestamps must report minutes as null, not crash or zero"; cat "$d/out.json"; exit 1; } +``` +and after the existing `assert_exit` lines: +```bash +assert_exit 2 "--json without a path should exit 2" "$d" --json +``` + +- [ ] **Step 3: Run; commit** + +Run: `bash .claude/workflows/tests/agent-cost-fixture.sh` → `agent-cost-fixture: ok`. Then the runner (its price diff reads `'fable': (10.0, 50.0)`-shaped rows only; `CACHE_READ` is not matched by `'[a-z]+': \([0-9.]+, [0-9.]+\)`? — it IS a float in braces but the regex needs two comma-separated numbers in parentheses; `{'fable': 0.025}` does not match. Confirm the diff line stays green). + +```bash +git add .claude/workflows/agent-cost.py .claude/workflows/tests/agent-cost-fixture.sh +git commit -m "fix(workflows): agent-cost prices cache reads per tier (0.025x on the top tier); fixture rows for the top tier, a zero-line transcript, null minutes and --json without a path (#58 item 6, smaller item 5) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 8: `review-sweep.js` — boundary-safe hint match, the roster read degrades on a throw, the retry reindex, the harness stops swallowing exceptions (H5; D-P-4) + +**Files:** +- Modify: `.claude/workflows/review-sweep.js` lines 109, 139–141, 181–184 +- Modify: `.claude/workflows/tests/review-sweep-accounting.mjs` line 38 (the stub), new scenarios after scenario 13 + +- [ ] **Step 1: The roster read degrades on a throw** + +Change the closing of the `const roster = await agent(` call (line 109–115): after the closing `)` of `agent(...)` and before the `;`, append `.catch(() => null)` with the comment: +```js + ).catch(() => null); // a throw (a budget ceiling — orchestration.md § Fan-out discipline) degrades exactly like a null read below; every other agent() here sits inside a parallel() thunk, which the runtime catches (#58 item 8) +``` + +- [ ] **Step 2: The boundary-safe match** + +Replace line 141: +```js + const matched = usable.filter((d) => files.some((f) => d.pathHints.some((h) => f.startsWith(h)))).map((d) => d.name); +``` +with: +```js + // Directory-boundary safe: `src/schema` must not claim `src/schema-extra/x` (#58 item 7). A hint + // is a directory prefix, never a segment anchor — enumerate directories rather than reaching for + // a regex the roster line cannot carry. + const matched = usable.filter((d) => files.some((f) => d.pathHints.some((h) => f === h || f.startsWith(h + "/")))).map((d) => d.name); +``` + +- [ ] **Step 3: The retry reindex, finish-ab's idiom** + +Replace lines 181–184: +```js +const failedFirst = dimensions.filter((_d, i) => firstPass[i] === null); +if (failedFirst.length) log(`review-sweep: ${failedFirst.length} find agent(s) returned nothing — retrying once at effort ${RETRY_EFFORT}: ${failedFirst.map((d) => d.key).join(", ")}`); +const retried = await parallel(failedFirst.map((dim) => () => findOnce(dim, RETRY_EFFORT, true))); +const results = dimensions.map((dim, i) => firstPass[i] ?? retried[failedFirst.indexOf(dim)] ?? null); +``` +with: +```js +const failedIdx = dimensions.map((_d, i) => i).filter((i) => firstPass[i] === null); +if (failedIdx.length) log(`review-sweep: ${failedIdx.length} find agent(s) returned nothing — retrying once at effort ${RETRY_EFFORT}: ${failedIdx.map((i) => dimensions[i].key).join(", ")}`); +const retried = await parallel(failedIdx.map((i) => () => findOnce(dimensions[i], RETRY_EFFORT, true))); +const results = firstPass.slice(); +failedIdx.forEach((i, k) => { results[i] = retried[k] ?? null; }); // the index-list reindex, shared with finish-ab.js (#58 smaller item 6) +``` + +- [ ] **Step 4: The harness stub propagates its own exceptions** + +Replace line 38 of `review-sweep-accounting.mjs`: +```js + const parallel = async (thunks) => Promise.all(thunks.map(async (t) => { try { return await t(); } catch { return null; } })); +``` +with: +```js + // A thunk that THROWS is a bug in this harness's own mock and must fail the test; the runtime + // resolves a failed agent to null without throwing, so null is modelled by returning null (#58 item 10). + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); +``` + +- [ ] **Step 5: Three new scenarios** + +Append after scenario 13 (before the final summary line): +```js +// 14 — a hint matches on a directory boundary, never on a common prefix. +{ + const { out } = await scenario("boundary-safe hint", { + args: { files: ["src/schema-extra/a.ts"] }, + roster: rosterOK, findings: {}, verdict: () => null, + }); + assert.ok(!out.reviewers.includes("schema-reviewer"), "src/schema must not claim src/schema-extra/"); + const { out: exact } = await scenario("boundary-safe hint (exact)", { args: { files: ["src/schema"] }, roster: rosterOK, findings: {}, verdict: () => null }); + assert.ok(exact.reviewers.includes("schema-reviewer"), "a changed path equal to the hint matches"); + n += 2; +} + +// 15 — a roster read that THROWS degrades like a null read: gate line, dropped coverage, no crash. +{ + const logs = []; + const agent = async (_prompt, opts = {}) => { if ((opts.label ?? "").startsWith("roster:")) throw new Error("budget ceiling"); return opts.label?.startsWith("verify:") ? null : { findings: [] }; }; + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); + const out = await run({ files: ["a"] }, agent, parallel, (m) => logs.push(m), () => {}); + assert.deepEqual(out.reviewers, []); + assert.ok(out.droppedCoverage.some((d) => d.includes("roster read failed")), "a throwing roster read is dropped coverage, not an aborted run"); + assert.ok(typeof out.gateLine === "string" && out.gateLine.length > 0, "the run still returns its gate line"); + n++; +} + +// 16 — the least-loaded-owner bound with THREE converging reporters charges the idle one. +{ + const three = { crossCutting: ["adr-conformance-reviewer", "cascade-rule-reviewer"], domain: [], note: "" }; + const { out } = await scenario("three converging reporters", { + args: { files: ["a"], maxPerDimension: 1, maxVerify: 8 }, roster: three, + findings: { + "code-review": { findings: [F("a", 1, "cr only", "high"), F("a", 5, "shared", "low")] }, + "adr-conformance-reviewer": { findings: [F("a", 2, "adr only", "high"), F("a", 5, "shared", "low")] }, + "cascade-rule-reviewer": { findings: [F("a", 5, "shared", "low")] }, + }, + verdict: real, + }); + assert.ok(out.confirmed.some((f) => f.line === 5), "the three-way shared finding is charged to the reporter with no other finding and verified"); + assert.ok(out.confirmed.some((f) => f.title === "cr only") && out.confirmed.some((f) => f.title === "adr only"), "neither loaded reporter loses its own unique finding to the shared one"); + n++; +} +``` +(`run` is the loader's function already in scope at the top of the file.) + +- [ ] **Step 6: Run; commit** + +Run: `node .claude/workflows/tests/review-sweep-accounting.mjs && bash .claude/workflows/tests/run-verification-block.sh | tail -3` +Expected: the harness prints its scenario count with no assertion error; both sentinels. + +```bash +git add .claude/workflows/review-sweep.js .claude/workflows/tests/review-sweep-accounting.mjs +git commit -m "fix(workflows): review-sweep matches hints on a directory boundary, degrades on a throwing roster read, reindexes retries by index; the harness stops swallowing its own exceptions (#58 items 7, 8, 10) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: `finish-ab.js` — distinct arm labels, guarded `hallucinations`, effort read once; harness scenarios (H5) + +**Files:** +- Modify: `.claude/workflows/finish-ab/finish-ab.js` lines 36–38, 132, 158–161 +- Modify: `.claude/workflows/tests/finish-ab-shape.mjs` line 19 and new scenarios + +- [ ] **Step 1: Arm labels distinct, beside `anon`** + +After line 38 (`if (new Set(ids).size !== ids.length) throw …`) add: +```js +const armLabels = args.arms.map((c) => c.arm) +if (new Set(armLabels).size !== armLabels.length) throw new Error(`finish-ab: arm labels must be distinct — otherFile() picks the OTHER arm by label (got ${armLabels.join(", ")})`) +``` + +- [ ] **Step 2: `hallucinations ?? []` at both sites; effort once** + +Replace lines 159 and 161: +```js + flags[id] = jok.reduce((n, j) => n + j.hallucinations.filter((h) => h.arm === id).length, 0) +… +const unattributed = jok.reduce((n, j) => n + j.hallucinations.filter((h) => !ids.includes(h.arm)).length, 0) +``` +with: +```js + flags[id] = jok.reduce((n, j) => n + (j.hallucinations ?? []).filter((h) => h.arm === id).length, 0) // a well-formed ranking with no hallucinations field counts zero, never crashes (#58 item 9) +… +const unattributed = jok.reduce((n, j) => n + (j.hallucinations ?? []).filter((h) => !ids.includes(h.arm)).length, 0) +``` +Replace line 132's `judgeOpts`: +```js +const judgeOpts = (j, i, retry) => { const effort = j.effort ?? "high"; return { label: `judge:${i + 1}@${effort}${retry ? ":retry" : ""}`, phase: "Judge", model: MODEL, effort, agentType: "general-purpose", schema: JUDGE_SCHEMA } } +``` + +- [ ] **Step 3: The harness stub propagates; three scenarios** + +Replace line 19 of `finish-ab-shape.mjs`: +```js + const parallel = async (thunks) => Promise.all(thunks.map((t) => t().catch(() => null))); +``` +with: +```js + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); // a throwing mock is a harness bug, not a "null agent" (#58 item 10) +``` +Append before the harness's final failure summary: +```js +// N — duplicate arm labels are refused before any dispatch. +{ + let err = null; let dispatched = 0; + const count = (opts) => { dispatched += 1; return armOk(opts); }; + try { await run({ ...base, arms: [arms[0], { ...arms[1], arm: "A" }] }, { armResult: count, judgeResult: judgeOk }); } catch (e) { err = e; } + check(err && /arm labels must be distinct/.test(err.message), "duplicate arm labels throw"); + check(dispatched === 0, "and nothing was dispatched first"); +} + +// N+1 — a well-formed judge with no `hallucinations` field counts zero flags instead of crashing. +{ + const noHall = (opts) => { const j = judgeOk(opts); delete j.hallucinations; return j; }; + const { result } = await run(base, { armResult: armOk, judgeResult: noHall }); + check(result.flags.P === 0 && result.flags.Q === 0, `missing hallucinations counts zero (got ${JSON.stringify(result.flags)})`); +} + +// N+2 — a contradicted claim naming no arm id is counted as unattributed, for neither arm. +{ + const stray = (opts) => { const j = judgeOk(opts); j.hallucinations = [{ arm: "Z", claim_verbatim: "x", contradicting_source: "y" }]; return j; }; + const { result, logs } = await run(base, { armResult: armOk, judgeResult: stray }); + check(result.unattributedFlags === 4, `four judges × one stray entry = 4 unattributed (got ${result.unattributedFlags})`); + check(result.flags.P === 0 && result.flags.Q === 0, "stray entries count for neither arm"); + check(logs.some((l) => /name no arm id/.test(l)), "the stray entries are logged"); +} +``` +(Number the comments to follow the file's last scenario.) + +- [ ] **Step 4: Run; commit** + +Run: `node .claude/workflows/tests/finish-ab-shape.mjs; echo "exit=$?"` → `exit=0`; then the runner → both sentinels. + +```bash +git add .claude/workflows/finish-ab/finish-ab.js .claude/workflows/tests/finish-ab-shape.mjs +git commit -m "fix(workflows): finish-ab refuses duplicate arm labels before dispatch, guards hallucinations at both sites, reads effort once; harness scenarios for unattributed flags (#58 item 9, smaller items 5–6) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 10: `/finish` Step 1 admits the meta-issue title (H6; D35) + +**Files:** +- Modify: `.claude/commands/finish.md` line 14 and line 21 +- Modify: `.claude/skills/rough-in/references/finish-command.md` — the same two lines below the marker (line 83) + +- [ ] **Step 1: The two sentences, in both files** + +Line 14: replace `An intake-lane issue (\`[:bug]\`, \`[:enh]\`) has no frame; its provenance comment stands in.` with `An intake-lane issue (\`[:bug]\`, \`[:enh]\`) or a directly roughed-in meta (\`[:meta]\`) has no frame; its provenance comment stands in.` + +Line 21: replace `The title is \`[:F<#>:R<#>] …\`, \`[:bug] …\` or \`[:enh] …\` with \`\` a workstream locked in \`docs/cbk/blueprint.md\` § Workstreams;` with `The title is \`[:F<#>:R<#>] …\`, \`[:bug] …\`, \`[:enh] …\` or \`[:meta] …\` with \`\` a workstream locked in \`docs/cbk/blueprint.md\` § Workstreams or the cascade/tooling lane's tag (\`cbk-conventions.md\` § Contribution intake — a meta reaches this executor roughed-in directly or through its \`[::R<#>]\` children; the \`cascade-depth:framed\` label the meta template applies at framing is the pre-rough-in state, not this check's — #58, smaller item 1);` + +Apply with one `sed -i` expression on both files, then verify: +`diff <(awk '/^--- BEGIN TEMPLATE ---/{flag=1; next} flag' .claude/skills/rough-in/references/finish-command.md) .claude/commands/finish.md && echo byte-parallel` + +- [ ] **Step 2: Run the runner (it diffs the pair); commit** + +```bash +git add .claude/commands/finish.md .claude/skills/rough-in/references/finish-command.md +git commit -m "fix(finish): Step 1 admits the [:meta] title the kit's own templates emit (#58 smaller item 1) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 11: ADR records — `adr-new` reads the index's form; the count rot; `Promotes:` defined; the reviewer cites by the register's own id form (H6; D39) + +**Files:** +- Modify: `.claude/skills/adr-new/SKILL.md` — step 3 first bullet (line 51), the § Refines vs Supersedes vs Extends opening sentence (line 73), a `Promotes:` bullet after the `Refines:` bullet +- Modify: `.claude/skills/scaffold/references/adr-starters/README.md` line 27 **and** `docs/adr/README.md` line 27 (identical) +- Modify: `.claude/agents/adr-conformance-reviewer.md` line 55 + +- [ ] **Step 1: `adr-new` step 3** + +Replace the bullet `- Add the row to the index table in number order; the status cell carries the grain and the parent inline (\`Accepted · Refines ADR-0007 (D2)\`, \`Accepted · Extends ADR-0003 (D1)\`).` with: +```markdown + - Add the row to the index table in number order — **in the form the existing rows already use.** Read them first: which cell carries the relation grain and the parent (the kit's starter index puts it in the Status cell — `Accepted · Refines ADR-0007 (D2)` — while an index that predates the starter may carry it as a Title-cell parenthetical, with another separator, or in prose), and write the new row exactly that way. The separator is the index's convention, not this skill's; two real indexes already contradicted the pinned form two different ways (#58, 2026-09-07 comment). +``` + +- [ ] **Step 2: The count and `Promotes:`** + +Replace `A new ADR connects to an existing one through one of two relationships. Both are recorded as header fields and both preserve the parent's immutability — neither ever edits the parent file.` with `A new ADR connects to an existing one through one of the relationships below (\`Supersedes:\`, \`Refines:\`, \`Extends:\`, and the clause-scoped form of the first); a \`Promotes:\` slot connects to a frozen corpus, not to an ADR. All are header fields and all preserve the parent's immutability — none ever edits the parent file.` + +After the `- **\`Refines: ADR-NNNN (Dn, …)\`**` bullet add: +```markdown +- **`Promotes: `** — the decision is lifted from a frozen pre-cascade corpus (consultation's frozen-corpus ingestion; `cbk-conventions.md` § Multi-surface facts names the corpus + errata pair). The corpus entry stays as written and the ADR becomes the decision's record home; the slot is the back-pointer. Not a relation to another ADR, so it carries no grain. +``` + +- [ ] **Step 3: The starter index and the kit's own index, identically** + +Append to line 27 of both files: ` A project whose index predates this starter keeps its own form — the cell and separator its rows already use — and \`adr-new\` reads the rows before writing one.` + +Check: `diff docs/adr/README.md .claude/skills/scaffold/references/adr-starters/README.md && echo identical`. + +- [ ] **Step 4: The reviewer's id-form assumption** + +Line 55 of `adr-conformance-reviewer.md`: replace `is cited by its \`C-\` number, never restated as a finding` with `is cited by the register's own id form (the starter numbers entries \`C-NNN\`; a register that predates it may number them in prose under per-ADR sections), never restated as a finding`. + +- [ ] **Step 5: Run the runner; commit** + +The block greps `Extends:` in these files and diffs the starters; `wc -l .claude/skills/adr-new/SKILL.md` stays under 500. + +```bash +git add .claude/skills/adr-new/SKILL.md .claude/skills/scaffold/references/adr-starters/README.md docs/adr/README.md .claude/agents/adr-conformance-reviewer.md +git commit -m "fix(adr): adr-new writes the index row in the index's own form; the relation list states no count and defines Promotes:; the reviewer cites corrections by the register's id form (#58, 2026-09-07 comment; second application items 1, 3) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 12: The small rot — the duplicated citation, the marker spelling, the pre-flight row, the stamped-state callouts, the `cwd` disagreement, the lock-file arms (H6) + +**Files:** +- Modify: `.claude/skills/framing/references/procedure.md:107` +- Modify: `.claude/skills/rough-in/references/templates/rough-in-spec-template.md:256` +- Modify: `.claude/rules/cbk-conventions.md:234` (delete the row) +- Modify: `.claude/rules/logging.md:8`, `.claude/rules/testing.md:11`, `.claude/skills/scaffold/references/bootstrap_checklist_template.md:90-91` +- Modify: `.claude/hooks/require-repo-root-for-agents.sh` Timing paragraph (lines 25–33) +- Modify: `.claude/hooks/protect-lock-files.sh` lines 57–76 + +- [ ] **Step 1: S2 and S4** + +`sed -i "s/See rough-in's the rough-in skill's/See the rough-in skill's/" .claude/skills/framing/references/procedure.md` +`sed -i 's|^MEASUREMENT / SPIKE VARIANT|MEASUREMENT/SPIKE VARIANT|' .claude/skills/rough-in/references/templates/rough-in-spec-template.md` +Check: `grep -c "rough-in's the rough-in" .claude/skills/framing/references/procedure.md` → 0; `grep -c 'MEASUREMENT / SPIKE' .claude/skills/rough-in/references/templates/rough-in-spec-template.md` → 0. + +- [ ] **Step 2: The pre-flight Quick-reference row** + +Delete line 234 of `cbk-conventions.md` (`| Adding a \`## Pre-flight checks\` row to a frame | Append-only edit to the frame's \`## Pre-flight checks\` table |`). Framing writes the table at frame creation and rough-in reads it; no skill appends to it, and the Mutation-discipline row names `## Rough-in events` as the only append-only table (#58, second application, item 4). + +- [ ] **Step 3: Callouts true before and after stamping** + +`logging.md` line 8 → +```markdown +> **Path-scoped.** The `paths:` block above makes this rule load only when a matching file is read, not every session (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — one entry per source extension (e.g. `"**/*.py"`, `"**/*.ts"`); until stamped they are bracketed placeholders that match nothing, which the conventions' verification block flags. This callout describes the mechanism and stays as it reads after stamping. +``` +`testing.md` line 11 → +```markdown +> **Path-scoped.** This rule loads only when a test file or a test directory is read (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — the project's test-file extension(s), and only the directory patterns it uses; until stamped they are placeholders the conventions' verification block flags. A stack whose unit tests live inline in source files (a `#[cfg(test)]` module) has no test-file extension: stamp the test directories alone and accept that inline modules reach this rule only through their directory. Anything a test-writing session needs *before* it opens a test file — the project's test-side trace-tag form, for one — is restated here rather than left in a rule this session never loads. This callout stays as it reads after stamping. +``` +`bootstrap_checklist_template.md` lines 90–91: replace `\`paths:\` set to \`**/*.\`` with `\`paths:\` set to the project's source globs (the callout stays; it describes the mechanism)` and `\`paths:\` set to \`**/*_test.\`, \`**/test/**\`` with `\`paths:\` set to the project's test globs and directories (inline-test stacks: directories alone)`. + +- [ ] **Step 4: The `cwd` disagreement in the launch-root guard's header** + +Replace the Timing paragraph's last two sentences (`The confirmed deny is a session launched from a subdirectory … relaunch the session from the root.`) with: +``` +# The confirmed deny is a session launched from a subdirectory (the +# verification block's payload dry-run); the remedy there is to relaunch +# the session from the root. The hooks reference states the OPPOSITE of +# the observation above — "cwd follows Claude: … the new directory after +# Claude runs cd" (https://code.claude.com/docs/en/hooks § Reference +# scripts by path, read 2026-09-07). The two disagree; this guard judges +# whichever cwd the payload carries and is correct under either reading. +# RE-VERIFY TRIGGER: a dispatch made after `cd ` that is denied +# means the page's reading now holds — update this paragraph (#58, S3). +``` + +- [ ] **Step 5: Lock-file arms** + +In `protect-lock-files.sh` change the case line to `uv.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|Cargo.lock|Gemfile.lock|poetry.lock|composer.lock|mix.lock|pubspec.lock)` and add ` - pubspec.lock → dart pub get / flutter pub get (pub upgrade to move a resolution)` after the `Cargo.lock` remediation line. After that arm's `;;` add a second arm: +```bash + *.lock) + cat >&2 <" +``` + +--- + +### Task 13: Record the install sha; sync by merge (H6; D40) + +**Files:** +- Modify: `.claude/skills/scaffold/references/scaffold_output_template.md` — both Cascade metadata tables (lines 19–23 and 124–128) +- Modify: `.claude/rules/cbk-conventions-reference.md` — new `## Syncing the kit` section before `## Verification` +- Modify: `.claude/rules/cbk-conventions.md` — a one-line pointer heading `## Syncing the kit` before `## Verification` (the contract/reference shape: every reference section has a pointer in the contract) + +- [ ] **Step 1: The Kit commit row** + +After `| **Repo** | |` add `| **Kit commit** | |`; after the example's `| **Repo** | https://github.com/jforth/notes-cli |` add `| **Kit commit** | e92e9c4 |`. + +- [ ] **Step 2: § Syncing the kit (reference half)** + +Insert before `## Verification` in the reference: +```markdown +## Syncing the kit + +A target that recorded its **Kit commit** (scaffold's Cascade metadata table) syncs to a newer kit as a three-way merge, not a hand-reconciliation: for every file, `git merge-file ` — base is the kit at the install sha, ours the project's copy, theirs the new kit. On a real application 47 of 48 files the project had customized auto-resolved (pure kit drift, derivable from the base); the conflict hunks were the two big rule files, where the kit's generalized text and the project's filled text both had to survive (#58, second application, item 10). The file-by-file table — classify each file as copy / add / merge / keep, then note what each merge must preserve — is the reusable artifact; write it before the branch. Two traps: a project sub-block re-homed into this file from a pre-split contract must split its own retired-vocabulary literals (or the block's `absent` check matches it), and byte-identity for `copy` rows is asserted against the target sha, so a fix a project needs ahead of the kit is filed upstream and carried as a named exception, never silently patched into a copy. Record the new sha in the table when the sync merges. +``` +And in the contract (`cbk-conventions.md`), before `## Verification`: +```markdown +## Syncing the kit + +Three-way `git merge-file` against the recorded **Kit commit**; the file-by-file table first. → `cbk-conventions-reference.md` § Syncing the kit. +``` + +- [ ] **Step 3: Run the runner; commit** + +```bash +git add .claude/skills/scaffold/references/scaffold_output_template.md .claude/rules/cbk-conventions-reference.md .claude/rules/cbk-conventions.md +git commit -m "docs(conventions): record the install sha in the Cascade metadata table; § Syncing the kit — three-way merge-file, the file-by-file table first (#58, second application, item 10) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 14: The review-workflow templates and the knowledge-backend matcher (H7; D37, D38) + +**Files:** +- Modify: `.claude/skills/blueprint/references/templates/claude-review.yml` — lines 154–155 (`additional_permissions`), after the checkout step (line 109–112), the prompt's every-file paragraph (line 163) +- Modify: `.claude/skills/blueprint/references/templates/claude.yml` — lines 38–43 and 80–81 +- Modify: `.claude/settings.json` — the knowledge-backend matcher (line 33 at `e92e9c4`; find it by `grep -n 'notion__notion-' .claude/settings.json`) +- Modify: `.claude/rules/knowledge-backend.md` line 230 + +- [ ] **Step 1: `checks: read` in `additional_permissions` (claude-review.yml) and in both places (claude.yml)** + +claude-review.yml: replace +```yaml + additional_permissions: | + actions: read +``` +with +```yaml + # Named twice on purpose — here and in the job's `permissions:` block. The job block scopes + # the workflow's own token; this input is what the action requests when it exchanges its OIDC + # token for an app token (`parseAdditionalPermissions` in anthropics/claude-code-action + # `src/github/token.ts`, read 2026-09-21). Without `checks: read` here, `gh pr checks` fails + # `Resource not accessible by integration` and the review takes the PR body's claim of a green + # gate at face value (observed 2026-09-18). `statuses: read` only when the rollup carries commit + # statuses (a non-Actions CI, a deploy bot) — measure with `gh api repos/{owner}/{repo}/commits//status`. + additional_permissions: | + actions: read + checks: read +``` +claude.yml: add ` checks: read # `gh pr checks` is allowlisted below; the scope must match` after ` actions: read` in the job `permissions:` block, and the same `checks: read` line under `additional_permissions:` with a one-line `# named twice — see claude-review.yml` comment. + +- [ ] **Step 2: The base ref exists in the checkout** + +After the `Checkout PR HEAD` step in claude-review.yml add: +```yaml + - name: Fetch the base ref (so `git diff origin/...HEAD` resolves) + # `ref: head.sha` above checks out only the PR head; without this the reviewer's first diff + # against the base fails ("no merge base") and it spends turns retrying fetches its allowlist + # refuses (#58 addendum, item 12). The ref name is read from an env var, never interpolated. + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" +``` + +- [ ] **Step 3: The prompt degrades explicitly on an oversized diff** + +After the prompt line `and walk each one; read the surrounding file, not only the hunk, when context matters.` add: +``` + If the diff exceeds [N] changed files (a sync of vendored or kit-copied trees, a generated + bulk), you cannot walk every file inside the turn cap: review the authored set the PR body + names and every file that is not a byte-identical copy of its source, post the summary FIRST + with the list of files you did not read, and say so in the verdict line. A capped run that + posted nothing is the failure this clause prevents (#58 addendum, item 11). +``` +(`[N]` is a project fill — the bracketed form the template already uses elsewhere; suggest 60, the turn cap.) + +- [ ] **Step 4: The matcher, widened and dated** + +In `.claude/settings.json` replace `"matcher": "mcp__(plugin_Notion_)?notion__notion-(create|update|move|duplicate|convert|delete).*"` with `"matcher": "mcp__(plugin_Notion_)?notion__notion-(create|update|move|duplicate|convert|delete|upload|spawn|send|stop).*"`, and in `_comment_hooks`'s ASK-GATE sentence extend `(Notion matcher shipped as the v1 reference — adjust the regex to your MCP's tool names;` to `(Notion matcher shipped as the v1 reference, dated 2026-09-21 — the live tool set added upload-skill (replaces a page's body), spawn-session / send-message-to-session (drive an agent that writes) and stop-session; a matcher is a dated observation of the vendor's tool names, so re-verify it when the MCP's tool list changes; adjust the regex to your MCP's tool names;`. + +In `knowledge-backend.md` line 230 replace `(registered in \`settings.json\` against the knowledge-backend MCP's write-verb tool names — Notion's as the v1 reference)` with `(registered in \`settings.json\` against the knowledge-backend MCP's mutating tool names — Notion's \`create|update|move|duplicate|convert|delete|upload|spawn|send|stop\` as the v1 reference, dated 2026-09-21; the matcher is a dated observation, re-verified when the MCP's tool list changes)`. + +- [ ] **Step 5: Run; commit** + +Run: `python3 -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .claude/skills/blueprint/references/templates/claude-review.yml .claude/skills/blueprint/references/templates/claude.yml 2>/dev/null || echo "(PyYAML absent — the templates carry bracketed placeholders, which is expected to fail strict parsing; check indentation by eye)"; jq . .claude/settings.json >/dev/null && bash .claude/workflows/tests/run-verification-block.sh | tail -3` + +```bash +git add .claude/skills/blueprint/references/templates/claude-review.yml .claude/skills/blueprint/references/templates/claude.yml .claude/settings.json .claude/rules/knowledge-backend.md +git commit -m "fix(templates): checks: read named twice in both review workflows, the base ref fetched, the prompt degrades explicitly on an oversized diff; the knowledge-backend matcher widened to the live tool set (#58 addendum items 11–12) + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 15: The always-loaded figure, the final run, the PR + +**Files:** +- Modify: `README.md` § Customization item 4 — one sentence + +- [ ] **Step 1: The figure** + +Append to item 4: ` The always-loaded rule set at the kit's current sha totals the figure the verification block prints (\`always-loaded total: … bytes\`; 130,405 at harvest 4) — the number a fresh target starts from before path-scoping its own rules.` + +- [ ] **Step 2: Everything green** + +Run: `bash .claude/workflows/tests/run-verification-block.sh | tail -6 && for h in .claude/hooks/*.sh; do bash -n "$h"; done && node .claude/workflows/tests/load-workflow-shape.mjs && node .claude/workflows/tests/review-sweep-accounting.mjs && node .claude/workflows/tests/finish-ab-shape.mjs && bash .claude/workflows/tests/agent-cost-fixture.sh && bash .claude/workflows/tests/hook-contract-fixture.sh && echo ALL-GREEN` +Expected: `ALL-GREEN`, `always-loaded total:` printed (record it), no `_example_` key in settings, the executor pair byte-parallel. + +Sanitization: `grep -rn -i 'crease\|CRE-[0-9]\|echosphere\|you-are-hear\|yah_\|ECH-[0-9]' .claude/ docs/adr/ README.md` → only the `#58`-adjacent mentions in the spec/plan (under `docs/superpowers/`) and the two public run names inside `.claude/` where a sentence already cites #58 with them (none expected; if any, rewrite to "a real application"). + +- [ ] **Step 3: Commit, push, one review sweep, the PR** + +```bash +git add README.md +git commit -m "docs(readme): the always-loaded figure a fresh target starts from + +Co-Authored-By: Claude Fable 5.1 " +git push -u origin feat/harvest-4-applied-defects +``` +Run one dogfooded review sweep over `git diff main...HEAD` (the kit's `review-sweep.js` with `{base: "main", files: [...]}`), triage per `pr-review.md`, apply the Apply-class findings as their own commits, then open the draft PR with `Closes #58` and `Closes #33` in the body, a `## Review gate` block (the three lines), a `## Triage` block, and a `## Verification` list carrying the before/after always-loaded totals and the mutation results from Tasks 1, 2 and 5. + +--- + +## Self-review (run before handing off) + +1. **Spec coverage.** H1 → Task 2. H2 → Tasks 3, 4, 5. H3 → Task 6. H4 → Task 1. H5 → Tasks 7, 8, 9. H6 → Tasks 10, 11, 12, 13. H7 → Task 14. § Sequencing → the task order. § Verification 1–7 → Task 15 Step 2 plus the per-task runner steps. Non-goals untouched. +2. **Placeholder scan.** The only bracketed token intentionally left is `[N]` inside a blueprint template that already uses that form for project fills. +3. **Name consistency.** `run-verification-block.sh` (Tasks 1, every test step, CI), `hook-contract-fixture.sh` (Tasks 3, 5, 12, 15), `ADVISORY_WIRED` (Task 1 only), `CACHE_READ` / `CACHE_READ_DEFAULT` (Task 7), `failedIdx` (Task 8), `armLabels` (Task 9). From 9a50c8b80ffa1de5a69075af6fa2b01c594f88ff Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:00:25 -0600 Subject: [PATCH 03/23] fix(verification): runner with two rails, target-safe advisory arm, guarded .mcp.json.example, diagnostics kept, kit CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block gains a runner (.claude/workflows/tests/run-verification-block.sh) carrying the two rails it cannot carry itself — an empty extraction is red, and an exit 0 without the done sentinel is red — both proven by mutation. The advisory-exemplar arm asserts "unregistered" on the kit tree only; a target declares ADVISORY_WIRED at the top of its project sub-block and that arm asserts each named hook registered exactly once and each unnamed one zero times (proven undeclared / declared / double-registered on a synthetic target). The .mcp.json.example operand is included only where the file exists; the launch-root dry-runs keep the hook's stderr and print it on failure. The kit's own CI runs the block through the runner on every PR (.github/workflows/verify.yml, no path filter). Closes #58 items 5 and 11 and the second-application items 1, 6 and 7. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/cbk-conventions-reference.md | 33 ++++++++++++++----- .../workflows/tests/run-verification-block.sh | 20 +++++++++++ .github/workflows/verify.yml | 25 ++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100755 .claude/workflows/tests/run-verification-block.sh create mode 100644 .github/workflows/verify.yml diff --git a/.claude/rules/cbk-conventions-reference.md b/.claude/rules/cbk-conventions-reference.md index 2079e4e..0e89d93 100644 --- a/.claude/rules/cbk-conventions-reference.md +++ b/.claude/rules/cbk-conventions-reference.md @@ -469,6 +469,8 @@ The kit's brownfield detection (run by scaffold's Stage 2 when knowledge backend Two audiences share one block. **Kit-repo checks** hold on the kit's own tree and on any target project's copy of `.claude/`; **project checks** hold only in a filled-in target project and skip themselves when `docs/cbk/scaffold.md` is absent. A red check is a defect in the check until proven otherwise: a suite with a permanently red line is a suite nobody runs, which is worse than no suite. Every check says what it catches. Run the block after major edits to cascade skills, to the rules, or to a project's filled-in copy of this file. +**Run it** through `.claude/workflows/tests/run-verification-block.sh`: it performs the documented extraction and adds two fail-loud rails the block cannot carry for itself — an empty extraction is red (a plain `bash -e` on an empty file exits 0), and an exit 0 that never printed `verification: done` is red. The kit's CI runs it on every pull request; a target wires the same script as the body of a task its check command depends on (a check nobody re-runs is a belief with a date on it — #58, second application, item 7). The block stays fail-fast: every red is fixed, or the check is narrowed in the project's own copy with an inline comment saying why — a "recorded" red cannot reach the sentinels. + ```bash # ═══ KIT-REPO CHECKS — must be green on the kit tree and in every target project ═══ @@ -490,7 +492,12 @@ absent grep -rnE "notion\.(so|site)/[0-9a-f]{16,}" .claude/skills/ # Pre-refactor "opinionated-profile" vocabulary must not appear anywhere in kit content (the # constant + two axes refactor removed the concept); the pattern splits its literal so this # line never matches itself. -absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md .mcp.json.example +# The example env file is an operand only where it exists: a target that commits `.mcp.json` instead +# names that file here, and a project sub-block re-homed from a pre-split contract must split its own +# retired-vocabulary literal (as this comment does: opinionate[d] profile) or this very line matches +# it (#58, second application, item 1). +mcpx=""; [ -f .mcp.json.example ] && mcpx=.mcp.json.example +absent grep -rn -i "opinionate[d] profile\|opinionated_profil[e]" .claude/ README.md $mcpx # CLAUDE.md points at this file as a backticked mention — deliberately NOT an `@` import, # which would expand this whole file into every session at launch (memory docs). @@ -595,21 +602,24 @@ absent grep -niE "(if|when) in doub[t],? (use|reach for)" .claude/rules/tooling. # The bootstrap checklist prompts for the orchestration posture (#34). grep -q 'Orchestration posture' .claude/skills/scaffold/references/bootstrap_checklist_template.md || { echo "the bootstrap checklist lacks the orchestration-posture row"; exit 1; } # Hook registry ⇔ files ⇔ table: every shipped guard is registered at least once and the advisory -# exemplars stay unregistered; every command field in settings.json — registered or an exemplar -# stanza — is in placeholder form, exists and is executable; every hook header carries its Tier: -# line; the registry comment names every hook and all four tiers; and the conventions' two-views -# paragraph (cbk-conventions.md § Mutation discipline) names every registered hook. +# exemplars stay unregistered on the kit tree (a target asserts its own wiring in the project +# sub-block, ADVISORY_WIRED); every command field in settings.json is in placeholder form, exists +# and is executable; every hook header carries its Tier: line; the registry comment names every hook +# and all four tiers; and the conventions' two-views paragraph (cbk-conventions.md § Mutation +# discipline) names every registered hook. hookcomment=$(jq -r '._comment_hooks' .claude/settings.json) -for h in .claude/hooks/*.sh; do b=$(basename "$h"); n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case "$b" in format-on-edit.sh|analyze-on-edit.sh) [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered"; exit 1; };; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac; grep -q "$b" <<<"$hookcomment" || { echo "registry comment does not name $b"; exit 1; }; grep -q '^# Tier:' "$h" || { echo "$b has no Tier: line in its header"; exit 1; }; done +for h in .claude/hooks/*.sh; do b=$(basename "$h"); n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case "$b" in format-on-edit.sh|analyze-on-edit.sh) if [ ! -f docs/cbk/scaffold.md ]; then [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered (the kit tree ships it unregistered; a target declares ADVISORY_WIRED in its sub-block)"; exit 1; }; fi;; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac; grep -q "$b" <<<"$hookcomment" || { echo "registry comment does not name $b"; exit 1; }; grep -q '^# Tier:' "$h" || { echo "$b has no Tier: line in its header"; exit 1; }; done [ "$(jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | wc -l)" -ge 1 ] || { echo "settings.json carries no hook commands at all"; exit 1; } jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | sort -u | while read -r c; do case "$c" in '${CLAUDE_PROJECT_DIR}/'*) ;; *) echo "hook command is not in placeholder form (handlers run in the current directory): $c"; exit 1;; esac; c="${c/\$\{CLAUDE_PROJECT_DIR\}/.}"; [ -x "$c" ] || { echo "hook command missing or not executable: $c"; exit 1; }; done for t in HARD-DENY ASK-GATE ADVISORY STOP; do grep -q "$t" <<<"$hookcomment" || { echo "registry comment lacks the $t tier"; exit 1; }; done jq -r '[.hooks[][] | .hooks[] | .command][]' .claude/settings.json | sort -u | while read -r c; do b=$(basename "$c"); grep -q "$b" .claude/rules/cbk-conventions.md || { echo "cbk-conventions.md § Mutation discipline does not name the registered hook $b"; exit 1; }; done # The launch-root guard, on its branches (crafted payloads; read-only). Only exit 2 denies, so the # blocking case asserts 2 exactly; `|| rc=$?` keeps -e satisfied while the status stays testable. -rc=0; printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || rc=$? -[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks)"; exit 1; } -printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh >/dev/null 2>&1 || { echo "launch-root guard blocked a root dispatch"; exit 1; } +# stderr is kept and printed on failure: the hook's own reason is the diagnostic (#58 item 11). +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s/docs"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 2 ] || { echo "launch-root guard did not DENY a subdirectory dispatch (exit $rc; only 2 blocks). The hook said:"; printf ' %s\n' "$err"; exit 1; } +rc=0; err=$(printf '{"tool_name":"Agent","tool_input":{},"cwd":"%s"}' "$PWD" | .claude/hooks/require-repo-root-for-agents.sh 2>&1 >/dev/null) || rc=$? +[ "$rc" -eq 0 ] || { echo "launch-root guard blocked a root dispatch (exit $rc). The hook said:"; printf ' %s\n' "$err"; exit 1; } # No reviewer-memory tree outside the root — asked of the Stop hook itself with a crafted payload, # so the exclusion list has one home (it exits 2 while a stray tree exists; the gate's own check). printf '{"stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$PWD" .claude/hooks/detect-forked-agent-memory.sh || { echo "a reviewer-memory tree exists outside the root (detect-forked-agent-memory.sh blocked; its stderr above names the paths)"; exit 1; } @@ -669,6 +679,11 @@ echo "verification: kit sub-block complete" # ═══ PROJECT CHECKS — a filled-in target project only; skipped on the kit tree ═══ if [ -f docs/cbk/scaffold.md ]; then + # Advisory hooks the project wired (§ Hook authoring): name each registered one here, space-separated. + # Each named hook must be registered exactly once; each unnamed one zero times. Default: none wired. + ADVISORY_WIRED="${ADVISORY_WIRED:-}" + for b in format-on-edit.sh analyze-on-edit.sh; do n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case " $ADVISORY_WIRED " in *" $b "*) [ "$n" -eq 1 ] || { echo "advisory hook $b is declared wired but registered $n times"; exit 1; };; *) [ "$n" -eq 0 ] || { echo "advisory hook $b is registered but not declared in ADVISORY_WIRED"; exit 1; };; esac; done + # Layout: the project's own artifacts follow the layout it chose. Flat is the default; # a project that chose the nested layout inverts this line. absent test -d docs/cbk/framings diff --git a/.claude/workflows/tests/run-verification-block.sh b/.claude/workflows/tests/run-verification-block.sh new file mode 100755 index 0000000..3a7886f --- /dev/null +++ b/.claude/workflows/tests/run-verification-block.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Runs the verification block (cbk-conventions-reference.md § Verification) with two fail-loud +# rails the block cannot carry itself: an EMPTY extraction is red (the heading or the fence +# moved — a plain `bash -e` on an empty file exits 0), and an exit 0 that never printed the +# closing sentinel is red (the block ended early). The kit's CI calls this; a target project +# copies it as the body of the task its check command runs (cbk-conventions-reference.md +# § Verification › Run it). Run from anywhere inside the checkout: +# bash .claude/workflows/tests/run-verification-block.sh +set -uo pipefail +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel 2>/dev/null) || { echo "run-verification-block: not inside a git checkout"; exit 1; } +cd "$root" || exit 1 +tmp=$(mktemp) || exit 1 +trap 'rm -f "$tmp"' EXIT +awk '/^## Verification/{p=1} p' .claude/rules/cbk-conventions-reference.md | awk '/^```bash/{c=1;next} /^```/{c=0} c' > "$tmp" +[ -s "$tmp" ] || { echo "verification: EMPTY EXTRACTION — '## Verification' or its bash fence moved in cbk-conventions-reference.md"; exit 1; } +rc=0; out=$(bash -e "$tmp" 2>&1) || rc=$? +printf '%s\n' "$out" +[ "$rc" -eq 0 ] || { echo "verification: block exited $rc"; exit "$rc"; } +grep -q '^verification: done$' <<<"$out" || { echo "verification: exit 0 WITHOUT the done sentinel — the block ended early"; exit 1; } diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..066a5b4 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,25 @@ +name: Verification block + +# Runs cbk-conventions-reference.md § Verification through its runner on every pull request to +# main — no path filter, so it can be a required check (the required-checks trap in +# cbk-conventions.md § `[skip ci]` rule). ubuntu-latest ships bash, jq, git, node and python3 +# (dated observation, 2026-09-21; the block names any tool it cannot find). + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + verify: + name: Verification block + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Run the block through its runner + run: bash .claude/workflows/tests/run-verification-block.sh From e4fa8a973aa06bfc95e5bff64597c915670833a2 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:01:50 -0600 Subject: [PATCH 04/23] =?UTF-8?q?fix(settings):=20no=20exemplar=20object?= =?UTF-8?q?=20in=20settings.json=20=E2=80=94=20the=20stanza=20moves=20to?= =?UTF-8?q?=20the=20hook=20headers=20and=20the=20block=20asserts=20the=20i?= =?UTF-8?q?nvariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hook-shaped object at the top level of settings.json is a fatal settings diagnostic on Claude Code 2.1.270–2.1.278, after which the parser returns no settings at all: every guard, enabledMcpjsonServers and enabledPlugins go inert, silently, with the verification block green. The kit shipped both advisory exemplars in exactly that shape. Both keys are deleted; each advisory hook's header carries the registration stanza as a `Register:` block; the registry comment says why no exemplar object may live in the file and names both memory scopes on the STOP tier; § Hook authoring's bullet states the invariant with its evidence; the block asserts no top-level key besides `hooks` holds a matcher/hooks object (proven red by mutation). Closes #58's settings-liveness finding (2026-09-13 comment) and the second-application item 5. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/analyze-on-edit.sh | 10 ++++++++-- .claude/hooks/format-on-edit.sh | 6 ++++++ .claude/rules/cbk-conventions-reference.md | 7 ++++++- .claude/settings.json | 22 +--------------------- README.md | 2 +- 5 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.claude/hooks/analyze-on-edit.sh b/.claude/hooks/analyze-on-edit.sh index d365e0a..0c9e404 100755 --- a/.claude/hooks/analyze-on-edit.sh +++ b/.claude/hooks/analyze-on-edit.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # PostToolUse hook (Edit|Write|MultiEdit matcher) — EXEMPLAR: a project copies -# this hook, wires its analyzer into the case arms below, and registers it via -# the `_example_PostToolUse_analyzer` stanza in settings.json. +# this hook, wires its analyzer into the case arms below, and registers it with +# the `Register:` stanza in this header (never as a top-level settings.json key). # # Runs the project's analyzer on the PACKAGE of every edited source file and # prints only its ERRORS, so a boundary violation surfaces at the edit rather @@ -23,6 +23,12 @@ # Allowed: everything. # Path: registered (once wired) as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… # Tier: ADVISORY. +# Register: copy this object into hooks.PostToolUse in .claude/settings.json once the case +# arms are wired — never as a top-level key (cbk-conventions-reference.md § Hook +# authoring: a hook-shaped object outside `hooks` voids the whole settings file): +# { "matcher": "Edit|Write|MultiEdit", +# "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/analyze-on-edit.sh" } ] } set -uo pipefail # Deliberately NOT `set -e` — see the advisory contract above. diff --git a/.claude/hooks/format-on-edit.sh b/.claude/hooks/format-on-edit.sh index 31d9168..25a6b7a 100755 --- a/.claude/hooks/format-on-edit.sh +++ b/.claude/hooks/format-on-edit.sh @@ -12,6 +12,12 @@ # Advisory-only contract: exit 0 ALWAYS. Formatting failures surface on stderr # as non-fatal notes; they never block the tool call. # Tier: ADVISORY (see the registry comment in .claude/settings.json). +# Register: copy this object into hooks.PostToolUse in .claude/settings.json once the case +# arms are wired — never as a top-level key (cbk-conventions-reference.md § Hook +# authoring: a hook-shaped object outside `hooks` voids the whole settings file): +# { "matcher": "Edit|Write|MultiEdit", +# "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" } ] } set -uo pipefail # Note: deliberately NOT using `set -e` — see the advisory contract above. diff --git a/.claude/rules/cbk-conventions-reference.md b/.claude/rules/cbk-conventions-reference.md index 0e89d93..aaaf0e5 100644 --- a/.claude/rules/cbk-conventions-reference.md +++ b/.claude/rules/cbk-conventions-reference.md @@ -399,7 +399,7 @@ The shape a hook follows, stated once (the registry comment in `.claude/settings - **Project-relative paths, in placeholder form.** The registry names `${CLAUDE_PROJECT_DIR}/.claude/hooks/.sh` — handlers run in the current directory (`https://code.claude.com/docs/en/hooks`), so a bare relative path is not found from a subdirectory, which is where a launch-directory guard must fire. The script derives its root from `CLAUDE_PROJECT_DIR` (falling back to `$PWD`) or, for a file-scoped hook, from the edited file's checkout (`git -C "$(dirname "$file")" rev-parse --show-toplevel`) — inside a worktree the project dir stays where the session started while the file lives in the worktree. - **Four tiers** — hard-deny, ask-gate, advisory, stop — per § HITL gate load-bearing heuristics › Mechanize the gates; the registry comment names every hook under its tier with its mechanism and dated source, and states prerequisites and the canonical dry-run once. - **The mutation table and the hook registry are two views of one list** — see `cbk-conventions.md` § Mutation discipline; the verification block checks that its paragraph names every registered hook. -- **Exemplar stanzas ship commented out.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered with its registration as an `_example_PostToolUse_*` key beside `hooks` — copied in after the case arms are filled — so a fresh checkout never runs a formatter it does not have. +- **No hook-shaped object outside `hooks`, ever.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered, and its registration stanza lives in the hook's own header (`Register:`), copied into `hooks.PostToolUse` after the case arms are filled — so a fresh checkout never runs a formatter it does not have. JSON has no comments, and a "commented-out" stanza approximated by a live top-level object is a defect: any top-level key that is not `hooks` (nor one of the harness's own settings keys) whose value carries `matcher` or a non-empty `hooks` is a fatal settings diagnostic, after which the parser returns no settings at all — every guard, `enabledMcpjsonServers` and `enabledPlugins` inert, silently, with the verification block green (Claude Code 2.1.270–2.1.278: found on a real application by single-variable probe, 2026-09-13; the fatal path read from the 2.1.278 bundle, 2026-09-21; re-verify after upgrades). The block asserts the invariant on every run. - **An axis-conditional guard is inert when its axis is off, and follows its rule file.** A guard whose matcher names tools of an axis the project turned off (the knowledge-backend ask-gate on a `none` project) never fires because those tools are not connected, so leaving it registered costs nothing; the kit's default for that axis is nonetheless to delete the rule, the hook and its stanza together at the bootstrap disposition pass (`cbk-conventions.md` § Rule loading and the instruction budget) — inert-if-kept is the safety property, not the recommendation. - **An edit-time analyzer under the advisory contract.** `analyze-on-edit.sh` runs the project's analyzer on the package of every edited file and prints only *errors* to stderr, exit 0 always — a boundary violation surfaces at the edit, not at the `check` task. Its case arms are the project's; the exemplar ships with them commented, inside the `case`, so uncommenting is the whole wiring; placeholders are bare `ALL_CAPS` words, never ``, because `<` and `>` are shell syntax once the arm is live. - **Verify by payload.** When a hook is authored or changed, each branch it has is exercised by piping a crafted JSON payload and asserting the exact exit (only 2 denies — a crash is not a block), plus one real dispatch for a guard whose matcher is a dated observation; the table of expected and observed exits goes in the PR body. The verification block re-runs a subset on every run — the launch-root guard's deny and allow payloads, and the Stop hook against the live tree — while a state-mutating dry-run (a stray memory tree, a commit on `main`) runs against a fixture or a throwaway clone at authoring time. @@ -608,6 +608,11 @@ grep -q 'Orchestration posture' .claude/skills/scaffold/references/bootstrap_che # and all four tiers; and the conventions' two-views paragraph (cbk-conventions.md § Mutation # discipline) names every registered hook. hookcomment=$(jq -r '._comment_hooks' .claude/settings.json) +# No hook-shaped object at the top level of settings.json (§ Hook authoring): the harness discards the +# whole file on one, silently. An empty key read is red, never a vacuous pass. +[ "$(jq -r 'keys | length' .claude/settings.json)" -ge 1 ] || { echo "settings.json: no top-level keys read — the check below would pass vacuously"; exit 1; } +bad=$(jq -r 'to_entries | map(select(.key != "hooks" and (.value | type == "object") and ((.value | has("matcher")) or (.value | has("hooks"))))) | .[].key' .claude/settings.json); [ -z "$bad" ] || { echo "settings.json: hook-shaped top-level object(s) void the whole file: $bad"; exit 1; } +absent grep -n '"_example_PostToolUse_' .claude/settings.json for h in .claude/hooks/*.sh; do b=$(basename "$h"); n=$(jq -r '[.hooks[][] | .hooks[] | .command] | map(select(endswith("'"$b"'"))) | length' .claude/settings.json); case "$b" in format-on-edit.sh|analyze-on-edit.sh) if [ ! -f docs/cbk/scaffold.md ]; then [ "$n" -eq 0 ] || { echo "advisory exemplar $b is registered (the kit tree ships it unregistered; a target declares ADVISORY_WIRED in its sub-block)"; exit 1; }; fi;; *) [ "$n" -ge 1 ] || { echo "$b is not registered"; exit 1; };; esac; grep -q "$b" <<<"$hookcomment" || { echo "registry comment does not name $b"; exit 1; }; grep -q '^# Tier:' "$h" || { echo "$b has no Tier: line in its header"; exit 1; }; done [ "$(jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | wc -l)" -ge 1 ] || { echo "settings.json carries no hook commands at all"; exit 1; } jq -r '.. | objects | select(has("command")) | .command' .claude/settings.json | sort -u | while read -r c; do case "$c" in '${CLAUDE_PROJECT_DIR}/'*) ;; *) echo "hook command is not in placeholder form (handlers run in the current directory): $c"; exit 1;; esac; c="${c/\$\{CLAUDE_PROJECT_DIR\}/.}"; [ -x "$c" ] || { echo "hook command missing or not executable: $c"; exit 1; }; done diff --git a/.claude/settings.json b/.claude/settings.json index 4020b94..adb73c0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "_comment_hooks": "Guards in FOUR tiers; each tier names its mechanism and its dated source; prerequisites and the canonical dry-run are stated once at the end (cbk-conventions-reference.md § HITL gate load-bearing heuristics › Mechanize the gates; the authoring shape is § Hook authoring). Every command uses ${CLAUDE_PROJECT_DIR}/… — handlers run in the current directory (code.claude.com/docs/en/hooks, verified 2026-09-06), so a bare relative path is not found from a subdirectory. HARD-DENY (PreToolUse, exit 2) for actions never legitimate for the agent: protect-immutable-adrs.sh blocks Edit/Write/MultiEdit on existing docs/adr/NNNN-*.md; protect-lock-files.sh blocks edits to lock files (change them via the package manager); protect-main-branch.sh blocks `git commit` on main/master (branch-first per cbk-conventions.md § Branch naming; judged on the branch at entry, so branch creation and the first commit are separate calls); require-repo-root-for-agents.sh blocks Task/Agent/Workflow dispatch from any directory but the checkout's git top-level (the memory-root mechanism and its dated source are stated once in pr-review.md § Reviewer precedent memory); the `Task|Agent|Workflow` matcher is a dated observation of the tool names the harness reports, with the older name kept so a rename degrades to a no-op. ASK-GATE (PreToolUse, permissionDecision \"ask\") for one-way doors legitimate only when operator-instructed — the permission prompt IS the per-action HITL approval, and it fires even when a broad allowlist entry would otherwise auto-approve: guard-pr-state.sh intercepts gh pr ready/merge/close/reopen; require-knowledge-backend-ok.sh intercepts knowledge-backend MCP writes (Notion matcher shipped as the v1 reference — adjust the regex to your MCP's tool names; when the knowledge axis is `none` the guard is inert because its tools are not connected, and it follows the rule file's disposition at bootstrap). ADVISORY (PostToolUse, exit 0 always) ships as unregistered exemplars with their stanzas beside `hooks`: format-on-edit.sh (`_example_PostToolUse_formatter`) and analyze-on-edit.sh (`_example_PostToolUse_analyzer` — the project's analyzer on the edited file's package, errors only; measured under a second per package on a real run, 2026-09-05) — copy a stanza into hooks.PostToolUse after wiring its case arms to your stack. STOP (Stop, exit 2 blocks the stop once): detect-forked-agent-memory.sh exits 2 while a `.claude/agent-memory/` tree exists anywhere but the root, so the agent that forked it moves it before handing off; `stop_hook_active` prevents a loop, and the harness overrides a Stop hook after eight consecutive blocks (code.claude.com/docs/en/hooks-guide § Limitations and troubleshooting, verified 2026-09-06). Registered on Stop only: on SubagentStop it would block every finishing subagent, which has no hand-off. All guards fail open (exit 0 + stderr warning naming the surviving backstop) on environment defects, so a missing jq can't turn a targeted guard into a universal block. Prerequisites: jq, git, bash 3.2+. Canonical dry-run: pipe a crafted JSON payload into the script and assert the exit (the verification block runs the launch-root guard's payloads on every run; the Stop hook's fixture run is the procedure in cbk-conventions-reference.md § Hook authoring › Verify by payload, recorded in the PR body's table).", + "_comment_hooks": "Guards in FOUR tiers; each tier names its mechanism and its dated source; prerequisites and the canonical dry-run are stated once at the end (cbk-conventions-reference.md § HITL gate load-bearing heuristics › Mechanize the gates; the authoring shape is § Hook authoring). Every command uses ${CLAUDE_PROJECT_DIR}/… — handlers run in the current directory (code.claude.com/docs/en/hooks, verified 2026-09-06), so a bare relative path is not found from a subdirectory. HARD-DENY (PreToolUse, exit 2) for actions never legitimate for the agent: protect-immutable-adrs.sh blocks Edit/Write/MultiEdit on existing docs/adr/NNNN-*.md; protect-lock-files.sh blocks edits to lock files (change them via the package manager); protect-main-branch.sh blocks `git commit` on main/master (branch-first per cbk-conventions.md § Branch naming; judged on the branch at entry, so branch creation and the first commit are separate calls); require-repo-root-for-agents.sh blocks Task/Agent/Workflow dispatch from any directory but the checkout's git top-level (the memory-root mechanism and its dated source are stated once in pr-review.md § Reviewer precedent memory); the `Task|Agent|Workflow` matcher is a dated observation of the tool names the harness reports, with the older name kept so a rename degrades to a no-op. ASK-GATE (PreToolUse, permissionDecision \"ask\") for one-way doors legitimate only when operator-instructed — the permission prompt IS the per-action HITL approval, and it fires even when a broad allowlist entry would otherwise auto-approve: guard-pr-state.sh intercepts gh pr ready/merge/close/reopen; require-knowledge-backend-ok.sh intercepts knowledge-backend MCP writes (Notion matcher shipped as the v1 reference — adjust the regex to your MCP's tool names; when the knowledge axis is `none` the guard is inert because its tools are not connected, and it follows the rule file's disposition at bootstrap). ADVISORY (PostToolUse, exit 0 always) ships as two unregistered exemplars, format-on-edit.sh and analyze-on-edit.sh (the analyzer runs the project's analyzer on the edited file's package, errors only; measured under a second per package on a real run, 2026-09-05); each hook's header carries the registration stanza to copy into hooks.PostToolUse once its case arms are wired. NO exemplar object lives in this file: a top-level key holding a matcher/hooks object makes the harness discard the whole settings file — every guard, enabledMcpjsonServers and enabledPlugins inert, silently (Claude Code 2.1.270–2.1.278; single-variable probe on a real application 2026-09-13, the parser's fatal diagnostic read from the 2.1.278 bundle 2026-09-21), and the verification block asserts none is present. STOP (Stop, exit 2 blocks the stop once): detect-forked-agent-memory.sh exits 2 while a `.claude/agent-memory/` or `.claude/agent-memory-local/` tree exists anywhere but the root (both memory scopes fork the same way), so the agent that forked it moves it before handing off; `stop_hook_active` prevents a loop, and the harness overrides a Stop hook after eight consecutive blocks (code.claude.com/docs/en/hooks-guide § Limitations and troubleshooting, verified 2026-09-06). Registered on Stop only: on SubagentStop it would block every finishing subagent, which has no hand-off. All guards fail open (exit 0 + stderr warning naming the surviving backstop) on environment defects, so a missing jq can't turn a targeted guard into a universal block. Prerequisites: jq, git, bash 3.2+. Canonical dry-run: pipe a crafted JSON payload into the script and assert the exit (the verification block runs the launch-root guard's payloads on every run; the Stop hook's fixture run is the procedure in cbk-conventions-reference.md § Hook authoring › Verify by payload, recorded in the PR body's table).", "hooks": { "PreToolUse": [ { @@ -59,26 +59,6 @@ } ] }, - "_example_PostToolUse_formatter": { - "_comment": "Copy this object into hooks.PostToolUse after wiring .claude/hooks/format-on-edit.sh's case arms to your stack. Advisory tier: exit 0 always.", - "matcher": "Edit|Write|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" - } - ] - }, - "_example_PostToolUse_analyzer": { - "_comment": "Copy this object into hooks.PostToolUse after wiring .claude/hooks/analyze-on-edit.sh's case arm to your analyzer (measure it first: under a second per package, or it belongs in the check task). Advisory tier: exit 0 always; errors only.", - "matcher": "Edit|Write|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/analyze-on-edit.sh" - } - ] - }, "_comment_enabledPlugins": "Plugins the cascade kit expects. Install each via Claude Code's plugin system before running /finish: pr-review-toolkit (the review pass dispatches pr-review-toolkit:review-pr) and commit-commands (staging/commit wrappers used by the kit's examples). `/simplify` — /finish's simplify pass — is built into Claude Code as a bundled skill (verified against the installed CLI bundle, Claude Code 2.1.263, 2026-09-06; re-verify after harness upgrades) and is deliberately NOT listed here: add \"simplify\": true only if your install ships it as a plugin. Rename entries to match installed plugin IDs if yours differ.", "enabledPlugins": { "pr-review-toolkit": true, diff --git a/README.md b/README.md index fedd574..1d3b818 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Four files reliably need editing per project, and scaffold's bootstrap checklist 2. **`.claude/commands/finish.md`** — bakes in the project's `check` task, `docs/STANDARDS.md § Step 4`, `.claude/rules/testing.md`, `.claude/rules/logging.md`, `pr-review-toolkit:review-pr`, `/simplify`. If your stack doesn't have one of these, edit the file. The eight-section spec contract that `/finish` reads from issue bodies is the stable interface; the tooling assumptions are the swap-out point. -3. **`.claude/settings.json`** — adjust `enabledPlugins` if your installed identifiers differ; adjust `enabledMcpjsonServers` if you don't use one of the five defaults or want to add others. +3. **`.claude/settings.json`** — adjust `enabledPlugins` if your installed identifiers differ; adjust `enabledMcpjsonServers` if you don't use one of the five defaults or want to add others. Never add a hook-shaped object as a top-level key while doing so — the advisory hooks' registration stanzas live in their headers (see `.claude/rules/cbk-conventions-reference.md` § Hook authoring). 4. **`.claude/rules/logging.md` and `.claude/rules/testing.md`** — stamp the `paths:` globs at the top with your project's real extensions; they ship as placeholders and a placeholder glob loads nothing. Then settle a disposition for each template rule (`orchestration.md`, `tooling.md`, and the bracketed entry in `cbk-conventions-reference.md`'s `paths:`): fill, path-scope, or delete — see `cbk-conventions.md` § Rule loading and the instruction budget. From 49951300943b9bcb533ca563ab05409557dcc257 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:03:03 -0600 Subject: [PATCH 05/23] =?UTF-8?q?docs(hooks):=20the=20stdin/exit=20contrac?= =?UTF-8?q?t=20=E2=80=94=20drain=20first,=20never=20decide=20on=20a=20pipe?= =?UTF-8?q?=20whose=20reader=20can=20exit=20first;=20Depends:=20in=20the?= =?UTF-8?q?=20header=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit § Hook authoring states the two halves once, with who pays for the masking (a piping caller under pipefail — the block's dry-runs and the fixture, not the harness's registered call), the here-string rule and the measured bypasses (you-are-hear #81); the header shape gains a Depends: line; verify- by-payload names the fixture as the durable home. Closes #58 item 4's text half and the 2026-09-07 comment's Depends: request. Co-Authored-By: Claude Fable 5.1 --- .claude/rules/cbk-conventions-reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/rules/cbk-conventions-reference.md b/.claude/rules/cbk-conventions-reference.md index aaaf0e5..e3a20fa 100644 --- a/.claude/rules/cbk-conventions-reference.md +++ b/.claude/rules/cbk-conventions-reference.md @@ -393,8 +393,8 @@ Moved from the contract's § `[skip ci]` rule on 2026-09-07 (consulted when a `. The shape a hook follows, stated once (the registry comment in `.claude/settings.json` and the hook headers cite this section; they do not restate it). The three hooks authored with this section carry every line; the earlier five carry `Blocked:`/`Allowed:` and `Tier:` (the line the verification block asserts) and approximate the rest — bring a hook up to the full shape when you next edit it. -- **Header.** Event and matcher on line 2; *why* in one paragraph with its dated source (a platform page or a dated real-run observation, never memory); `Blocked:` / `Allowed:` lines; a `Timing:` line when the guard reads state before the tool runs (a compound command is judged on the state at entry — create the branch and make the first commit in separate calls; and the payload `cwd` is the session's launch directory, which a shell `cd` does not move — a real dispatch, 2026-09-07 — so a launch-directory guard is answered by relaunching the session from the root); `Path:` naming the placeholder registration; `Tier:` on every hook. -- **The stdin / exit contract.** JSON on stdin (`tool_name`, `tool_input`, and the common fields, `cwd` among them — `https://code.claude.com/docs/en/hooks-guide` § How hooks work). Exit 2 + stderr blocks (deny), exit 0 allows; an ask-gate prints `hookSpecificOutput.permissionDecision: "ask"` with a reason and exits 0 (`https://code.claude.com/docs/en/hooks`: allow / deny / ask / defer). Matching hooks in one group run in parallel — never rely on order between two hooks on the same event. `Stop` takes no matcher; `SubagentStop` matches agent types, and a finishing subagent has no hand-off — a repair-before-stop hook belongs on `Stop`. Verified 2026-09-06; re-verify after harness upgrades. +- **Header.** Event and matcher on line 2; *why* in one paragraph with its dated source (a platform page or a dated real-run observation, never memory); `Blocked:` / `Allowed:` lines; a `Timing:` line when the guard reads state before the tool runs (a compound command is judged on the state at entry — create the branch and make the first commit in separate calls; and the payload `cwd` is the session's launch directory, which a shell `cd` does not move — a real dispatch, 2026-09-07 — so a launch-directory guard is answered by relaunching the session from the root); `Path:` naming the placeholder registration; `Tier:` on every hook; a `Depends:` line naming each external dependency (jq, git, mktemp, a sourced helper) and what its absence costs — fail open, degrade unpruned, degrade unmonitored — so a rewrite that adds a dependency cannot leave the header claiming none (#58, 2026-09-07 comment). +- **The stdin / exit contract.** **Drain stdin before anything that can exit** — `input="$(cat)"` is the first statement after `set -uo pipefail`, above the dependency check and every other early exit. A hook that exits unread leaves its caller holding an open pipe with no reader, so the caller takes SIGPIPE; the masking needs a shell pipeline under `pipefail` (`printf … | hook`), where the writer's 141 becomes the *pipeline's* status and hides the hook's own exit code, so a guard that failed open correctly reads as a crash. That is exactly the shape of this file's § Verification dry-runs, the hook fixture, and any script that pipes a payload in; the harness's registered call is not that shape (a `command` entry with no `args` runs as one process with the payload written to its stdin — `https://code.claude.com/docs/en/hooks`, read 2026-09-21), and draining first is the contract regardless, because the test and verification surfaces are where a hook's exit code is read as its verdict. A payload smaller than the pipe buffer lets the writer finish first, so the race is one a small-payload test almost always wins — a regression test for a race must not itself be a race. **And never decide on the status of a pipeline whose reader can exit first.** `printf '%s' "$x" | grep -Eq …` reads like a string test, but grep exits on its first matching *line*, so a multi-line subject past the buffer leaves the writer to take SIGPIPE and `pipefail` turns a match into a 141 the `if` reads as *no match* — a guard bypassed with no warning, the one fail-open shape the next bullet forbids. A single long line hides it (grep must reach EOF to complete the line), so a filler-on-one-line probe reads clean. Feed the subject as a here-string (`grep -Eq … <<<"$x"`), which keeps grep's line semantics byte-for-byte and leaves nothing still writing when the reader exits (bash hands a small subject a pipe it has already filled and spills a large one to an unlinked temp file), and cap a report inside its producer (awk's own counter), never with `| head -N`. Measured on a real application, 2026-09-18: a >64 KiB multi-line commit body bypassed the main-branch deny outright and a long `gh pr merge --body` skipped the PR-state ask-gate (you-are-hear #81 → PR #82). Both halves are enforced by `.claude/workflows/tests/hook-contract-fixture.sh`, which reads every hook: one structural check asserts the drain is the first statement, the other that no hook pipes into an early-exiting reader — a tripwire over known spellings, not a proof, so a new hook still earns a behavioural case. Then the wire contract: JSON on stdin (`tool_name`, `tool_input`, and the common fields, `cwd` among them — `https://code.claude.com/docs/en/hooks-guide` § How hooks work). Exit 2 + stderr blocks (deny), exit 0 allows; an ask-gate prints `hookSpecificOutput.permissionDecision: "ask"` with a reason and exits 0 (`https://code.claude.com/docs/en/hooks`: allow / deny / ask / defer). Matching hooks in one group run in parallel — never rely on order between two hooks on the same event. `Stop` takes no matcher; `SubagentStop` matches agent types, and a finishing subagent has no hand-off — a repair-before-stop hook belongs on `Stop`. Verified 2026-09-06; re-verify after harness upgrades. - **Fail-open, with its backstop named.** On an environment defect (no `jq`, not a git checkout, an unset variable) a guard exits 0 with a stderr warning that names the surviving backstop — the CI lint, the Stop-tier hook, the verification block — never fails closed into a universal block. `set -uo pipefail`, deliberately not `set -e`; no bash-4-only builtins (`mapfile`), because a stock macOS bash is 3.2 and a "command not found" there is a fail-closed. - **Project-relative paths, in placeholder form.** The registry names `${CLAUDE_PROJECT_DIR}/.claude/hooks/.sh` — handlers run in the current directory (`https://code.claude.com/docs/en/hooks`), so a bare relative path is not found from a subdirectory, which is where a launch-directory guard must fire. The script derives its root from `CLAUDE_PROJECT_DIR` (falling back to `$PWD`) or, for a file-scoped hook, from the edited file's checkout (`git -C "$(dirname "$file")" rev-parse --show-toplevel`) — inside a worktree the project dir stays where the session started while the file lives in the worktree. - **Four tiers** — hard-deny, ask-gate, advisory, stop — per § HITL gate load-bearing heuristics › Mechanize the gates; the registry comment names every hook under its tier with its mechanism and dated source, and states prerequisites and the canonical dry-run once. @@ -402,7 +402,7 @@ The shape a hook follows, stated once (the registry comment in `.claude/settings - **No hook-shaped object outside `hooks`, ever.** An advisory hook the project must wire to its stack (a formatter, an analyzer) ships unregistered, and its registration stanza lives in the hook's own header (`Register:`), copied into `hooks.PostToolUse` after the case arms are filled — so a fresh checkout never runs a formatter it does not have. JSON has no comments, and a "commented-out" stanza approximated by a live top-level object is a defect: any top-level key that is not `hooks` (nor one of the harness's own settings keys) whose value carries `matcher` or a non-empty `hooks` is a fatal settings diagnostic, after which the parser returns no settings at all — every guard, `enabledMcpjsonServers` and `enabledPlugins` inert, silently, with the verification block green (Claude Code 2.1.270–2.1.278: found on a real application by single-variable probe, 2026-09-13; the fatal path read from the 2.1.278 bundle, 2026-09-21; re-verify after upgrades). The block asserts the invariant on every run. - **An axis-conditional guard is inert when its axis is off, and follows its rule file.** A guard whose matcher names tools of an axis the project turned off (the knowledge-backend ask-gate on a `none` project) never fires because those tools are not connected, so leaving it registered costs nothing; the kit's default for that axis is nonetheless to delete the rule, the hook and its stanza together at the bootstrap disposition pass (`cbk-conventions.md` § Rule loading and the instruction budget) — inert-if-kept is the safety property, not the recommendation. - **An edit-time analyzer under the advisory contract.** `analyze-on-edit.sh` runs the project's analyzer on the package of every edited file and prints only *errors* to stderr, exit 0 always — a boundary violation surfaces at the edit, not at the `check` task. Its case arms are the project's; the exemplar ships with them commented, inside the `case`, so uncommenting is the whole wiring; placeholders are bare `ALL_CAPS` words, never ``, because `<` and `>` are shell syntax once the arm is live. -- **Verify by payload.** When a hook is authored or changed, each branch it has is exercised by piping a crafted JSON payload and asserting the exact exit (only 2 denies — a crash is not a block), plus one real dispatch for a guard whose matcher is a dated observation; the table of expected and observed exits goes in the PR body. The verification block re-runs a subset on every run — the launch-root guard's deny and allow payloads, and the Stop hook against the live tree — while a state-mutating dry-run (a stray memory tree, a commit on `main`) runs against a fixture or a throwaway clone at authoring time. +- **Verify by payload.** When a hook is authored or changed, each branch it has is exercised by piping a crafted JSON payload and asserting the exact exit (only 2 denies — a crash is not a block), plus one real dispatch for a guard whose matcher is a dated observation; the branches a payload can reach are asserted in `.claude/workflows/tests/hook-contract-fixture.sh` (durable, run by the block), and the PR body's table is for the branches only a mutation can reach, named as such (#58 item 4). The verification block re-runs a subset on every run — the launch-root guard's deny and allow payloads, and the Stop hook against the live tree — while a state-mutating dry-run (a stray memory tree, a commit on `main`) runs against a fixture or a throwaway clone at authoring time. ## Recommended planning-backend settings From bba08349d99f868685d3044cb2ae02b673611762 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:03:48 -0600 Subject: [PATCH 06/23] fix(hooks): drain stdin first in every hook; decide on here-strings, never on a pipe whose reader can exit first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight hooks move `input="$(cat)"` to the first statement after `set -uo pipefail`, above the dependency check and every early exit (the knowledge-backend gate reads its payload into a variable instead of `cat > /dev/null`). The two decision sites that piped `printf | grep -Eq` decide on here-strings with byte-identical patterns: measured before the change, a >250 KB multi-line `git commit -F -` body on main was ALLOWED (exit 0 — a HARD-DENY silently bypassed); after it, denied (exit 2). The detector's own site lands with its rewrite in the next commit. Closes #58 item 4's hook half (you-are-hear #81 → PR #82, the bypass measured 2026-09-18). Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/analyze-on-edit.sh | 5 ++++- .claude/hooks/format-on-edit.sh | 5 ++++- .claude/hooks/guard-pr-state.sh | 9 +++++++-- .claude/hooks/protect-immutable-adrs.sh | 5 ++++- .claude/hooks/protect-lock-files.sh | 5 ++++- .claude/hooks/protect-main-branch.sh | 9 +++++++-- .claude/hooks/require-knowledge-backend-ok.sh | 5 ++++- .claude/hooks/require-repo-root-for-agents.sh | 5 ++++- 8 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.claude/hooks/analyze-on-edit.sh b/.claude/hooks/analyze-on-edit.sh index 0c9e404..14cbd4c 100755 --- a/.claude/hooks/analyze-on-edit.sh +++ b/.claude/hooks/analyze-on-edit.sh @@ -31,11 +31,14 @@ # "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/analyze-on-edit.sh" } ] } set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Deliberately NOT `set -e` — see the advisory contract above. command -v jq &>/dev/null || { echo "analyze-on-edit: jq not installed; skipping (advisory)." >&2; exit 0; } -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')" diff --git a/.claude/hooks/format-on-edit.sh b/.claude/hooks/format-on-edit.sh index 25a6b7a..cd3c20b 100755 --- a/.claude/hooks/format-on-edit.sh +++ b/.claude/hooks/format-on-edit.sh @@ -20,13 +20,16 @@ # "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh" } ] } set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — see the advisory contract above. PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" command -v jq &>/dev/null || { echo "format-on-edit: jq not installed; skipping (advisory)." >&2; exit 0; } -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" diff --git a/.claude/hooks/guard-pr-state.sh b/.claude/hooks/guard-pr-state.sh index efe0616..8ca2a1a 100755 --- a/.claude/hooks/guard-pr-state.sh +++ b/.claude/hooks/guard-pr-state.sh @@ -17,6 +17,10 @@ set -uo pipefail +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" + if ! command -v jq &>/dev/null; then echo "guard-pr-state: WARNING — jq not installed; PR-state guard DISABLED." >&2 echo " Until fixed, the only backstop is the prose rule in /finish and" >&2 @@ -24,7 +28,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" @@ -34,7 +37,9 @@ command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" # Token-anchored + flag-tolerant: matches `gh pr merge`, `gh -R o/r pr ready`, # `gh pr -R o/r close 5`, etc. Over-matching (the phrase quoted inside another # command) costs one extra confirmation — acceptable for an ask-gate. -if printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])'; then +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if grep -Eq '(^|[;&|[:space:]])gh([[:space:]]+[^[:space:]]+)*[[:space:]]+pr([[:space:]]+[^[:space:]]+)*[[:space:]]+(ready|merge|close|reopen)([[:space:]]|$|[;&|])' <<<"$command"; then cat <<'EOF' { "hookSpecificOutput": { diff --git a/.claude/hooks/protect-immutable-adrs.sh b/.claude/hooks/protect-immutable-adrs.sh index 21985c8..83d14a8 100755 --- a/.claude/hooks/protect-immutable-adrs.sh +++ b/.claude/hooks/protect-immutable-adrs.sh @@ -22,6 +22,10 @@ # Tier: HARD-DENY (see the registry comment in .claude/settings.json). set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — we fail open (exit 0) on environment # defects rather than abort with cryptic stderr that blocks all tool calls. @@ -35,7 +39,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" diff --git a/.claude/hooks/protect-lock-files.sh b/.claude/hooks/protect-lock-files.sh index 8e849bb..fe5dff0 100755 --- a/.claude/hooks/protect-lock-files.sh +++ b/.claude/hooks/protect-lock-files.sh @@ -22,6 +22,10 @@ # Tier: HARD-DENY (see the registry comment in .claude/settings.json). set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Note: deliberately NOT using `set -e` — we want to exit 0 (fail-open) on # environment defects rather than abort with cryptic stderr that blocks all # tool calls. @@ -37,7 +41,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" diff --git a/.claude/hooks/protect-main-branch.sh b/.claude/hooks/protect-main-branch.sh index c66c798..c656549 100755 --- a/.claude/hooks/protect-main-branch.sh +++ b/.claude/hooks/protect-main-branch.sh @@ -24,6 +24,10 @@ # Tier: HARD-DENY (see the registry comment in .claude/settings.json). set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Deliberately NOT `set -e` — fail-open on environment defects rather than # aborting with cryptic stderr that blocks all Bash calls. @@ -34,7 +38,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" command="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" # The Bash tool's payload carries the call's working directory; the branch @@ -54,7 +57,9 @@ PROJECT_DIR="${cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}" # repo's branch — costs one wrongly BLOCKED call with a loud message the # operator can override by running the commit themselves; under-matching # would be a silent bypass, which is worse for a deny-tier guard. -if ! printf '%s' "$command" | grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)'; then +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). +if ! grep -Eq '(^|[;&|[:space:]])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([[:space:]]|$)' <<<"$command"; then exit 0 fi diff --git a/.claude/hooks/require-knowledge-backend-ok.sh b/.claude/hooks/require-knowledge-backend-ok.sh index ee53efd..ec0eee4 100755 --- a/.claude/hooks/require-knowledge-backend-ok.sh +++ b/.claude/hooks/require-knowledge-backend-ok.sh @@ -17,7 +17,10 @@ # Tier: ASK-GATE (see the registry comment in .claude/settings.json). set -uo pipefail -cat > /dev/null # consume stdin; the decision is unconditional for matched tools +# Drain stdin first (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract); +# the decision is unconditional for matched tools, so the payload is read and not inspected. +input="$(cat)" +: "$input" cat <<'EOF' { "hookSpecificOutput": { diff --git a/.claude/hooks/require-repo-root-for-agents.sh b/.claude/hooks/require-repo-root-for-agents.sh index e309728..3149246 100755 --- a/.claude/hooks/require-repo-root-for-agents.sh +++ b/.claude/hooks/require-repo-root-for-agents.sh @@ -42,6 +42,10 @@ # stderr warning naming the surviving backstop, mirroring protect-main-branch.sh. set -uo pipefail + +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" # Deliberately NOT `set -e` — fail-open on environment defects rather than # aborting with cryptic stderr that blocks every dispatch. @@ -52,7 +56,6 @@ if ! command -v jq &>/dev/null; then exit 0 fi -input="$(cat)" tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')" case "$tool_name" in Task|Agent|Workflow) ;; From 6a72b366c4f0b64a178d9cd8034b5c3a9f115cf3 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:07:06 -0600 Subject: [PATCH 07/23] fix(hooks): the exercised fork detector, and the hook-contract fixture that reads every hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop-tier detector takes the body two real applications proved: the prune list is the project's own ignore rules (git ls-files --ignored | check-ignore) with the three safety rules — never prune a path that could be or contain the hunted tree, escape glob metacharacters before -path, keep check-ignore so a collapsed ancestor is walked — find's stderr kept behind a writability probe, `./.claude-pr` (a hosted review action's staging copy) pruned unconditionally, a Depends: line, the residual named in the header, and a BLOCKED remediation that says a tooling path is not a fork and nothing is moved or deleted. § .gitignore anchoring names `/.claude-pr/` among the harness transients. Proven in a throwaway tree: the real fork denied and named alone, an ignored build tree and the staging copy pruned, the second stop warns and proceeds, a clean tree passes. hook-contract-fixture.sh asserts, over every .claude/hooks/*.sh, that the drain is the first statement and that no hook decides on a pipeline whose reader can exit first (a tripwire over known spellings), plus one over-buffer probe per decision site (the main-branch deny, the PR-state ask, the detector's second stop). Both structural checks proven red by mutation. The block runs it. Landed as one commit with the detector: the fixture reads every hook, so neither is green without the other. Closes #58 items 1, 2 and 4 (the fixture half), the 2026-09-07 comment's new defect and its item-2 correction. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/detect-forked-agent-memory.sh | 110 +++++++++++++++--- .claude/rules/cbk-conventions-reference.md | 5 +- .../workflows/tests/hook-contract-fixture.sh | 91 +++++++++++++++ 3 files changed, 188 insertions(+), 18 deletions(-) create mode 100755 .claude/workflows/tests/hook-contract-fixture.sh diff --git a/.claude/hooks/detect-forked-agent-memory.sh b/.claude/hooks/detect-forked-agent-memory.sh index 6dd3c08..4fdcef3 100755 --- a/.claude/hooks/detect-forked-agent-memory.sh +++ b/.claude/hooks/detect-forked-agent-memory.sh @@ -15,7 +15,9 @@ # agent-memory-local directory exists — both memory scopes fork the same # way (exit 2 + the remediation on stderr). # Allowed: a clean tree; a second stop after one block (`stop_hook_active`); -# a working directory that is not a git checkout (fail-open with a warning). +# a working directory that is not a git checkout (fail-open with a warning); +# a tree under a directory the project's own ignore rules exclude, or under +# `./.claude-pr` — see Residual below. # Event: Stop only. Not SubagentStop — a finishing subagent has no hand-off # to repair the tree in, and `stop_hook_active` is documented for # Stop; registering there would block every subagent of a review @@ -30,16 +32,34 @@ # 2026-09-06, re-verify after upgrades), so this hook blocks at most # once per stop. A payload without the field is treated as the first # block (the assumption is stated, not silently defaulted). +# Residual: the walk prunes every directory the project's ignore rules exclude, so +# a tree placed by hand or by tooling UNDER an ignored directory (a build +# output, a tool cache) exits 0 — the dispatch that would create one is +# refused by require-repo-root-for-agents.sh, which is the guard for that +# case (#58 item 1's scenario table, reproduced independently 2026-09-13). +# `./.claude-pr` is pruned unconditionally: it is the staging copy a +# hosted review action makes of the branch's `.claude/` tree, committed +# memory included — a copy, not a fork (#58, 2026-09-07 comment). # Path: registered as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… (handlers run # in the current directory — https://code.claude.com/docs/en/hooks). # Tier: STOP. -# No jq dependency: the scan needs none, and the one field this hook reads -# (`stop_hook_active`) is matched with grep — so this backstop works exactly when -# the jq-dependent guards have failed open. No bash-4-only builtins (a stock -# macOS bash is 3.2): the directory list is read with a while loop, not mapfile. +# Depends: NO jq — the one field this hook reads (`stop_hook_active`) is matched +# with grep, so this backstop works exactly when the jq-dependent guards +# have failed open. `git` is required for the root and used for the prune +# list: without it the guard above fails open, and a failing `git +# ls-files`/`check-ignore` leaves the prune list empty, so the scan runs +# unpruned — slower, never blinder. `mktemp` is optional: it holds the +# scan's stderr, and when no scratch file can be created the walk still +# runs and says so rather than reporting a clean tree it could not have +# verified. No bash-4-only builtins (a stock macOS bash is 3.2): the +# directory list is read with a while loop, not mapfile. set -uo pipefail +# Drain stdin before any early exit, or a piping caller's SIGPIPE masks this hook's own +# exit code (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract). +input="$(cat)" + # The scan runs from the checkout's git top-level. CLAUDE_PROJECT_DIR is exported to the hook # process (https://code.claude.com/docs/en/hooks — the same page documents the placeholder); # when it is absent the process's own $PWD may be a subdirectory — the drift case — and a scan @@ -50,27 +70,77 @@ PROJECT_DIR="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> exit 0 } -input="$(cat)" # Absent field ⇒ first block. Present and true ⇒ the agent is already continuing from this hook. # Matched with grep, not jq, so fork detection has no environment dependency to fail open on. +# A here-string, never `printf … | grep`: a reader that exits on its first match makes +# pipefail read a real match as NO MATCH (cbk-conventions-reference.md § Hook authoring). active="" -printf '%s' "$input" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' && active="true" +grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' <<<"$input" && active="true" -cd "$PROJECT_DIR" || exit 0 +cd "$PROJECT_DIR" || { + echo "detect-forked-agent-memory: WARNING — cannot enter $PROJECT_DIR; fork detection inactive for this stop." >&2 + echo " Backstop: the verification block's Stop-hook check (cbk-conventions-reference.md § Verification)." >&2 + exit 0 +} # Every directory named agent-memory or agent-memory-local that is not the root's -# own (the `project` and `local` scopes fork identically). -prune stops -# descent into the root's own tree, worktrees (each is its own checkout with its -# own root tree), .git, and build output — add your stack's build directories to -# the list. No -mindepth: as first written, -mindepth 2 exempted depth-1 directories -# from the prune test, so a top-level node_modules was walked and its contents flagged -# (reproduced 2026-09-06; -mindepth 1 would not have, but the option buys nothing here). +# own (the `project` and `local` scopes fork identically). The walk prunes the root's +# own two trees, worktrees (each is its own checkout with its own root tree), .git, +# the hosted review action's staging copy (`./.claude-pr`), and every directory the +# project's own ignore rules exclude — `git ls-files --others --ignored +# --exclude-standard --directory` names them (build output, tool caches, vendored +# trees), so the skip list is .gitignore's, read at run time, never a hand-kept list +# of stack names (the hand-kept list missed a second project's tool cache on its first +# real application — #58 item 1). No -mindepth: as first written, -mindepth 2 +# exempted depth-1 directories from the prune test, so a top-level node_modules was +# walked and its contents flagged (reproduced 2026-09-06). A stray tree under an +# untracked but unignored directory is still found — that is the fork case. +# +# Three rules make an ignore-driven list safe (#58 item 1): +# 1. Never prune a path that could BE or CONTAIN the tree this hook hunts for. A +# project that ignores the memory directory by an unanchored name (`agent-memory/` +# — the natural spelling for the `local` scope) or ignores `.claude/` wholesale +# would otherwise have its own ignore rules hide the fork. +# 2. `-path` takes a glob, and `*`/`?` in it cross `/`. An ignored directory named +# `*` (a legal Unix name) would splice in as `-path './*'`, prune the first +# top-level entry the walk reaches, and report a clean tree with no stderr — +# the silent miss this hook exists to stop. Metacharacters are escaped so an +# entry prunes itself and nothing else. +# 3. `--directory` collapses a directory whose whole subtree is ignored into its +# topmost ancestor, and in a repo with nothing committed yet that ancestor can +# be `packages/` — a directory no ignore rule names. `git check-ignore` keeps +# only the paths the rules actually exclude, so a collapsed ancestor is walked. +prunes=() +while IFS= read -r d; do + d="${d%/}" + [ -n "$d" ] || continue + case "${d##*/}" in .claude|agent-memory|agent-memory-local) continue ;; esac + prunes+=(-o -path "./$(printf '%s' "$d" | sed 's/[][*?\\]/\\&/g')") +done < <( + git ls-files --others --ignored --exclude-standard --directory 2>/dev/null \ + | sed -n 's:/$::p' | git check-ignore --stdin 2>/dev/null || true +) +# find's stderr is kept, not discarded: an unreadable directory makes the scan partial, +# and a partial scan that reports "clean" is the silent miss this hook exists to stop +# (#58 item 2). A redirection to an uncreatable path would abort the walk before find +# ran, leaving forks empty and the tree reported clean — so the path is tested first +# and the walk degrades to unmonitored with a warning (#58, 2026-09-07 comment). +scan_err="$(mktemp 2>/dev/null || printf '%s/.detect-forked-agent-memory.%s.err' "${TMPDIR:-/tmp}" "$$")" +if ! : 2>/dev/null >"$scan_err"; then + echo "detect-forked-agent-memory: WARNING — no scratch file for the scan's stderr ($scan_err); the walk runs unmonitored, so a partial scan cannot be reported." >&2 + scan_err=/dev/null +fi forks=() while IFS= read -r d; do forks+=("$d"); done < <( - find . \( -path './.claude/agent-memory' -o -path './.claude/agent-memory-local' -o -path './.claude/worktrees' -o -name .git \ - -o -name node_modules -o -name target -o -name build -o -name _build \ - -o -name dist -o -name .venv \) -prune -o -type d \( -name agent-memory -o -name agent-memory-local \) -print 2>/dev/null | sort + find . \( -path './.claude/agent-memory' -o -path './.claude/agent-memory-local' -o -path './.claude/worktrees' -o -path './.claude-pr' -o -name .git \ + ${prunes[@]+"${prunes[@]}"} \) -prune -o -type d \( -name agent-memory -o -name agent-memory-local \) -print 2>"$scan_err" | sort ) +if [ -s "$scan_err" ]; then + echo "detect-forked-agent-memory: WARNING — the scan was partial; find could not read:" >&2 + head -n 5 "$scan_err" >&2 + echo " A stray tree under an unreadable directory is missed; fix the permissions and stop again." >&2 +fi +[ "$scan_err" = /dev/null ] || rm -f "$scan_err" [ "${#forks[@]}" -eq 0 ] && exit 0 @@ -90,5 +160,11 @@ $PROJECT_DIR/.claude/agent-memory-local// (pr-review.md § Reviewer precedent memory). Before stopping: move each / directory's files into the root tree, append their pointer lines to the root MEMORY.md for that reviewer, delete the forked tree, and say so in the hand-off. + +If the path above belongs to tooling rather than to a dispatched agent — a harness +staging copy, a container's workspace, a vendored checkout — it is not a fork and +nothing should be moved or deleted. Add it to .gitignore, anchored, and this scan +prunes it from then on (the review action's \`.claude-pr/\` staging copy is the +exercised case and is pruned unconditionally). MSG exit 2 diff --git a/.claude/rules/cbk-conventions-reference.md b/.claude/rules/cbk-conventions-reference.md index e3a20fa..dc7bea6 100644 --- a/.claude/rules/cbk-conventions-reference.md +++ b/.claude/rules/cbk-conventions-reference.md @@ -268,7 +268,7 @@ A `.gitignore` entry is **anchored by default** — `/build/`, `/.env`, `/target - **Any-depth entries are deliberate and marked.** An entry meant to match at every depth (`**/node_modules/`, `*.pyc`, `.DS_Store`) carries a one-line comment saying so; an unmarked unanchored entry is a defect. - **Per-language sections are scoped to the language's directory home** once one is declared — a Python section under `/services/api/` writes `/services/api/__pycache__/`, not `__pycache__/`. Before a home is declared, the section says which. - **A commit that changes `.gitignore` states its pin assertions in the body**: which path each new entry is meant to match, and one path it must *not* match. `git check-ignore -v ` is the test; the assertion is what makes a later reader able to re-run it. -- **Harness transients are ignored by anchored path** — the agent's scratch and memory-local trees (`/.claude/agent-memory-local/`, the session scratchpad if it is ever placed in-tree), never by a bare name that would also hide a real directory. +- **Harness transients are ignored by anchored path** — the agent's scratch and memory-local trees (`/.claude/agent-memory-local/`, the session scratchpad if it is ever placed in-tree) and a hosted review action's staging copy of the branch's tooling (`/.claude-pr/` — it carries a copy of the committed memory tree, which the Stop-tier fork detector prunes unconditionally and the ignore-driven prune covers once the entry exists; #58, 2026-09-07 comment), never by a bare name that would also hide a real directory. - **No shipped reviewer restates this.** The `cascade-rule-reviewer` names the section in scope; the rule lives here once. ## Methodology — choice space @@ -581,6 +581,9 @@ node .claude/workflows/tests/review-sweep-accounting.mjs || { echo "review-sweep node .claude/workflows/tests/finish-ab-shape.mjs || { echo "finish-ab.js does not parse or its panel guard regressed"; exit 1; } node .claude/workflows/tests/load-workflow-shape.mjs || { echo "load-workflow.mjs regressed"; exit 1; } bash .claude/workflows/tests/agent-cost-fixture.sh || { echo "agent-cost.py regressed on the fixture"; exit 1; } +# Every hook honours the stdin/exit contract (§ Hook authoring): structural checks over .claude/hooks/*.sh +# plus one over-buffer probe per decision site. Runs in throwaway trees; never touches this checkout. +bash .claude/workflows/tests/hook-contract-fixture.sh || { echo "a hook violates the stdin/exit contract (the fixture names it)"; exit 1; } # Every dispatch names its model and its effort (orchestration.md § The role ladder): agent definitions # carry both, except a model without the dial, which carries none (the frontmatter is read once per file). for a in .claude/agents/*.md; do fm=$(sed -n '2,/^---$/p' "$a"); grep -q '^model:' <<<"$fm" || { echo "$a names no model"; exit 1; }; if grep -q '^model: haiku' <<<"$fm"; then absent grep -n '^effort:' <<<"$fm"; else grep -q '^effort:' <<<"$fm" || { echo "$a names no effort (orchestration.md § The role ladder)"; exit 1; }; fi; done diff --git a/.claude/workflows/tests/hook-contract-fixture.sh b/.claude/workflows/tests/hook-contract-fixture.sh new file mode 100755 index 0000000..16c5ef5 --- /dev/null +++ b/.claude/workflows/tests/hook-contract-fixture.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Fixture for the kit's hooks: the two properties every hook must have regardless of what it +# guards (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract), asserted +# structurally over every .claude/hooks/*.sh, plus one over-buffer behavioural probe per +# decision site the kit ships. Runs against throwaway `git init` trees under mktemp, never the +# real checkout, and never depends on the directory it is launched from. Run by the +# verification block; also: bash .claude/workflows/tests/hook-contract-fixture.sh +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +hooks="$here/../../hooks" +MAINBRANCH="$hooks/protect-main-branch.sh" +PRSTATE="$hooks/guard-pr-state.sh" +STOP="$hooks/detect-forked-agent-memory.sh" +for h in "$MAINBRANCH" "$PRSTATE" "$STOP"; do [ -x "$h" ] || { echo "hook-contract-fixture: $h is missing or not executable"; exit 1; }; done +d=$(mktemp -d) +cleanup() { chmod -R u+rwX "$d" 2>/dev/null || true; rm -rf "$d"; } +trap cleanup EXIT + +RC=0; ERR=""; OUT="" +want() { [ "$RC" -eq "$1" ] || { echo "FAIL: $2 (want exit $1, got $RC)"; [ -n "$ERR" ] && printf ' stderr: %s\n' "$ERR"; exit 1; }; } +says() { grep -q -- "$1" <<<"$ERR" || { echo "FAIL: $2 (stderr does not carry '$1')"; printf ' stderr: %s\n' "$ERR"; exit 1; }; } + +# ── structural check 1: the drain is the first statement after `set -uo pipefail` ── +# Comment and blank lines are skipped; the first two statements must be exactly these. +want_drain_first="$(printf 'set -uo pipefail\ninput="$(cat)"')" +for h in "$hooks"/*.sh; do + sig="$(awk '/^[[:space:]]*(#|$)/ { next } n < 2 { print; n++ }' "$h")" + [ "$sig" = "$want_drain_first" ] || { + echo "FAIL: $(basename "$h") does not drain stdin as its first statement" + printf '%s\n' "$sig" | sed 's/^/ reads instead: /' + exit 1 + } +done + +# ── structural check 2: no hook decides on a pipeline whose reader can exit first ── +# A TRIPWIRE over known spellings (grep -q/-l/--quiet/--silent/-m N, head, read, sed …q, awk … exit +# after a `|`), not a proof; full-line comments are dropped (hooks quote the idiom while explaining +# it) and backslash/pipe continuations are joined first. A new hook still earns a behavioural case. +early_readers='[|][[:space:]]*(([{(]|timeout|xargs|env|stdbuf|while)[[:space:]]*[^|]*)?(grep[^|]*([[:space:]]-[A-Za-z]*[ql][A-Za-z]*([[:space:]]|$)|--quiet|--silent|--max-count|[[:space:]]-m[[:space:]]*[0-9])|head([[:space:]]|$)|read([[:space:]]|$)|sed[^|]*[[:space:]]['"'"'"]?[0-9$]*q|awk[^|]*exit)' +join_pipelines=' + { line = $0 + if (line ~ /^[[:space:]]*#/) line = "" + buf = buf line + if (buf ~ /[|\\][[:space:]]*$/) { sub(/\\[[:space:]]*$/, "", buf); next } + print NR ": " buf; buf = "" + } + END { if (buf != "") print NR ": " buf } +' +for h in "$hooks"/*.sh; do + hit="$(awk "$join_pipelines" "$h" | grep -E "$early_readers" || true)" + [ -z "$hit" ] || { + echo "FAIL: $(basename "$h") decides on a pipeline whose reader can exit first" + printf '%s\n' "$hit" | sed 's/^/ /' + exit 1 + } +done + +# ── behavioural probes: a body of many lines, past any plausible reader ceiling ── +manybody='line of a long body wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww' +while [ ${#manybody} -lt 250000 ]; do manybody="$manybody +$manybody"; done +cmdpay() { jq -Rs --arg cwd "${2:-}" '{tool_name:"Bash",tool_input:{command:.},cwd:$cwd}' "$1"; } + +# protect-main-branch is a HARD-DENY: a long commit body must not buy a bypass. +mainrepo="$d/mainrepo"; mkdir -p "$mainrepo" +git -c init.defaultBranch=main init -q "$mainrepo" +git -C "$mainrepo" -c user.email=f@x -c user.name=f commit -q --allow-empty -m init +printf "git commit -F - < "$d/cmd-commit" +cmdpay "$d/cmd-commit" "$mainrepo" > "$d/pay-commit" +RC=0; ERR="$("$MAINBRANCH" < "$d/pay-commit" 2>&1 >/dev/null)" || RC=$? +want 2 "a commit on main is denied however long the command string is" +says "BLOCKED" "the long-command denial names itself" + +# guard-pr-state is an ASK-GATE: its deliverable is the decision on STDOUT. +printf 'gh pr merge 5 --squash --body "%s"' "$manybody" > "$d/cmd-merge" +cmdpay "$d/cmd-merge" > "$d/pay-merge" +RC=0; ERR=""; OUT="$("$PRSTATE" < "$d/pay-merge" 2>/dev/null)" || RC=$? +want 0 "the PR-state ask-gate still exits 0 on a long command" +grep -q '"permissionDecision": "ask"' <<<"$OUT" || { echo "FAIL: gh pr merge with a long body did not raise the ask-gate"; exit 1; } + +# The detector reads one field the same way; the cost there is the loop guard: a payload whose +# field sits early with the bulk after it must still be read as a second stop. +repo="$d/repo"; mkdir -p "$repo"; git -c init.defaultBranch=main init -q "$repo" +mkdir -p "$repo/pkg/a/.claude/agent-memory/reviewer" +printf '%s' "$manybody" > "$d/padfile" +jq -Rs '{stop_hook_active:true,pad:.}' "$d/padfile" > "$d/pay-stop" +RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$STOP" < "$d/pay-stop" 2>&1 >/dev/null)" || RC=$? +want 0 "a second stop proceeds however long the payload is" +says "still present after one fix attempt" "and warns instead of blocking again" + +echo "hook-contract-fixture: ok" From 63a25ed0efdc362439e372bf73cd3e8d9ae47947 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:07:57 -0600 Subject: [PATCH 08/23] fix(workflows): agent-cost prices cache reads per tier; fixture rows for the top tier, a zero-line transcript, null minutes and --json without a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache hits and refreshes on the top tier are 0.025x base input, not the 0.1x the reader applied to every tier (platform.claude.com/docs/en/build- with-claude/prompt-caching § Pricing, read 2026-09-07), so every top-tier figure it produced overstated the cache-read component 4x. CACHE_READ maps the tier to its multiplier with 0.1x as the default; the fixture gains a top-tier transcript asserted at $10.25 (proven red at $11.00 when the map is emptied), a transcript with zero parseable lines, a single-event transcript whose minutes are null, and the --json-without-a-path exit. Closes #58 item 6 and the smaller-items test gaps. Co-Authored-By: Claude Fable 5.1 --- .claude/workflows/agent-cost.py | 7 +++++-- .claude/workflows/tests/agent-cost-fixture.sh | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.claude/workflows/agent-cost.py b/.claude/workflows/agent-cost.py index af56df1..0063ac2 100755 --- a/.claude/workflows/agent-cost.py +++ b/.claude/workflows/agent-cost.py @@ -11,7 +11,7 @@ PRICE below is list price per MTok as of 2026-09-05 (platform.claude.com/docs/en/about-claude/models/overview § Compare models, Pricing row — quoted in .claude/rules/orchestration-reference.md § Generation notes — the -sources, which the kit's verification block diffs against PRICE), with cache writes at 1.25x input (5-minute TTL) and cache reads at 0.1x input. Every cache write is priced at the +sources, which the kit's verification block diffs against PRICE), with cache writes at 1.25x input (5-minute TTL) and cache reads at 0.1x input except on the top tier, where cache hits and refreshes are 0.025x (CACHE_READ). Every cache write is priced at the 5-minute rate — the per-TTL breakdown inside `cache_creation` is not read. A 1-hour cache TTL prices writes at 2x; because the cache-write share differs by tier, that widens a write-heavy tier's ratio rather than cancelling out (on one measured run, 2026-09-01, it moved a pooled top-tier:workhorse ratio from 3.2x to @@ -29,6 +29,9 @@ 'sonnet': (2.0, 10.0), 'haiku': (1.0, 5.0), } +CACHE_READ = {'fable': 0.025} # cache hits and refreshes on the top tier are 0.025x base input +CACHE_READ_DEFAULT = 0.10 # every other tier: the standard 0.1x +# platform.claude.com/docs/en/build-with-claude/prompt-caching § Pricing, read 2026-09-07 (#58 item 6). def tier(model): @@ -88,7 +91,7 @@ def summarise(path): cost = None # one unpriced model leaves the whole row unpriced rather than partially counted break pi, po = PRICE[k] - cost += (t['inp'] * pi + t['cw'] * pi * 1.25 + t['cr'] * pi * 0.10 + t['out'] * po) / 1e6 + cost += (t['inp'] * pi + t['cw'] * pi * 1.25 + t['cr'] * pi * CACHE_READ.get(k, CACHE_READ_DEFAULT) + t['out'] * po) / 1e6 minutes = None if len(stamps) >= 2: minutes = round((parse_iso(max(stamps)) - parse_iso(min(stamps))).total_seconds() / 60, 1) diff --git a/.claude/workflows/tests/agent-cost-fixture.sh b/.claude/workflows/tests/agent-cost-fixture.sh index 0ba5f60..a923749 100755 --- a/.claude/workflows/tests/agent-cost-fixture.sh +++ b/.claude/workflows/tests/agent-cost-fixture.sh @@ -26,15 +26,26 @@ printf '%s\n' \ '{"type":"assistant","timestamp":"2026-09-06T00:01:00Z","message":{"model":"claude-opus-5","usage":{"input_tokens":1000000,"output_tokens":0}}}' \ '{"type":"assistant","timestamp":"2026-09-06T00:02:00Z","message":{"model":"claude-sonnet-5","usage":{"input_tokens":1000000,"output_tokens":0}}}' \ > "$d/agent-ddd.jsonl" +printf '%s\n' \ + '{"type":"user","timestamp":"2026-09-06T00:00:00Z","message":{"content":"arm five prompt"}}' \ + '{"type":"assistant","timestamp":"2026-09-06T00:03:00Z","message":{"model":"claude-fable-5-1","usage":{"input_tokens":1000000,"output_tokens":0,"cache_read_input_tokens":1000000}}}' \ + > "$d/agent-eee.jsonl" +printf '%s\n' '{"type":"assistant","timestamp":"2026-09-06T00:0' > "$d/agent-fff.jsonl" +printf '%s\n' \ + '{"type":"assistant","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":1000,"output_tokens":0}}}' \ + > "$d/agent-ggg.jsonl" out=$(python3 "$script" "$d" --json "$d/out.json") grep -qE '^aaa\b.*\b14\.25\b' <<<"$out" || { echo "expected aaa at \$14.25 (1M in @ \$5 + 1M cache write @ 1.25x + 1M cache read @ 0.1x + 100k out @ \$25); got:"; echo "$out"; exit 1; } grep -qE '^ddd\b.*claude-opus-5,claude-sonnet-5.*\b7\.00\b' <<<"$out" || { echo "expected the mixed transcript priced per model at \$7.00 with both models named; got:"; echo "$out"; exit 1; } -grep -q 'total list-price cost: \$21.25' <<<"$out" || { echo "expected a \$21.25 total; got:"; echo "$out"; exit 1; } -grep -q '2 of 4 agents priced' <<<"$out" || { echo "the priced/unpriced split is not printed"; echo "$out"; exit 1; } +grep -qE '^eee\b.*\b10\.25\b' <<<"$out" || { echo "expected eee at \$10.25 (1M in @ \$10 + 1M cache read @ 0.025x on the top tier — not \$11.00 at the 0.1x default); got:"; echo "$out"; exit 1; } +grep -q 'total list-price cost: \$31.50' <<<"$out" || { echo "expected a \$31.50 total (14.25 + 7.00 + 10.25 + 0.00 haiku); got:"; echo "$out"; exit 1; } +grep -q '4 of 7 agents priced' <<<"$out" || { echo "the priced/unpriced split is not printed (aaa, ddd, eee, ggg priced; bbb, ccc, fff not)"; echo "$out"; exit 1; } grep -q '"agent": "bbb"' "$d/out.json" && grep -q '"cost_usd": null' "$d/out.json" || { echo "--json did not write the unpriced row with a null cost"; cat "$d/out.json"; exit 1; } -grep -q 'unpriced.*bbb, ccc' <<<"$out" || { echo "the unpriced agents are not named (unknown model; no usage events)"; echo "$out"; exit 1; } -grep -q 'unparsable lines skipped.*ccc (1)' <<<"$out" || { echo "the truncated transcript is not named with its skipped-line count"; echo "$out"; exit 1; } +grep -q 'unpriced.*bbb, ccc, fff' <<<"$out" || { echo "the unpriced agents are not named (unknown model; no usage events)"; echo "$out"; exit 1; } +grep -q 'unparsable lines skipped.*ccc (1), fff (1)' <<<"$out" || { echo "the truncated transcript is not named with its skipped-line count"; echo "$out"; exit 1; } +grep -q '"agent": "ggg"' "$d/out.json" && grep -q '"minutes": null' "$d/out.json" || { echo "a transcript with fewer than two timestamps must report minutes as null, not crash or zero"; cat "$d/out.json"; exit 1; } assert_exit() { local want=$1 desc=$2; shift 2; local rc=0; python3 "$script" "$@" >/dev/null 2>&1 || rc=$?; [ "$rc" -eq "$want" ] || { echo "$desc (got $rc)"; exit 1; }; } assert_exit 2 "no arguments should exit 2" assert_exit 1 "an empty directory should exit 1" "$e" +assert_exit 2 "--json without a path should exit 2" "$d" --json echo "agent-cost-fixture: ok" From 822fdc644fdc71d90efaec538ccf11fea6c3c2af Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:08:36 -0600 Subject: [PATCH 09/23] fix(workflows): review-sweep matches hints on a directory boundary, degrades on a throwing roster read, reindexes retries by index; the harness stops swallowing its own exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A domain reviewer's path hint is a directory prefix: `src/schema` no longer claims `src/schema-extra/x` (a path equal to the hint still matches). The roster read — the one agent() call outside a parallel() thunk — takes `.catch(() => null)`, so a throw (a budget ceiling) degrades into the existing "roster read failed" branch with its gate line instead of ending the run with nothing. The retry pass reindexes by index list, the idiom finish-ab.js already uses. The stub harness's parallel() no longer swallows exceptions from its own mocks; three scenarios cover the boundary (both directions), the throwing roster, and three converging reporters under the least-loaded-owner bound. Both fixes proven red by mutation. Closes #58 items 7, 8 and 10 (the review-sweep half) and smaller item 6's idiom. Co-Authored-By: Claude Fable 5.1 --- .claude/workflows/review-sweep.js | 16 ++++--- .../tests/review-sweep-accounting.mjs | 45 ++++++++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.claude/workflows/review-sweep.js b/.claude/workflows/review-sweep.js index 83980eb..7db9a5a 100644 --- a/.claude/workflows/review-sweep.js +++ b/.claude/workflows/review-sweep.js @@ -113,7 +113,7 @@ Split them by the section's own dispatch rule: reviewers it says run UNCONDITION If the section is ambiguous, missing, or names a reviewer whose agent file does not exist under .claude/agents/, say so in "note" rather than guessing.`, { label: "roster:read-rule-file", phase: "Roster", model: "haiku", schema: ROSTER_SCHEMA }, // haiku: no effort dial — orchestration.md § Generation notes - ); + ).catch(() => null); // a throw (a budget ceiling — orchestration.md § Fan-out discipline) degrades exactly like a null read below; every other agent() here sits inside a parallel() thunk, which the runtime catches (#58 item 8) if (roster === null || !Array.isArray(roster.crossCutting) || !Array.isArray(roster.domain)) { log("review-sweep: DEGRADED — the reviewer roster could not be read from pr-review.md (null or malformed); running the toolkit dimensions only. Project-reviewer coverage is DROPPED for this run: record it on the sweep's `## Review gate` line and dispatch the project reviewers directly."); @@ -138,7 +138,10 @@ If the section is ambiguous, missing, or names a reviewer whose agent file does const usable = domain.filter((d) => !unusable.includes(d)); // Hints are directory prefixes (pr-review.md § Reviewer craft rules), so the match is anchored // at the path's start — a substring match would let src/schema/ claim test/src/schema/x. - const matched = usable.filter((d) => files.some((f) => d.pathHints.some((h) => f.startsWith(h)))).map((d) => d.name); + // Directory-boundary safe: `src/schema` must not claim `src/schema-extra/x` (#58 item 7). A hint + // is a directory prefix, never a segment anchor — enumerate directories rather than reaching for + // a regex the roster line cannot carry. + const matched = usable.filter((d) => files.some((f) => d.pathHints.some((h) => f === h || f.startsWith(h + "/")))).map((d) => d.name); const skipped = usable.filter((d) => !matched.includes(d.name)).map((d) => d.name); if (skipped.length) { log(`review-sweep: domain reviewers not path-matched by this diff: ${skipped.join(", ")}`); @@ -178,10 +181,11 @@ const firstPass = await parallel(dimensions.map((dim) => () => findOnce(dim))); // clean verdict ({findings: []}) is a valid result, not a failure. The retry // pass escalates effort once — "did it not try hard enough?" is the question // effort answers (orchestration.md § The effort axis). -const failedFirst = dimensions.filter((_d, i) => firstPass[i] === null); -if (failedFirst.length) log(`review-sweep: ${failedFirst.length} find agent(s) returned nothing — retrying once at effort ${RETRY_EFFORT}: ${failedFirst.map((d) => d.key).join(", ")}`); -const retried = await parallel(failedFirst.map((dim) => () => findOnce(dim, RETRY_EFFORT, true))); -const results = dimensions.map((dim, i) => firstPass[i] ?? retried[failedFirst.indexOf(dim)] ?? null); +const failedIdx = dimensions.map((_d, i) => i).filter((i) => firstPass[i] === null); +if (failedIdx.length) log(`review-sweep: ${failedIdx.length} find agent(s) returned nothing — retrying once at effort ${RETRY_EFFORT}: ${failedIdx.map((i) => dimensions[i].key).join(", ")}`); +const retried = await parallel(failedIdx.map((i) => () => findOnce(dimensions[i], RETRY_EFFORT, true))); +const results = firstPass.slice(); +failedIdx.forEach((i, k) => { results[i] = retried[k] ?? null; }); // the index-list reindex, shared with finish-ab.js (#58 smaller item 6) const failedDimensions = dimensions.filter((_d, i) => results[i] === null).map((d) => d.key); if (failedDimensions.length > 0) { diff --git a/.claude/workflows/tests/review-sweep-accounting.mjs b/.claude/workflows/tests/review-sweep-accounting.mjs index 7727834..c201040 100644 --- a/.claude/workflows/tests/review-sweep-accounting.mjs +++ b/.claude/workflows/tests/review-sweep-accounting.mjs @@ -35,7 +35,9 @@ async function scenario(name, { args, roster, findings, verdict }) { if (label.startsWith("verify:")) return verdict(label.slice(7), opts); throw new Error(`unexpected label ${label}`); }; - const parallel = async (thunks) => Promise.all(thunks.map(async (t) => { try { return await t(); } catch { return null; } })); + // A thunk that THROWS is a bug in this harness's own mock and must fail the test; the runtime + // resolves a failed agent to null without throwing, so null is modelled by returning null (#58 item 10). + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); const out = await run(args, agent, parallel, (m) => logs.push(m), () => {}); const buckets = [...out.confirmed, ...out.refuted, ...out.unverified].map((f) => `${f.file}:${f.line}:${f.title}`); assert.equal(new Set(buckets).size, buckets.length, `${name}: a finding landed in two buckets`); @@ -344,4 +346,45 @@ for (const roster of [null, { crossCutting: "not-an-array" }]) { n++; } +// 14 — a hint matches on a directory boundary, never on a common prefix. +{ + const { out } = await scenario("boundary-safe hint", { + args: { files: ["src/schema-extra/a.ts"] }, + roster: rosterOK, findings: {}, verdict: () => null, + }); + assert.ok(!out.reviewers.includes("schema-reviewer"), "src/schema must not claim src/schema-extra/"); + const { out: exact } = await scenario("boundary-safe hint (exact)", { args: { files: ["src/schema"] }, roster: rosterOK, findings: {}, verdict: () => null }); + assert.ok(exact.reviewers.includes("schema-reviewer"), "a changed path equal to the hint matches"); + n += 2; +} + +// 15 — a roster read that THROWS degrades like a null read: gate line, dropped coverage, no crash. +{ + const logs = []; + const agent = async (_prompt, opts = {}) => { if ((opts.label ?? "").startsWith("roster:")) throw new Error("budget ceiling"); return (opts.label ?? "").startsWith("verify:") ? null : { findings: [] }; }; + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); + const out = await run({ files: ["a"] }, agent, parallel, (m) => logs.push(m), () => {}); + assert.deepEqual(out.reviewers, []); + assert.ok(out.droppedCoverage.some((d) => d.includes("roster read failed")), "a throwing roster read is dropped coverage, not an aborted run"); + assert.ok(typeof out.gateLine === "string" && out.gateLine.length > 0, "the run still returns its gate line"); + n++; +} + +// 16 — the least-loaded-owner bound with THREE converging reporters charges the idle one. +{ + const three = { crossCutting: ["adr-conformance-reviewer", "cascade-rule-reviewer"], domain: [], note: "" }; + const { out } = await scenario("three converging reporters", { + args: { files: ["a"], maxPerDimension: 1, maxVerify: 8 }, roster: three, + findings: { + "code-review": { findings: [F("a", 1, "cr only", "high"), F("a", 5, "shared", "low")] }, + "adr-conformance-reviewer": { findings: [F("a", 2, "adr only", "high"), F("a", 5, "shared", "low")] }, + "cascade-rule-reviewer": { findings: [F("a", 5, "shared", "low")] }, + }, + verdict: real, + }); + assert.ok(out.confirmed.some((f) => f.line === 5), "the three-way shared finding is charged to the reporter with no other finding and verified"); + assert.ok(out.confirmed.some((f) => f.title === "cr only") && out.confirmed.some((f) => f.title === "adr only"), "neither loaded reporter loses its own unique finding to the shared one"); + n++; +} + console.log(`review-sweep accounting: meta + body parse, ${n} scenarios OK`); From 79d6c2f481b0d1e3baa4016e86f7d0b81d20617d Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:09:28 -0600 Subject: [PATCH 10/23] fix(workflows): finish-ab refuses duplicate arm labels before dispatch, guards hallucinations at both sites, reads effort once; harness scenarios for unattributed flags `arm` labels are checked distinct beside `anon` (otherFile() picks the other arm by label, so two arms labelled A threw inside the prompt builder after paying for the dispatch); both `hallucinations` dereferences take `?? []`, since a well-formed ranking can arrive without that field; `j.effort ?? "high"` is evaluated once. The stub harness's parallel() no longer swallows exceptions from its own mocks; three scenarios cover the label check (no dispatch first), the missing field (zero flags, no crash) and stray entries naming no arm (counted as unattributed, for neither arm, and logged). Both fixes proven red by mutation. Closes #58 item 9, item 10's finish-ab half, and the smaller items' unattributedFlags gap. Co-Authored-By: Claude Fable 5.1 --- .claude/workflows/finish-ab/finish-ab.js | 8 +++--- .claude/workflows/tests/finish-ab-shape.mjs | 29 +++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.claude/workflows/finish-ab/finish-ab.js b/.claude/workflows/finish-ab/finish-ab.js index 0ceffe1..fc35f14 100644 --- a/.claude/workflows/finish-ab/finish-ab.js +++ b/.claude/workflows/finish-ab/finish-ab.js @@ -36,6 +36,8 @@ if (!Array.isArray(args.arms) || args.arms.length !== 2) throw new Error("finish args.arms.forEach((c) => { for (const k of ["arm", "anon", "read"]) if (!c || !c[k]) throw new Error(`finish-ab: every arm needs arm, anon and read (got ${JSON.stringify(c)})`) }) const ids = args.arms.map((c) => c.anon) if (new Set(ids).size !== ids.length) throw new Error(`finish-ab: anon ids must be distinct (got ${ids.join(", ")})`) +const armLabels = args.arms.map((c) => c.arm) +if (new Set(armLabels).size !== armLabels.length) throw new Error(`finish-ab: arm labels must be distinct — otherFile() picks the OTHER arm by label (got ${armLabels.join(", ")})`) const judges = Array.isArray(args.judges) ? args.judges : [] if (judges.length === 0 || judges.length % 2 !== 0) throw new Error(`finish-ab: a two-arm panel needs an even number of judges, half per reading order (got ${judges.length})`) judges.forEach((j, i) => { if (!j || !Array.isArray(j.order) || j.order.length !== ids.length || !ids.every((id) => j.order.includes(id))) throw new Error(`finish-ab: judge ${i + 1}'s order must be a permutation of the arm ids ${ids.join(", ")} (got ${JSON.stringify(j && j.order)})`) }) @@ -129,7 +131,7 @@ const byAnon = {} done.forEach((d) => { byAnon[d.anon] = d.result }) const describe = (id) => `${id}: worktree ${byAnon[id].worktree}, branch ${byAnon[id].branch}` const judgePrompt = (j, i) => `You are judge ${i + 1} of ${judges.length}. Read the rubric at ${RUBRIC} and apply it exactly. The two arms are, in the order you must read them: ${j.order.map(describe).join("; ")}. Read the operator brief at ${BRIEF} too, so you know what both arms were told. Verify against the sources the rubric names before scoring. Write your full report to ${S}/judges/judge-${i + 1}.md and return the structured result with one scores entry per arm named ${j.order.join(" and ")}. Do not edit any file in either worktree or in the repository.` -const judgeOpts = (j, i, retry) => ({ label: `judge:${i + 1}@${j.effort ?? "high"}${retry ? ":retry" : ""}`, phase: "Judge", model: MODEL, effort: j.effort ?? "high", agentType: "general-purpose", schema: JUDGE_SCHEMA }) +const judgeOpts = (j, i, retry) => { const effort = j.effort ?? "high"; return { label: `judge:${i + 1}@${effort}${retry ? ":retry" : ""}`, phase: "Judge", model: MODEL, effort, agentType: "general-purpose", schema: JUDGE_SCHEMA } } const judged = await parallel(judges.map((j, i) => () => agent(judgePrompt(j, i), judgeOpts(j, i, false)))) // A judge that returned nothing is retried once at the same order and effort — the retry pass, not the @@ -156,9 +158,9 @@ const ranks = {} const flags = {} ids.forEach((id) => { ranks[id] = jok.map((j) => j.ranking.indexOf(id) + 1) - flags[id] = jok.reduce((n, j) => n + j.hallucinations.filter((h) => h.arm === id).length, 0) + flags[id] = jok.reduce((n, j) => n + (j.hallucinations ?? []).filter((h) => h.arm === id).length, 0) // a well-formed ranking with no hallucinations field counts zero, never crashes (#58 item 9) }) -const unattributed = jok.reduce((n, j) => n + j.hallucinations.filter((h) => !ids.includes(h.arm)).length, 0) +const unattributed = jok.reduce((n, j) => n + (j.hallucinations ?? []).filter((h) => !ids.includes(h.arm)).length, 0) if (unattributed) log(`finish-ab: ${unattributed} contradicted-claim entr${unattributed === 1 ? "y" : "ies"} name no arm id and count for neither arm`) log(`finish-ab: ranks: ${ids.map((id) => `${id}: ${ranks[id].join("/")}, flags ${flags[id]}`).join(" | ")}`) return { arms: done, droppedArms: [], judges: jok, ranks, flags, droppedJudges: dropped, unattributedFlags: unattributed, panel: { planned: judges.length, returned: jok.length, byOrder: Object.fromEntries(survivingOrders) }, plannedAgents } diff --git a/.claude/workflows/tests/finish-ab-shape.mjs b/.claude/workflows/tests/finish-ab-shape.mjs index c9c6f90..2585a56 100644 --- a/.claude/workflows/tests/finish-ab-shape.mjs +++ b/.claude/workflows/tests/finish-ab-shape.mjs @@ -16,7 +16,7 @@ async function run(args, { armResult, judgeResult }) { calls.push({ prompt, opts }); return opts.phase === "Execute" ? armResult(opts, prompt) : judgeResult(opts, prompt); }; - const parallel = async (thunks) => Promise.all(thunks.map((t) => t().catch(() => null))); + const parallel = async (thunks) => Promise.all(thunks.map((t) => t())); // a throwing mock is a harness bug, not a "null agent" (#58 item 10) const result = await runWorkflow(args, agent, parallel, (m) => logs.push(String(m)), () => {}); return { result, logs, calls }; } @@ -133,6 +133,31 @@ const judgeOk = (opts) => { check(Array.isArray(result.droppedJudges) && result.droppedJudges.length === 1, "droppedJudges is returned"); } +// 11. Duplicate arm labels are refused before any dispatch (otherFile() picks the other arm by label). +{ + let err = null; let dispatched = 0; + const count = (opts) => { dispatched += 1; return armOk(opts); }; + try { await run({ ...base, arms: [arms[0], { ...arms[1], arm: "A" }] }, { armResult: count, judgeResult: judgeOk }); } catch (e) { err = e; } + check(err && /arm labels must be distinct/.test(err.message), "duplicate arm labels throw"); + check(dispatched === 0, "and nothing was dispatched first"); +} + +// 12. A well-formed judge with no `hallucinations` field counts zero flags instead of crashing. +{ + const noHall = (opts, prompt) => { const j = judgeOk(opts, prompt); delete j.hallucinations; return j; }; + const { result } = await run(base, { armResult: armOk, judgeResult: noHall }); + check(result.flags.P === 0 && result.flags.Q === 0, `missing hallucinations counts zero (got ${JSON.stringify(result.flags)})`); +} + +// 13. A contradicted claim naming no arm id is counted as unattributed, for neither arm. +{ + const stray = (opts, prompt) => { const j = judgeOk(opts, prompt); j.hallucinations = [{ arm: "Z", claim_verbatim: "x", contradicting_source: "y" }]; return j; }; + const { result, logs } = await run(base, { armResult: armOk, judgeResult: stray }); + check(result.unattributedFlags === 4, `four judges × one stray entry = 4 unattributed (got ${result.unattributedFlags})`); + check(result.flags.P === 0 && result.flags.Q === 0, "stray entries count for neither arm"); + check(logs.some((l) => /name no arm id/.test(l)), "the stray entries are logged"); +} + check(meta.name === "finish-ab" && Array.isArray(meta.phases) && meta.phases.length === 2, "meta literal is well-formed"); if (failures) { console.error(`finish-ab-shape: ${failures} failure(s)`); process.exit(1); } -console.log("finish-ab-shape: 10 scenarios ok"); +console.log("finish-ab-shape: 13 scenarios ok"); From 98bb5cbd76a8a782c6b2f85986926ff239c34be1 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:10:02 -0600 Subject: [PATCH 11/23] fix(finish): Step 1 admits the [:meta] title the kit's own templates emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor accepted three title forms while § Title-prefix scheme defines a fourth and the meta template emits it, so a tooling meta-issue failed the first precondition by construction. Step 1 now admits `[:meta] …` (with `` a workstream slug or the cascade/tooling lane's tag) and states the two routes a meta takes to the executor; the frame-inheritance line names a directly roughed-in meta beside the intake lanes. Applied identically to the bundled template; the pair is byte-parallel. Closes #58 smaller item 1. Co-Authored-By: Claude Fable 5.1 --- .claude/commands/finish.md | 4 ++-- .claude/skills/rough-in/references/finish-command.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/commands/finish.md b/.claude/commands/finish.md index 4ba83f7..3b6f3a0 100644 --- a/.claude/commands/finish.md +++ b/.claude/commands/finish.md @@ -11,14 +11,14 @@ This command is contract-first. On 2026-09-03 an A/B gave two workhorse-tier exe - **The planning backend**, resolved first from `docs/cbk/scaffold.md` § Cascade metadata (falling back to `.cascade/backends.toml`). On `github-issues`, `$1` is the issue number and the read is `gh issue view $1 --json number,title,body,labels,state,comments` (or the github MCP); on `linear`, `$1` is the planner ID and every read goes through the planner's MCP — never a GitHub read against a planner ID; on `in-repo-markdown` there is no `/finish` (design-doc mode; the scaffold gate disclosed it) — stop and say so. - **The issue** — its body and every comment. The body is the contract; comments are context (provenance, roll-forward notes from earlier runs, corrections the operator posted). -- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) has no frame; its provenance comment stands in. +- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) or a directly roughed-in meta (`[:meta]`) has no frame; its provenance comment stands in. - **Every decision record the issue cites** — the ADRs in `docs/adr/` (follow each `Refines:` chain) and any design-decision ledger the project keeps — in full for every clause cited. - `docs/ARCHITECTURE.md`, `docs/STANDARDS.md`, `CLAUDE.md`, and the rules that bind the diff: `.claude/rules/testing.md`, `.claude/rules/logging.md`, `.claude/rules/pr-review.md`, `.claude/rules/simplification.md`, `.claude/rules/cbk-conventions.md`; at triage, `.claude/rules/pr-review-reference.md` § Apply / Surface calibration and § Path-conditional aggressiveness — a triage is not a file read, so the path-scoped half does not load on its own. - **The repository itself**: the task-runner config, the package manifests and lockfiles, the trees the issue names, and the pinned sources of any package the issue's assumptions cite. Verify every claim you act on by reading or running; existence and absence claims are checked repo-wide from the root. Never assert a package API from memory. A knowledge-backend URL the body cites is resolved only through the configured MCP, with the announcement `.claude/rules/knowledge-backend.md` § HITL announcement discipline requires; without the MCP, surface and proceed. ## Preconditions — checked before anything is planned -1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …` or `[:enh] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams; the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. +1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …`, `[:enh] …` or `[:meta] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams or the cascade/tooling lane's tag (`cbk-conventions.md` § Contribution intake — a meta reaches this executor roughed-in directly or through its `[::R<#>]` children; the `cascade-depth:framed` label the meta template applies at framing is the pre-rough-in state, not this check's — #58, smaller item 1); the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. 2. **Dependencies.** Every identifier under `## Dependencies` is a closed issue whose state reason is *completed* (a Done-type state on linear, never Canceled or Duplicate). One open or otherwise-closed dependency stops the run: name it and its state, and do not offer to proceed anyway — a bypass is a deliberate rough-in revision, not a runtime patch. A cross-axis identifier (a planner ID on github-issues, a bare `#N` on linear) is surfaced, never bridged. 3. **Idempotency.** No open PR carries this issue's close marker (either family) and no branch exists for it. If either does, stop and ask whether to continue there or start fresh. 4. **Break-glass.** A `` marker in the body or the operator's instructions waives the `pr-review-toolkit:review-pr` half of the floor for this run and is recorded on its `## Review gate` line, in the PR body and the hand-off. `/simplify` is never waived; nothing else waives anything. diff --git a/.claude/skills/rough-in/references/finish-command.md b/.claude/skills/rough-in/references/finish-command.md index d3a4a5f..1eab521 100644 --- a/.claude/skills/rough-in/references/finish-command.md +++ b/.claude/skills/rough-in/references/finish-command.md @@ -94,14 +94,14 @@ This command is contract-first. On 2026-09-03 an A/B gave two workhorse-tier exe - **The planning backend**, resolved first from `docs/cbk/scaffold.md` § Cascade metadata (falling back to `.cascade/backends.toml`). On `github-issues`, `$1` is the issue number and the read is `gh issue view $1 --json number,title,body,labels,state,comments` (or the github MCP); on `linear`, `$1` is the planner ID and every read goes through the planner's MCP — never a GitHub read against a planner ID; on `in-repo-markdown` there is no `/finish` (design-doc mode; the scaffold gate disclosed it) — stop and say so. - **The issue** — its body and every comment. The body is the contract; comments are context (provenance, roll-forward notes from earlier runs, corrections the operator posted). -- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) has no frame; its provenance comment stands in. +- **The parent framing sub-issue** and its frame (`docs/cbk/frame-NN.md`): the milestone's criteria, § Interface Commitments, § Resolved during this framing, § Key Constraints. Rough-in inherited them; you honour them. An intake-lane issue (`[:bug]`, `[:enh]`) or a directly roughed-in meta (`[:meta]`) has no frame; its provenance comment stands in. - **Every decision record the issue cites** — the ADRs in `docs/adr/` (follow each `Refines:` chain) and any design-decision ledger the project keeps — in full for every clause cited. - `docs/ARCHITECTURE.md`, `docs/STANDARDS.md`, `CLAUDE.md`, and the rules that bind the diff: `.claude/rules/testing.md`, `.claude/rules/logging.md`, `.claude/rules/pr-review.md`, `.claude/rules/simplification.md`, `.claude/rules/cbk-conventions.md`; at triage, `.claude/rules/pr-review-reference.md` § Apply / Surface calibration and § Path-conditional aggressiveness — a triage is not a file read, so the path-scoped half does not load on its own. - **The repository itself**: the task-runner config, the package manifests and lockfiles, the trees the issue names, and the pinned sources of any package the issue's assumptions cite. Verify every claim you act on by reading or running; existence and absence claims are checked repo-wide from the root. Never assert a package API from memory. A knowledge-backend URL the body cites is resolved only through the configured MCP, with the announcement `.claude/rules/knowledge-backend.md` § HITL announcement discipline requires; without the MCP, surface and proceed. ## Preconditions — checked before anything is planned -1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …` or `[:enh] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams; the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. +1. **State and shape.** The issue is open — a closed one stops the run: say so and ask whether it was closed by mistake. The title is `[:F<#>:R<#>] …`, `[:bug] …`, `[:enh] …` or `[:meta] …` with `` a workstream locked in `docs/cbk/blueprint.md` § Workstreams or the cascade/tooling lane's tag (`cbk-conventions.md` § Contribution intake — a meta reaches this executor roughed-in directly or through its `[::R<#>]` children; the `cascade-depth:framed` label the meta template applies at framing is the pre-rough-in state, not this check's — #58, smaller item 1); the issue carries `cascade-depth:roughed-in`; and the body has exactly these eight headings, in this order: `## Context`, `## Assumptions` (present even when it reads `- None — …`), `## Implementation`, `## Acceptance criteria`, `## Test plan`, `## Done signal`, `## Dependencies`, `## PR contract`. Anything else is surfaced, never normalised away. 2. **Dependencies.** Every identifier under `## Dependencies` is a closed issue whose state reason is *completed* (a Done-type state on linear, never Canceled or Duplicate). One open or otherwise-closed dependency stops the run: name it and its state, and do not offer to proceed anyway — a bypass is a deliberate rough-in revision, not a runtime patch. A cross-axis identifier (a planner ID on github-issues, a bare `#N` on linear) is surfaced, never bridged. 3. **Idempotency.** No open PR carries this issue's close marker (either family) and no branch exists for it. If either does, stop and ask whether to continue there or start fresh. 4. **Break-glass.** A `` marker in the body or the operator's instructions waives the `pr-review-toolkit:review-pr` half of the floor for this run and is recorded on its `## Review gate` line, in the PR body and the hand-off. `/simplify` is never waived; nothing else waives anything. From a8fc48d050a6f891e4b5d5782eef9e7d072b1fe4 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:11:10 -0600 Subject: [PATCH 12/23] fix(adr): adr-new writes the index row in the index's own form; the relation list states no count and defines Promotes:; the reviewer cites corrections by the register's id form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real indexes contradicted the kit's pinned status-cell separator in two different ways, and adr-new would have written a third form into either. The row-writing step now reads the existing rows and matches their cell and separator; the starter index (and the kit's own, byte-identical) says a project's index sets its own form. § Refines vs Supersedes vs Extends lets the list be the count and defines the Promotes: slot it fills. The ADR reviewer cites a recorded correction by the register's own id form, not an assumed C- number. Closes #58's 2026-09-07 separator item and the second- application items 1 and 3. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/adr-conformance-reviewer.md | 2 +- .claude/skills/adr-new/SKILL.md | 5 +++-- .claude/skills/scaffold/references/adr-starters/README.md | 2 +- docs/adr/README.md | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.claude/agents/adr-conformance-reviewer.md b/.claude/agents/adr-conformance-reviewer.md index b6c05a0..7b37cb8 100644 --- a/.claude/agents/adr-conformance-reviewer.md +++ b/.claude/agents/adr-conformance-reviewer.md @@ -52,7 +52,7 @@ If only a branch name is given, run `git diff main...HEAD` (or against the named - **Be specific.** "Violates ADR-NNNN" is useless without "because the LLM is acting as a controller (line 42 calls `Servo.set_angle/2` directly) instead of emitting a tool call". - **Don't flag what isn't a violation.** ADRs don't govern every line; if a change is orthogonal, it's orthogonal. False positives erode trust in this reviewer. - **A literal ADR clause may be refined — confirm before flagging.** Before reporting "violates ADR-NNNN Dn", check that no `Refines:` child ADR or reconciliation-layer entry narrows that clause for the case at hand. A flag against a correctly-scoped clause is a false-positive, and false-positives erode trust faster than misses. -- **Consult `docs/adr/corrections.md` before flagging a claim.** A citation, figure, attribution or formula already recorded wrong there is cited by its `C-` number, never restated as a finding; a wrong claim not yet recorded is reported as one, with the register named as its destination. +- **Consult `docs/adr/corrections.md` before flagging a claim.** A citation, figure, attribution or formula already recorded wrong there is cited by the register's own id form (the starter numbers entries `C-NNN`; a register that predates it may number them in prose under per-ADR sections), never restated as a finding; a wrong claim not yet recorded is reported as one, with the register named as its destination. - **Don't propose new ADRs.** That's an `/adr-new` invocation, not a review finding. - **Don't review style, tests, or logging.** Other reviewers cover those. Stay scoped to ADR conformance. diff --git a/.claude/skills/adr-new/SKILL.md b/.claude/skills/adr-new/SKILL.md index 19ef9ce..807f2f5 100644 --- a/.claude/skills/adr-new/SKILL.md +++ b/.claude/skills/adr-new/SKILL.md @@ -48,7 +48,7 @@ When invoked, **propose** every input below from what the operator already said Then fill in: ADR number, title, status (`Accepted` for new decisions, `Proposed` if user wants HITL gate first), date (today, ISO format), deciders, related ADRs, the relation slots the inputs filled (`Supersedes:` / `Refines:` / `Extends:` / `Promotes:` — delete the unused lines), context, options considered, decision, consequences. 3. **Update `docs/adr/README.md` index** — the canonical surface. - - Add the row to the index table in number order; the status cell carries the grain and the parent inline (`Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`). + - Add the row to the index table in number order — **in the form the existing rows already use.** Read them first: which cell carries the relation grain and the parent (the kit's starter index puts it in the Status cell — `Accepted · Refines ADR-0007 (D2)` — while an index that predates the starter may carry it as a Title-cell parenthetical, with another separator, or in prose), and write the new row exactly that way. The separator is the index's convention, not this skill's; two real indexes already contradicted the pinned form two different ways (#58, 2026-09-07 comment). - If wholly superseding, mark the old ADR's status field in the index as `Superseded by ADR-${NNNN}` (don't edit the old ADR file itself; the index expresses supersession). A clause-scoped supersession annotates the parent's row (`Accepted · Dn superseded by ADR-${NNNN}`); a refine or extend leaves the parent's row as it was. 4. **Update every other index the conventions name.** `.claude/rules/cbk-conventions.md` § ADR index sync is the one home for the target list — read it now and walk it; this skill states no count. The blueprint's § Stack decisions table is append-only and gets a one-line bullet **only when the ADR changes a stack decision**; a decision that is not about the stack does not touch it. Where a project keeps an open-questions list on an index surface, an ADR that resolves one removes it there. @@ -70,10 +70,11 @@ When invoked, **propose** every input below from what the operator already said **See also** `docs/adr/corrections.md` — a wrong *claim* in an accepted ADR (a citation, a figure, an attribution, a formula) is none of these grains; it is an append-only register entry, and the ADR stays as written. -A new ADR connects to an existing one through one of two relationships. Both are recorded as header fields and both preserve the parent's immutability — neither ever edits the parent file. +A new ADR connects to an existing one through one of the relationships below (`Supersedes:`, `Refines:`, `Extends:`, and the clause-scoped form of the first); a `Promotes:` slot connects to a frozen corpus, not to an ADR. All are header fields and all preserve the parent's immutability — none ever edits the parent file. - **`Supersedes: ADR-NNNN`** — the new ADR *replaces* the parent's decision. The parent's status becomes `Superseded by ADR-MMMM`; new code follows the new ADR. This is the relationship the interactive **Supersedes?** input captures, and the one Step 3's index-marking handles. - **`Refines: ADR-NNNN (Dn, …)`** — the new ADR *clause-level-clarifies or narrows* a specific decision `Dn` in the parent **without invalidating it**. The parent stays `Accepted`; both parent and child are consulted when evaluating conformance. Use this when implementation reveals that an accepted clause was written too generally and needs a scoped reading (e.g. "this rule applies only to "), not a reversal. +- **`Promotes: `** — the decision is lifted from a frozen pre-cascade corpus (consultation's frozen-corpus ingestion; `cbk-conventions.md` § Multi-surface facts names the corpus + errata pair). The corpus entry stays as written and the ADR becomes the decision's record home; the slot is the back-pointer. Not a relation to another ADR, so it carries no grain. **Clause-scoped supersession.** Supersession can also target a single clause rather than a whole ADR: `Supersedes: ADR-NNNN Dn` reverses only decision `Dn` of the parent while the parent's other clauses stand. The parent's status stays `Accepted` (it is not wholly superseded); the child's index row names the specific clause it replaces. diff --git a/.claude/skills/scaffold/references/adr-starters/README.md b/.claude/skills/scaffold/references/adr-starters/README.md index c5fd6b2..43f2186 100644 --- a/.claude/skills/scaffold/references/adr-starters/README.md +++ b/.claude/skills/scaffold/references/adr-starters/README.md @@ -24,7 +24,7 @@ The `/adr-new` Claude Code skill (`.claude/skills/adr-new/SKILL.md`) automates t | `Deprecated` | No longer recommended, but still in effect; new code should not assume it | | `Superseded by ADR-NNNN` | Replaced by a newer decision | -The status cell carries the relation grain and the parent inline — `Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`, `Accepted · D3 superseded by ADR-0012` — so a clause-scoped supersession annotates the parent's row while the parent file stays untouched. +The status cell carries the relation grain and the parent inline — `Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`, `Accepted · D3 superseded by ADR-0012` — so a clause-scoped supersession annotates the parent's row while the parent file stays untouched. A project whose index predates this starter keeps its own form — the cell and separator its rows already use — and `adr-new` reads the rows before writing one. ## Numbering convention diff --git a/docs/adr/README.md b/docs/adr/README.md index c5fd6b2..43f2186 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,7 +24,7 @@ The `/adr-new` Claude Code skill (`.claude/skills/adr-new/SKILL.md`) automates t | `Deprecated` | No longer recommended, but still in effect; new code should not assume it | | `Superseded by ADR-NNNN` | Replaced by a newer decision | -The status cell carries the relation grain and the parent inline — `Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`, `Accepted · D3 superseded by ADR-0012` — so a clause-scoped supersession annotates the parent's row while the parent file stays untouched. +The status cell carries the relation grain and the parent inline — `Accepted · Refines ADR-0007 (D2)`, `Accepted · Extends ADR-0003 (D1)`, `Accepted · D3 superseded by ADR-0012` — so a clause-scoped supersession annotates the parent's row while the parent file stays untouched. A project whose index predates this starter keeps its own form — the cell and separator its rows already use — and `adr-new` reads the rows before writing one. ## Numbering convention From 1d810a62de65bbfaabb2517b272a3e3bedde4467 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:12:11 -0600 Subject: [PATCH 13/23] fix(kit): the duplicated citation, one marker spelling, the pre-flight row, stamped-state callouts, the cwd disagreement named, pubspec.lock and a *.lock fallback arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framing procedure's "See rough-in's the rough-in skill's" citation reads "See the rough-in skill's"; the spec template spells the measurement variant marker one way; the Quick-reference row that called a frame's pre-flight table append-only is retired (framing writes it, rough-in reads it, nothing appends — the mutation row names only ## Rough-in events); the logging and testing callouts describe the mechanism so they read true before and after stamping, and name the inline-tests stack; the bootstrap rows say so. The launch-root guard's Timing paragraph carries both readings of the payload cwd and names the disagreement as its re-verify trigger. The lock-file guard gains a pubspec.lock arm and a *.lock fallback with a generic remediation (proven: pubspec.lock 2, flake.lock 2, notes.md 0). Closes #58 item 3, smaller items 2–4, and the second-application items 2 and 4. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/protect-lock-files.sh | 13 ++++++++++++- .claude/hooks/require-repo-root-for-agents.sh | 9 ++++++++- .claude/rules/cbk-conventions.md | 1 - .claude/rules/logging.md | 2 +- .claude/rules/testing.md | 2 +- .claude/skills/framing/references/procedure.md | 2 +- .../references/templates/rough-in-spec-template.md | 2 +- .../references/bootstrap_checklist_template.md | 4 ++-- 8 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.claude/hooks/protect-lock-files.sh b/.claude/hooks/protect-lock-files.sh index fe5dff0..202cf92 100755 --- a/.claude/hooks/protect-lock-files.sh +++ b/.claude/hooks/protect-lock-files.sh @@ -57,7 +57,7 @@ rel="${file_path#"$PROJECT_DIR/"}" # Match the common lock file shapes anywhere in the repo. case "$(basename "$rel")" in - uv.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|Cargo.lock|Gemfile.lock|poetry.lock|composer.lock|mix.lock) + uv.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|Cargo.lock|Gemfile.lock|poetry.lock|composer.lock|mix.lock|pubspec.lock) cat >&2 <&2 <` that is denied means the +# page's reading now holds — update this paragraph (#58, S3). # Path: registered as ${CLAUDE_PROJECT_DIR}/.claude/hooks/… — handlers run # in the current directory (https://code.claude.com/docs/en/hooks), so # a bare relative path would not resolve from the very subdirectory diff --git a/.claude/rules/cbk-conventions.md b/.claude/rules/cbk-conventions.md index 66c95e9..9f8a679 100644 --- a/.claude/rules/cbk-conventions.md +++ b/.claude/rules/cbk-conventions.md @@ -231,7 +231,6 @@ Cascade events being append-only is structurally important: the cascade IS the a | Naming a branch | `/--` | | Closing an issue from a PR | `Closes -N` (Linear) or `Closes #N` (GitHub) in PR body | | Adding an ADR | `adr-new` skill (auto-syncs indexes) | -| Adding a `## Pre-flight checks` row to a frame | Append-only edit to the frame's `## Pre-flight checks` table | | Skipping CI on a docs-only commit | Append `[skip ci]` to commit message subject | | Mid-session gate trimming | See § HITL gate load-bearing heuristics | | Phase exit | Run the `## Phase exit checklist` from the relevant cascade skill | diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index 57a4980..44120f3 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -5,7 +5,7 @@ paths: # Logging Rules -> **Path-scoped — stamp the glob at install.** The `paths:` block above makes this rule load only when a matching file is read, not every session (`https://code.claude.com/docs/en/memory`). As shipped it carries a placeholder: replace `` with the project's source extension(s) — one entry per extension (e.g. `"**/*.py"`, `"**/*.ts"`). A placeholder glob matches nothing, so the rule would never load; the conventions' verification block flags an unfilled placeholder. +> **Path-scoped.** The `paths:` block above makes this rule load only when a matching file is read, not every session (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — one entry per source extension (e.g. `"**/*.py"`, `"**/*.ts"`); until stamped they are bracketed placeholders that match nothing, which the conventions' verification block flags. This callout describes the mechanism and stays as it reads after stamping. Operational rules for structured logging. Your `docs/STANDARDS.md` establishes the principle; this file is the implementation contract. When writing or modifying logging code, follow these rules — adapted to whatever logging stack your project uses. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 68d0bf0..2f0b201 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -8,7 +8,7 @@ paths: # Testing Rules -> **Path-scoped — stamp the globs at install.** This rule loads only when a test file or a test directory is read (`https://code.claude.com/docs/en/memory`). Replace `` with the project's test-file extension(s) and drop the directory patterns the project does not use. A placeholder glob matches nothing; the conventions' verification block flags it. Anything a test-writing session needs *before* it opens a test file — the project's test-side trace-tag form, for one — is restated here rather than left in a rule this session never loads. +> **Path-scoped.** This rule loads only when a test file or a test directory is read (`https://code.claude.com/docs/en/memory`). The globs are stamped at install — the project's test-file extension(s), and only the directory patterns it uses; until stamped they are placeholders the conventions' verification block flags. A stack whose unit tests live inline in source files (a `#[cfg(test)]` module) has no test-file extension: stamp the test directories alone and accept that inline modules reach this rule only through their directory. Anything a test-writing session needs *before* it opens a test file — the project's test-side trace-tag form, for one — is restated here rather than left in a rule this session never loads. This callout stays as it reads after stamping. Operational rules for tests. The principle (logic modules get real tests; boundary impls get conformance + mocks; tests are part of done) lives in your `docs/STANDARDS.md`; this file is the implementation contract — when, in what order, and to what shape. diff --git a/.claude/skills/framing/references/procedure.md b/.claude/skills/framing/references/procedure.md index 6dca25a..14d1d4e 100644 --- a/.claude/skills/framing/references/procedure.md +++ b/.claude/skills/framing/references/procedure.md @@ -104,7 +104,7 @@ The **rough issues** list for each milestone is the seed that rough-in's Step 4 - ✅ *"Define the Verifier trait with associated types for Input, Context, Error per IC-1 from this framing"* — intent, points at the IC that locks the shape - ❌ *"Create `crates/core-engine/src/verifier.rs` with `pub trait Verifier { type Input; type Context; type Error; fn verify(...) -> Result<(), Self::Error>; }`"* — prescription, inlines a signature rough-in should either derive from IC-1 or leave to plan mode -The intent phrasing matters because rough-in's Implementation sections (the thing handed to Claude Code plan mode) inherit their shape from framing's rough issues. If framing produces prescriptive rough issues, rough-in naturally inherits that prescriptive framing and produces over-specified Implementation sections. If framing produces intent-shaped rough issues, rough-in has room to apply its own eight-properties discipline without fighting against framing's seeds. See rough-in's the rough-in skill's `references/plan-mode-prompts.md` § Property 8 for the downstream discipline; framing's job is to set that discipline up for success by starting with intents. +The intent phrasing matters because rough-in's Implementation sections (the thing handed to Claude Code plan mode) inherit their shape from framing's rough issues. If framing produces prescriptive rough issues, rough-in naturally inherits that prescriptive framing and produces over-specified Implementation sections. If framing produces intent-shaped rough issues, rough-in has room to apply its own eight-properties discipline without fighting against framing's seeds. See the rough-in skill's `references/plan-mode-prompts.md` § Property 8 for the downstream discipline; framing's job is to set that discipline up for success by starting with intents. **Rough issue count per milestone**: the same "review-unit discipline" that rough-in applies to R-issues applies (transitively) to framing's rough issues. Rough-in will ultimately produce **2-6 R-issues per milestone** for Claude-Code-executed workflows (see rough-in's Step 4). Framing's rough-issue list should therefore land in roughly the same range — 2-6 intents per milestone is typical. If framing's draft has 8+ rough issues for a milestone, that's a signal the milestone is too big (split it) or framing is pre-decomposing work that rough-in will naturally collapse (merge adjacent intents). If framing's draft has 1 rough issue for a milestone, either the milestone is truly atomic (legitimate, note and proceed) or the milestone is too small (fold into a sibling). diff --git a/.claude/skills/rough-in/references/templates/rough-in-spec-template.md b/.claude/skills/rough-in/references/templates/rough-in-spec-template.md index 145f80a..4eb868a 100644 --- a/.claude/skills/rough-in/references/templates/rough-in-spec-template.md +++ b/.claude/skills/rough-in/references/templates/rough-in-spec-template.md @@ -253,7 +253,7 @@ with expected output, or a "the smoke test in CI passes" statement. `` ` marker (or an equivalent agreed marker). If present, Step 9's `pr-review-toolkit:review-pr` invocation is waived per `pr-review.md` § Break-glass override — the rest of Step 9 still runs, `/simplify` is never waived — and the skill's `## Review gate` line (Step 10) records the reason. The marker is read *here*, from a surface that exists at gate time — not from the PR body, which doesn't exist until Step 10. diff --git a/.claude/skills/rough-in/references/finish-procedure.md b/.claude/skills/rough-in/references/finish-procedure.md index ce096d0..aa19b92 100644 --- a/.claude/skills/rough-in/references/finish-procedure.md +++ b/.claude/skills/rough-in/references/finish-procedure.md @@ -30,7 +30,7 @@ The shape of a run: read and validate the issue (Steps 1–4), research it execu Verify: - The issue is **open**. If closed, stop and tell the user: *"Issue #$1 is already closed. If you want to re-execute it, either reopen the issue or run rough-in to create a new sub-sub-issue."* -- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, or an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake). In every case `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. +- The title matches one of the cascade issue formats: the standard rough-in `[:F:R] `, an intake-lane form — bug-lane `[:bug] ` or enhancement-lane `[:enh] ` (externally-sourced work shaped by `/intake` / `/enrich` that skips framing; see `.claude/rules/cbk-conventions.md` § Contribution intake) — or a roughed-in meta `[:meta] ` (a cascade / tooling gap that reaches the executor directly or through its `[::R<#>]` children; the contract's Step 1 names the same four forms). `` is one of the workstream slugs locked in this project's blueprint (`docs/cbk/blueprint.md` § Workstreams) — or, for a meta, the cascade / tooling lane's tag (§ Contribution intake). If the title matches none of these, stop and ask whether this is actually a cascade issue or a different kind that got routed here by mistake. - The labels include `cascade-depth:roughed-in`. If not, same question — confirm with the user before proceeding. **Break-glass check.** Note whether the issue body or the operator's instructions carry a `` marker (or an equivalent agreed marker). If present, Step 9's `pr-review-toolkit:review-pr` invocation is waived per `pr-review.md` § Break-glass override — the rest of Step 9 still runs, `/simplify` is never waived — and the skill's `## Review gate` line (Step 10) records the reason. The marker is read *here*, from a surface that exists at gate time — not from the PR body, which doesn't exist until Step 10. From 04a791e07456860d98472b2706ab209b55aa33c0 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:37:49 -0600 Subject: [PATCH 21/23] docs(plan): the ADVISORY_WIRED mutation recipe names its two preconditions and what the positive arm can show As written, Step 8's recipe tripped the kit sub-block's registry-naming check before the arm and expected a green project sub-block a copied kit tree cannot reach. Rewritten from a live run: name the hook in the mutation table first, read the positive arm as the message's absence, and add the double-registration direction (review sweep, test-coverage dimension). Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md b/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md index ae207cb..19c69be 100644 --- a/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md +++ b/docs/superpowers/plans/2026-09-21-harvest-4-applied-defects.md @@ -208,7 +208,7 @@ jobs: Run: `bash .claude/workflows/tests/run-verification-block.sh | tail -4; echo "exit=$?"` Expected: both sentinels, `exit=0`. -Run (mutation — a target that wired the formatter, undeclared): in a throwaway copy `cp -r . /tmp/kitcopy && cd /tmp/kitcopy && touch docs/cbk/scaffold.md 2>/dev/null || (mkdir -p docs/cbk && touch docs/cbk/scaffold.md)` then register `format-on-edit.sh` in `settings.json`'s `PostToolUse` with `jq`, run the runner, expect `advisory hook format-on-edit.sh is registered but not declared in ADVISORY_WIRED`; then export `ADVISORY_WIRED=format-on-edit.sh` and expect the project sub-block to pass that arm. `cd -; rm -rf /tmp/kitcopy`. +Run (mutation — a target that wired the formatter, undeclared), in a throwaway copy: `cp -r . /tmp/kitcopy && cd /tmp/kitcopy && mkdir -p docs/cbk && touch docs/cbk/scaffold.md` (the project sub-block is gated on that file); append `format-on-edit.sh` to `.claude/rules/cbk-conventions.md` (the kit sub-block's registry-naming check runs before the arm and fires on any registered hook the mutation table does not name); register `format-on-edit.sh` in `settings.json`'s `PostToolUse` with `jq`; run the runner and expect `advisory hook format-on-edit.sh is registered but not declared in ADVISORY_WIRED` as the failure line. Then `ADVISORY_WIRED=format-on-edit.sh bash .claude/workflows/tests/run-verification-block.sh` and expect that line GONE: the run continues past the arm into target-only checks a copied kit tree cannot satisfy (the stub-language literal the plans under `docs/superpowers/` quote, then the unfilled `paths:` placeholder) and exits 1 there — that is the arm passing; a green project sub-block is not reachable from a kit copy. Register the hook a second time and expect `advisory hook format-on-edit.sh is declared wired but registered 2 times`. `cd -; rm -rf /tmp/kitcopy`. (Recorded from a live run, 2026-09-21: as first written this step named neither precondition and expected the positive arm to "pass", which a kit copy cannot show — review sweep, test-coverage dimension.) - [ ] **Step 9: Commit** From fc8237a28417e5cbe70a3ec558b7d4d6ff10da9b Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:37:49 -0600 Subject: [PATCH 22/23] test(hooks): first-stop probes for the detector's prune rules and degrade path, and the lock-file arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three ignore-driven prune rules, the staging-copy prune and the scratch-file degrade path had no fixture: each removed shipped green (measured — every mutation exits 0 with empty stderr on a tree whose ignore rules name a directory literally called *). One first-stop probe against a tree that discriminates all of them, plus the degrade path under an unwritable TMPDIR; and the lock-file hook's named pubspec.lock arm, its *.lock fallback and a passing non-lock edit (review sweep, test-coverage dimension, two findings). Co-Authored-By: Claude Fable 5.1 --- .../workflows/tests/hook-contract-fixture.sh | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/.claude/workflows/tests/hook-contract-fixture.sh b/.claude/workflows/tests/hook-contract-fixture.sh index 16c5ef5..0f10126 100755 --- a/.claude/workflows/tests/hook-contract-fixture.sh +++ b/.claude/workflows/tests/hook-contract-fixture.sh @@ -1,17 +1,19 @@ #!/usr/bin/env bash # Fixture for the kit's hooks: the two properties every hook must have regardless of what it # guards (cbk-conventions-reference.md § Hook authoring › The stdin / exit contract), asserted -# structurally over every .claude/hooks/*.sh, plus one over-buffer behavioural probe per -# decision site the kit ships. Runs against throwaway `git init` trees under mktemp, never the -# real checkout, and never depends on the directory it is launched from. Run by the -# verification block; also: bash .claude/workflows/tests/hook-contract-fixture.sh +# structurally over every .claude/hooks/*.sh, plus behavioural probes: one over-buffer probe per +# decision site the kit ships, the fork detector's prune rules and degrade path, and the lock-file +# arms. Runs against throwaway `git init` trees under mktemp, never the real checkout, and never +# depends on the directory it is launched from. Run by the verification block; also: +# bash .claude/workflows/tests/hook-contract-fixture.sh set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) hooks="$here/../../hooks" MAINBRANCH="$hooks/protect-main-branch.sh" PRSTATE="$hooks/guard-pr-state.sh" STOP="$hooks/detect-forked-agent-memory.sh" -for h in "$MAINBRANCH" "$PRSTATE" "$STOP"; do [ -x "$h" ] || { echo "hook-contract-fixture: $h is missing or not executable"; exit 1; }; done +LOCKFILES="$hooks/protect-lock-files.sh" +for h in "$MAINBRANCH" "$PRSTATE" "$STOP" "$LOCKFILES"; do [ -x "$h" ] || { echo "hook-contract-fixture: $h is missing or not executable"; exit 1; }; done d=$(mktemp -d) cleanup() { chmod -R u+rwX "$d" 2>/dev/null || true; rm -rf "$d"; } trap cleanup EXIT @@ -88,4 +90,38 @@ RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$STOP" < "$d/pay-stop" 2>&1 >/dev/null) want 0 "a second stop proceeds however long the payload is" says "still present after one fix attempt" "and warns instead of blocking again" +# A first stop against the same tree exercises the prune's three safety rules and the review action's +# staging copy — branches a payload can reach, so they are asserted here, not only in a PR-body table. +# The tree adds: a memory tree under an ignored build directory (pruned — not a fork); the staging copy +# (pruned unconditionally); a directory literally named `*` that the ignore rules name (escaped, or +# `-path './*'` prunes the whole top level and the real fork is missed — rule 2); and the memory +# directory ignored by bare name (never pruned — rule 1; and its collapsed ancestors are filtered by +# check-ignore — rule 3). The real fork under pkg/a is named only when every rule holds: each rule +# removed exits 0 with empty stderr (measured 2026-09-21). +printf '/pkg/*/build/\n/\\*/\nagent-memory/\n' > "$repo/.gitignore" +mkdir -p "$repo/pkg/b/build/.claude/agent-memory/y" "$repo/.claude-pr/.claude/agent-memory/z" "$repo/*/.claude/agent-memory/w" +printf '{"stop_hook_active":false}' > "$d/pay-first" +RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$STOP" < "$d/pay-first" 2>&1 >/dev/null)" || RC=$? +want 2 "a fork outside the root blocks the first stop" +says " ./pkg/a/.claude/agent-memory" "the block names the forked tree" +! grep -q '^ \./pkg/b' <<<"$ERR" || { echo "FAIL: a tree under an ignored build directory was reported as a fork"; exit 1; } +! grep -q '^ \./\.claude-pr' <<<"$ERR" || { echo "FAIL: the review action's staging copy was reported as a fork"; exit 1; } +! grep -q '^ \./\*' <<<"$ERR" || { echo "FAIL: the ignored directory named * was reported as a fork"; exit 1; } +# No scratch file for the scan's stderr: the walk runs unmonitored and says so — never a clean report. +RC=0; ERR="$(TMPDIR=/nonexistent/x CLAUDE_PROJECT_DIR="$repo" "$STOP" < "$d/pay-first" 2>&1 >/dev/null)" || RC=$? +want 2 "an unwritable scratch path degrades the walk to unmonitored and the fork still blocks" +says "no scratch file" "and the degrade is named" + +# protect-lock-files is a HARD-DENY whose two arm shapes are on the diff: the named list (pubspec.lock +# is its newest entry, with its own remediation) and the *.lock fallback; a non-lock edit passes. +lockpay() { jq -n --arg f "$repo/$1" '{tool_name:"Edit",tool_input:{file_path:$f}}' > "$d/pay-lock"; } +lockpay pubspec.lock; RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$LOCKFILES" < "$d/pay-lock" 2>&1 >/dev/null)" || RC=$? +want 2 "an edit to pubspec.lock is denied by the named arm" +says "dart pub get" "with the dart remediation" +lockpay deps/foo.lock; RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$LOCKFILES" < "$d/pay-lock" 2>&1 >/dev/null)" || RC=$? +want 2 "an edit to an unlisted *.lock is denied by the fallback arm" +says "fallback arm" "and the arm names itself" +lockpay notes.md; RC=0; ERR="$(CLAUDE_PROJECT_DIR="$repo" "$LOCKFILES" < "$d/pay-lock" 2>&1 >/dev/null)" || RC=$? +want 0 "an edit to a non-lock file passes" + echo "hook-contract-fixture: ok" From 86b58d7597f953345d829bbf9756751cecbbb4f1 Mon Sep 17 00:00:00 2001 From: Justin Forth Date: Mon, 21 Sep 2026 20:37:49 -0600 Subject: [PATCH 23/23] test(workflows): the index-list reindex under two first-pass failures with mixed retry outcomes One failure at index 0 cannot tell results[i] from results[k]; two can. The review-sweep scenario pins the retried finding to its own dimension and the still-failing one to failedDimensions; the finish-ab scenario pins the retried verdict to its own judge and the panel at three. Both harnesses red under a judged[k]/results[k] misindex (review sweep, test-coverage dimension). Co-Authored-By: Claude Fable 5.1 --- .claude/workflows/tests/finish-ab-shape.mjs | 19 ++++++++++++++++- .../tests/review-sweep-accounting.mjs | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.claude/workflows/tests/finish-ab-shape.mjs b/.claude/workflows/tests/finish-ab-shape.mjs index 2585a56..d2dc37f 100644 --- a/.claude/workflows/tests/finish-ab-shape.mjs +++ b/.claude/workflows/tests/finish-ab-shape.mjs @@ -123,6 +123,23 @@ const judgeOk = (opts) => { check(result.panel && result.panel.returned === 4 && !logs.some((l) => /dropped judges/.test(l)), `a retried verdict counts (got panel ${JSON.stringify(result.panel)})`); } +// 8b. Two judges return nothing with mixed retry outcomes: the retried verdict lands on ITS judge index +// (the index-list reindex — judged[i] from retried[k]), the still-failing judge is dropped by name, +// and the panel counts three. One failure cannot tell i from k; two can. +{ + const seen = { 2: 0, 4: 0 }; + const { result, logs, calls } = await run(base, { armResult: armOk, judgeResult: (opts, prompt) => { + const m = opts.label.match(/^judge:(\d+)@\w+(:retry)?$/); const j = Number(m[1]); + if (j === 2) { seen[2] += 1; return null; } + if (j === 4) { seen[4] += 1; return m[2] ? judgeOk(opts, prompt) : null; } + return judgeOk(opts, prompt); + } }); + check(seen[2] === 2 && seen[4] === 2, `both failed judges are retried once (got ${JSON.stringify(seen)})`); + check(calls.some((c) => c.opts.label === "judge:4@xhigh:retry"), "the retry keeps judge 4's own order and effort"); + check(result.panel && result.panel.returned === 3, `the retried verdict counts on its own index and the failed one does not (got panel ${JSON.stringify(result.panel)})`); + check(logs.some((l) => /dropped judges .*judge 2 \(Q>P\): no result/.test(l)) && !logs.some((l) => /dropped judges .*judge 4/.test(l)), `judge 2 is dropped by name and judge 4 is not (got: ${logs.filter((l) => /dropped/.test(l)).join(" | ")})`); +} + // 5. A judge whose ranking omits an arm is dropped by name and its votes are not counted; the surviving // panel's order balance is re-checked and reported. { @@ -160,4 +177,4 @@ const judgeOk = (opts) => { check(meta.name === "finish-ab" && Array.isArray(meta.phases) && meta.phases.length === 2, "meta literal is well-formed"); if (failures) { console.error(`finish-ab-shape: ${failures} failure(s)`); process.exit(1); } -console.log("finish-ab-shape: 13 scenarios ok"); +console.log("finish-ab-shape: 14 scenarios ok"); diff --git a/.claude/workflows/tests/review-sweep-accounting.mjs b/.claude/workflows/tests/review-sweep-accounting.mjs index c201040..d345723 100644 --- a/.claude/workflows/tests/review-sweep-accounting.mjs +++ b/.claude/workflows/tests/review-sweep-accounting.mjs @@ -223,6 +223,27 @@ for (const roster of [null, { crossCutting: "not-an-array" }]) { n++; } +// 14b — two dimensions fail the first pass with mixed retry outcomes: the retried finding lands on ITS +// dimension (the index-list reindex — results[i] from retried[k]), the still-failing one is dropped by +// name, and nothing is misattributed. One failure cannot tell i from k; two can. +{ + const attempts = { "code-review": 0, "test-coverage": 0 }; + const { out } = await scenario("mixed retry outcomes", { + args: { files: ["a"] }, roster: rosterOK, + findings: { + "code-review": () => { attempts["code-review"] += 1; return null; }, + "test-coverage": () => { attempts["test-coverage"] += 1; return attempts["test-coverage"] === 1 ? null : { findings: [F("a", 2, "found on the second dimension's retry", "medium")] }; }, + }, + verdict: real, + }); + assert.deepEqual(attempts, { "code-review": 2, "test-coverage": 2 }); + const f = out.confirmed.find((x) => x.title === "found on the second dimension's retry"); + assert.ok(f, "the retried finding is counted"); + assert.equal(f.dimension, "test-coverage", `the retried finding is attributed to its own dimension (got ${f.dimension})`); + assert.deepEqual(out.failedDimensions, ["code-review"]); + n++; +} + // 15 — caller-supplied reviewers skip the roster agent; an explicit empty list means "no project reviewers". { const { out, logs } = await scenario("caller reviewers", { args: { files: ["a"], reviewers: ["custom-reviewer"] }, roster: null, findings: {}, verdict: () => null });