create-agent-rig 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +140 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +11 -3
  10. package/templates/agent-os/init/CLAUDE.md +11 -3
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
  23. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  24. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
  25. package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
  26. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
  27. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
  28. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  29. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  30. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  31. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  32. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  33. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  34. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  36. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  37. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
  38. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
  39. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  40. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  41. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  42. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  43. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  44. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  45. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  46. package/templates/agent-os/universal/.claude/settings.json +5 -1
  47. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  48. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
  49. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  50. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  51. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  52. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  53. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  54. package/templates/agent-os/universal/AGENTS.md +3 -1
  55. package/templates/agent-os/universal/CLAUDE.md +3 -1
  56. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  57. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  58. package/templates/agent-os/universal/layers.json +9 -0
  59. package/templates/hash-history.json +309 -49
  60. package/templates/release-ledger.json +9 -0
@@ -25,7 +25,7 @@ Selection never reads a tracker directly. It goes through
25
25
  ```bash
26
26
  node .claude/scripts/queue/index.mjs next # the item to take, and why the rest were skipped
27
27
  node .claude/scripts/queue/index.mjs next --json
28
- node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies
28
+ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, overtaken proposals
29
29
  ```
30
30
 
31
31
  - **`plan-md`** (default) — the Agent queue in `PLAN.md`. The only adapter that
@@ -53,13 +53,13 @@ state-vs-queue split exists to prevent.
53
53
  node .claude/scripts/preflight.mjs
54
54
  ```
55
55
 
56
- Three items are scripted (kill switch absent · local default branch matches the
57
- remote · the last deploy concluded successfully) and the script **prints the ones
58
- it did not check, every time**. Paste the block into the journal: a checklist that
56
+ Four items are scripted (kill switch absent · `RIG_RUN_DIR` not already
57
+ exported · local default branch matches the remote · the last deploy concluded
58
+ successfully) and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
59
59
  leaves no record cannot tell you it was skipped.
60
60
 
61
61
  Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
62
- knowing which ground is soft. **GO** → the scripted three are clean; the rest are
62
+ knowing which ground is soft. **GO** → the scripted four are clean; the rest are
63
63
  still yours.
64
64
 
65
65
  **An `unknown` never becomes a `pass`.** A probe that could not run tells you
@@ -87,6 +87,42 @@ export RIG_RUN_DIR="$PWD/.claude/runs/$(date +%Y%m%d-%H%M%S)" # one per run
87
87
  mkdir -p "$RIG_RUN_DIR"
88
88
  ```
89
89
 
90
+ ⚠ **That export reaches the commands THIS shell runs and nothing else.** A
91
+ `PreToolUse` hook is spawned by the harness with the harness's own environment,
92
+ never with a variable the session exported — pinned in the generator's
93
+ `test/template/guard-rulebook.test.ts` (absent in a generated rig) › "only a
94
+ flag arms it — an exported RIG_UNATTENDED=1 with no flag changes nothing" —
95
+ and in some harnesses the export does not even survive to the next Bash call,
96
+ which is why every command in this skill can also take the run directory per
97
+ invocation. What a hook CAN see is a file, so the unattended signal is one:
98
+
99
+ ```bash
100
+ # at claim time, from the paths the item names (repo-relative prefixes, with
101
+ # their trailing slash); the guard refuses every other rulebook edit while it is on
102
+ node .claude/scripts/unattended-flag.mjs on --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
103
+ ```
104
+
105
+ `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
106
+ on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under `.claude/hooks/`,
107
+ `.claude/settings.json`, `.claude/queue.json`, `.claude/scripts/queue/`, the
108
+ router, the gate sweep, `.claude/rules/` or `CLAUDE.md` is refused unless its
109
+ path starts with an allowed prefix; with no flag the guard does nothing. An
110
+ item that needs a rulebook path names it here — a decision made at claim
111
+ time, never a default — and the stop step below turns the flag off. Pinned in
112
+ the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
113
+ rig — › "blocks a hook-config edit with an empty allow-list, naming path, item and the rule".
114
+
115
+ ⚠ **The export outlives the run's own calls.** Everything the session spawns
116
+ inherits it — and a test suite that spawns the queue CLI would write fixture
117
+ records into this run's trace (AR-139: 38 fixture selections and 22 fixture
118
+ revalidation events in one session, two tests exiting 1). So preflight refuses
119
+ to start on a `RIG_RUN_DIR` already exported. The generator's own test harness
120
+ also scrubs the variable before any test file loads — its `test/setup-env.ts`,
121
+ pinned by its `test/template/rig-run-dir-scrub.test.ts` › "holds with the
122
+ variable exported around the whole vitest process" — and **neither file ships
123
+ into a generated rig**: here, nothing scrubs it, and a rig's own test setup is
124
+ the place to do the same.
125
+
90
126
  🔴 **One directory per run, never shared and never reused — and the journal
91
127
  cannot enforce this for you.** A collision or an already-ended directory is
92
128
  refused loudly; two runs whose records merely do not collide are merged into one
@@ -127,6 +163,60 @@ not deleting the old marker, and the silent resolution goes to the **less**
127
163
  restrictive gate. Until that is fixed, treat a double-marked item as
128
164
  human-gated by hand.
129
165
 
166
+ 🔴 **An item marked for another repository is held, never taken.** A label
167
+ `owner-<name>` names the repository an item belongs to; the checkout names
168
+ itself in `options.owner` of `.claude/queue.json`, and a mismatch — or an
169
+ owned item in a checkout that declares no owner — is the holding cause `owner`,
170
+ reported by `hygiene` as `owner-mismatch`. It clears the way `trigger-human`
171
+ does: a human moves the item or re-marks it. An unmarked item is unconditional.
172
+ Two items of another product once entered this queue as normal spacers and
173
+ escalated `PREMISE FALSE` back to back — a run-level stop spent on work that
174
+ was never this checkout's (AR-132). Pinned in the generator's
175
+ `test/template/queue-owner.test.ts` — absent in a generated rig — › "holds an
176
+ item whose owner is another repository, with the cause named".
177
+
178
+ 🔴 **An item's lifecycle is a label a human wrote, and the loop infers none of
179
+ it** (AR-144). Four words, read by `lifecycleOf` in `core.mjs` so every adapter
180
+ means the same thing — three of lifecycle, one of scheduling:
181
+
182
+ - `keep-core` — the problem and the responsibility are valid and the item is
183
+ executable as written. A statement about the item, never a condition on taking
184
+ it: a bare `keep-core` item is selected like an unmarked one.
185
+ - `re-scope` — the problem is valid but the item is **not executable
186
+ literally**: a path, a mechanism, a boundary or an acceptance criterion has
187
+ drifted. It is the holding cause `re-scope`, reported by `hygiene` as
188
+ `re-scope-pending` until it clears — and it clears **only** by a human
189
+ re-reading the code, rewriting the item and removing the label. It is a
190
+ short-lived quarantine, not a backlog category, and never a synonym for
191
+ obsolete, low priority or parked. The loop surfaces it and never rewrites it:
192
+ a re-aimed item is work the agent authored (§8).
193
+ - `obsolete` — the responsibility is gone or fully superseded by a proven
194
+ mechanism. A **human verdict, terminal**: the loop never applies it and never
195
+ closes an item because it believes another mechanism supersedes it. Closing as
196
+ obsolete needs external evidence and a comment naming the replacement or the
197
+ reason — so an obsolete item is out of play (the cause `obsolete`), not held,
198
+ and the stop line says so.
199
+ - `parked` — valid work deliberately not active now. The **scheduling** axis,
200
+ orthogonal to the three above: `keep-core + parked` is the ordinary shape of a
201
+ deferred item and means "still needed, not now". It is the holding cause
202
+ **`deferred`**, freed by a human un-parking it. The cause is not spelled
203
+ `parked` on purpose: §3 already uses that word for the **out-of-play pile** —
204
+ items waiting on a human that no session will take — and a parked-labelled
205
+ item is the opposite, held and takeable. So the stop line reads "held by
206
+ deferred" for the label, and "are parked" for the pile; `obsolete` lands in
207
+ the pile, the `parked` label never does.
208
+
209
+ The rule under all four: **nothing infers `obsolete`** — not age, not a key
210
+ range, old terminology, `parked`, absence from a roadmap, or a migration marker.
211
+ `legacy-backlog` is that marker, retired: `hygiene` reports an open item still
212
+ carrying it (`stale-legacy-backlog-label`), and two lifecycle labels on one item
213
+ (`contradictory-lifecycle-labels`) — selection meanwhile reads the most
214
+ restrictive one. Reported, never corrected: which label is wrong is the human's
215
+ call. Pinned in the generator's `test/template/queue-lifecycle.test.ts` — absent
216
+ in a generated rig — › "holds a re-scope item and says a human rewrites it" and
217
+ › "refuses an obsolete item and says a human closes it with a comment naming the
218
+ evidence".
219
+
130
220
  **For a `trigger-auto` item, record the declaration** — it has to outlive the
131
221
  turn it was made in, or the next selection holds the item back again:
132
222
 
@@ -168,6 +258,60 @@ and the work turns out to touch an elevated path (`CLAUDE.md` →
168
258
  `elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
169
259
  as this run's elevated item for spacing.
170
260
 
261
+ **Selection also revalidates the item against its last take-up.** `next`
262
+ records the selected item's `updatedAt` marker in the run state (`takeUps`) and,
263
+ when the item is offered again, compares the two — against this run's take-up
264
+ when it has one, otherwise against the newest earlier run's under `.claude/runs/`
265
+ (AR-138: before that, an item taken up yesterday compared against nothing and
266
+ read as a first sight). A proposal the loop files carries its own baseline: the
267
+ `jira` and `github-issues` adapters record the filed item's marker as a take-up
268
+ in the run that filed it. The event names which it used — `baseline:
269
+ this-run | previous-run | null` — pinned in the generator's
270
+ `test/template/revalidation-baseline.test.ts` — absent in a generated rig — ›
271
+ "holds when the marker moved past the earlier run’s take-up, and names that
272
+ baseline". A marker that
273
+ moved prints a `revalidate:` line and the JSON carries `revalidation.changed:
274
+ true`: **re-read the item before acting on it**, then record what the re-read
275
+ concluded — whether the change altered the action is the evidence this exists to
276
+ collect, and the comparison alone cannot supply it:
277
+
278
+ ```bash
279
+ node .claude/scripts/revalidate.mjs outcome --point SELECT --ticket <item-id> --action-changed <true | false> --note '<what changed, or why it changes nothing>'
280
+ ```
281
+
282
+ It appends one `revalidation-outcome` record whose `answers` names the
283
+ revalidation it resolves, so the report can pair the two without guessing. The
284
+ note is stored verbatim from argv, so keep it in single quotes: inside double
285
+ quotes the shell expands a backtick or a `$` before the command sees it.
286
+ Nothing forces this record — a `revalidation` event with no matching outcome is
287
+ counted as `unresolved`, which is the honest word for a re-read the run skipped.
288
+
289
+ Under a declared run directory, every selection logs one `revalidation` event
290
+ `{ticket, point: SELECT, changed, source, action, task}` — the same shape the
291
+ BEFORE_PR and BEFORE_CLOSE points write. **No-change is always recorded**, one
292
+ line per selection and no sampling: the rule is explicit so the report's
293
+ `opportunities` is a count and not an estimate. An adapter with no marker
294
+ (`plan-md`) logs `changed: null`, never "unchanged". ⚠ The marker also moves on
295
+ the run's own claim and comments. The `jira` and `github-issues` adapters
296
+ re-record the take-up after each write they make — claim, comment, close,
297
+ escalate — so a move made through the adapter is not a hold (AR-140, from the
298
+ journal's RX3/RX4 entry: every BEFORE_PR catch of that run was the run's own
299
+ comment, counted by `revalidation-report.mjs`); a
300
+ comment posted by any other route — a REST call by hand, a connector — still
301
+ moves it like anyone else's, and a `true` can still be self-inflicted that way
302
+ — the re-read decides, which is why the outcome is recorded separately, and a
303
+ hold the re-read overturns is counted as a false hold with its source named.
304
+ Pinned in the generator's `test/template/self-inflicted-marker.test.ts` — absent
305
+ in a generated rig — › "%s leaves the take-up at the marker the write produced",
306
+ an `it.each` over claim, comment, close and escalate. The
307
+ four-week view is `node .claude/scripts/revalidation-report.mjs --since <date>`,
308
+ over this rig's `.claude/runs/` (or a `--runs <dir>`). The behaviour is pinned in the
309
+ generator's `test/template/queue-revalidation.test.ts` — absent in a generated
310
+ rig — › "an adapter with no marker records a blind spot, not \"unchanged\"", ›
311
+ "a moved marker holds on task:updatedAt, re-snapshots, and journals the change"
312
+ and › "the loop skill's outcome command records what the re-read concluded", and
313
+ in `test/template/revalidation-evidence.test.ts`.
314
+
171
315
  **Then, before the Red step: `check-premises`.** The item was written by someone
172
316
  who was not reading the code at the time, and everything downstream — the failing
173
317
  test, the implementation, the reviewer comparing diff to item — inherits its
@@ -193,10 +337,50 @@ returns one fenced `json` verdict like every other gate, and this loop is its ca
193
337
  node .claude/scripts/verdict.mjs check <report> check-premises
194
338
  ```
195
339
 
340
+ **`<report>` is a file you write, not one the harness leaves behind.** The
341
+ subagent's answer arrives as text in the conversation; its transcript on disk is
342
+ a JSONL file whose last fenced block does not parse, so pointing the check at it
343
+ exits 1 whatever the reviewer said. Save the whole answer to a file under the run
344
+ directory — `$RIG_RUN_DIR/check-premises.md`, one file per gate so two answers
345
+ never overwrite each other — and pass that path (`-` reads stdin instead). The
346
+ same holds for every `<report>` in this skill, as `pr-ship` already does for its
347
+ reviewers (AR-117). Pinned in the generator's `test/template/loop-report-file.test.ts`
348
+ — absent in a generated rig — › "states that the report is a file the session
349
+ writes from the subagent answer, before the first check".
350
+
196
351
  Exit 1 means it did not answer: a stop verdict naming no premise, or no block at all.
197
352
  That is `incomplete` — neither "the premises hold" nor a reason to escalate — so run
198
353
  the pass again rather than reading the silence as a pass.
199
354
 
355
+ **Then journal the block that parsed**, exactly as `pr-ship` journals a reviewer's —
356
+ the paths a premise check names are what `revalidate.mjs` reads at BEFORE_PR as the
357
+ task's cited paths, and a verdict held only in context cites nothing after a
358
+ compaction:
359
+
360
+ ```sh
361
+ node --input-type=module -e '
362
+ const runDir = process.env.RIG_RUN_DIR;
363
+ if (!runDir) process.exit(0); // an undeclared run has no trace to write
364
+ if (!process.argv[1]) process.exit(0); // `check` printed nothing: nothing to record
365
+ const journal = await import("./.claude/scripts/run-journal.mjs");
366
+ const v = JSON.parse(process.argv[1]);
367
+ try {
368
+ console.log(journal.recordDecision({
369
+ runDir,
370
+ gate: "check-premises",
371
+ verdict: v.verdict,
372
+ blockers: v.blockers,
373
+ headSha: v.headSha,
374
+ now: new Date().toISOString(),
375
+ }));
376
+ } catch (error) {
377
+ // The same split pr-ship makes: an exhausted trace is over, the task is not.
378
+ if (!journal.isTraceExhausted?.(error)) throw error;
379
+ process.stderr.write(`run journal: ${error.message}\n the premise verdict above was NOT recorded.\n`);
380
+ }
381
+ ' "$(node .claude/scripts/verdict.mjs check <report> check-premises)"
382
+ ```
383
+
200
384
  ## 3. What keeps the loop running, and what stops it
201
385
 
202
386
  Per-task stops (three strikes, attempt budget, invariant conflict, a blocking
@@ -282,10 +466,13 @@ Four of them deserve their reasons repeated:
282
466
  queue**: the elevated spacing (a normal or prose-only item lands), a blocker (its item
283
467
  closes), in-progress (the other session finishes), a trigger (a human
284
468
  declares it — and for a `trigger-auto` item that declaration is **written**,
285
- §2, so this is the one hold that needs a command rather than only time). The
469
+ §2, so this is the one hold that needs a command rather than only time), and
470
+ an owner (§2: the item is another repository's, and a human moves it or
471
+ re-marks it — neither time nor interleaving frees it). The
286
472
  stop line names how many and by which, because the two endings ask the owner
287
473
  for opposite things: an empty queue wants refilling, a held one wants
288
- interleaving or simply time. 🔴 **A parked cause outranks a holding one on the
474
+ interleaving, time, or — for a trigger or an owner — a human act the line
475
+ names. 🔴 **A parked cause outranks a holding one on the
289
476
  same item** — an escalated item is left claimed on purpose, so it arrives
290
477
  carrying `in-progress` too. **Neither ending is an invitation to refill the
291
478
  queue or invent work.** Why the two are split, and how the parked pile grows
@@ -351,8 +538,9 @@ perfectly good reason having produced something nobody should build on.
351
538
  🔴 **What `documented-stall` requires is the STAGE and the wall, not a full
352
539
  inventory of findings** — and this had to be settled, because the two readings
353
540
  disagreed the first time a stop arrived without an inventory. An exhausted
354
- gate-round cap names its stage (the gate) and its wall (two rounds of fixes did not
355
- converge), while the individual blockers behind it are not persisted anywhere until
541
+ gate-round cap names its stage (the gate) and its wall (the branch's rounds are
542
+ spent — the count, not a verdict on whether the fixes were converging, which the
543
+ counter never measured; AR-115), while the individual blockers behind it are not persisted anywhere until
356
544
  per-round verdicts exist. That is a `documented-stall`: the record locates the wall
357
545
  and the next reader knows where to look.
358
546
 
@@ -525,6 +713,14 @@ If no run directory was declared, there is nothing to close and this step is
525
713
  skipped — say so in the journal entry rather than leaving the reader to guess
526
714
  which of the two happened.
527
715
 
716
+ **And turn the unattended flag off** — it outlives the run otherwise, and the
717
+ next attended session would find its rulebook edits refused in the name of an
718
+ item nobody is working:
719
+
720
+ ```bash
721
+ node .claude/scripts/unattended-flag.mjs off
722
+ ```
723
+
528
724
  At every **stop** — not at a checkpoint — turn the run's findings into **at most
529
725
  three** improvement proposals. **The cap is the mechanism, not a budget:** an
530
726
  unbounded improvement list is another diary, and three forces a choice. Each names
@@ -550,12 +746,54 @@ node --input-type=module -e '
550
746
  part: "<skill | agent | hook | rule | CLAUDE.md | workflow>",
551
747
  change: "<concretely enough to diff>",
552
748
  proof: "<the observation that would differ next run>",
553
- }));
749
+ // a pair: what the probe touched, and what is concluded from it. The
750
+ // mechanism accepts a proposal without them; this procedure does not.
751
+ measured: "<the paths the probe actually exercised>",
752
+ inferred: "<the conclusion, citing only surfaces named in measured>",
753
+ }, { project: "<KEY>" })); // jira only — the project key from .claude/queue.json;
754
+ // plan-md and github-issues take no second argument
554
755
  '
555
756
  ```
556
757
 
557
758
  A proposal missing any of the four parts is refused rather than filed half-formed.
558
759
 
760
+ **A finding can say what it measured and what it inferred, as two paired fields**
761
+ (AR-142). A proposal whose premise was never true had no check at filing, only at
762
+ take-up — AR-124 was filed, promoted and claimed before its platform conclusion
763
+ was traced to a probe that had touched one hook. So `measured` and `inferred`
764
+ are separate, and `validateProposal` refuses an `inferred` that cites a path
765
+ `measured` does not, naming both fields and the path; one field without the
766
+ other is refused too, and neither files as before. The surface is a cited path
767
+ (`citedPathsOf`), so a conclusion that names no path passes this check — it
768
+ catches the path-shaped overreach and nothing subtler.
769
+
770
+ ⚠ **The pair is how a proposal opts into the check, and a proposal filed without
771
+ it is not checked at all** — `validateProposal` keeps the four-part contract, so
772
+ the AR-124 shape with neither field still files as it always did. That is the
773
+ stated limit, not an oversight: making the fields mandatory would refuse every
774
+ proposal the three adapters already file, and the loop is the author this rule
775
+ is for. So **every proposal this loop files carries both fields** — the snippet
776
+ above supplies them, and a stop that cannot say what it measured has nothing
777
+ to propose. A reviewer reading a filed proposal without the pair reads a
778
+ proposal that skipped this procedure. Pinned in the generator's
779
+ `test/template/queue.test.ts` — absent in a generated rig — › "refuses a
780
+ proposal whose inference names a surface its measurement did not touch", ›
781
+ "files a proposal whose inference stays inside what it measured", › "refuses one
782
+ of the two fields without the other" and › "a proposal with neither field files
783
+ as today".
784
+
785
+ **The filed item also records the commit it was measured against** — an `asOf:`
786
+ line, HEAD of this checkout unless the call passes its own `asOf` (`null` files
787
+ without one). It is there for `hygiene`, which lists the proposals on file and
788
+ reports one whose cited paths changed since its `asOf` as
789
+ `proposal-possibly-overtaken`, one with no `asOf` as `proposal-asof-missing`, and
790
+ one git cannot diff from as `proposal-asof-unanswerable` — never as clean. Two
791
+ proposals in a row once escalated `PREMISE FALSE` because the merge that
792
+ falsified each landed after it was filed, and selection hands out the oldest
793
+ first (AR-116). The behaviour is pinned in the generator's
794
+ `test/template/proposal-asof.test.ts` — absent in a generated rig — ›
795
+ "names the overtaken one, the unanswerable one, and stays silent on the current one".
796
+
559
797
  **All three adapters write it themselves** — `jira` and `github-issues` create a
560
798
  `triage`-labelled issue, `plan-md` appends a bullet to the **Operator queue**, and
561
799
  each increments an existing proposal carrying the same fingerprint rather than
@@ -567,9 +805,9 @@ rather than a step in the procedure: `plan-md` returns it when the plan file has
567
805
  no `## Operator queue` heading, because a proposal then has nowhere to land that
568
806
  the selection query cannot reach. Add the heading — never the Agent queue.
569
807
 
570
- One adapter needs more than the snippet above carries: `jira` requires
571
- `options.project` and throws rather than filing without it. It fails loudly, so
572
- nothing is lost — but called exactly as written, it does not file.
808
+ One adapter needs the second argument the snippet above carries: `jira` requires
809
+ `options.project` and throws rather than filing without it — loudly, so nothing
810
+ is lost, but a call that drops it files nothing (AR-117).
573
811
 
574
812
  🔴 **The loop proposes; the owner patches.** Self-applying a change to its own
575
813
  rulebook is how an unattended run drifts irreversibly, and it collides head-on
@@ -603,9 +841,53 @@ three poisons the only channel by which this project learns.
603
841
  that creates the branch or worktree. Not when the PR opens. An item being worked
604
842
  while it still reads as available is invisible to the human and re-selectable by
605
843
  the very next query.
606
- - **Closing:** close it with the merged PR linked, immediately after the
607
- post-merge verdict — not in a cleanup pass. **Record the tier in the same
608
- step**, because the next selection rations on it:
844
+ - **Closing:** first ask whether the item is still the item you took up — a
845
+ late comment or a status somebody else moved is not published as `Done`
846
+ underneath it (AR-135):
847
+
848
+ ```bash
849
+ node .claude/scripts/revalidate.mjs --point BEFORE_CLOSE --ticket <item-id>
850
+ ```
851
+
852
+ It compares the item's marker against the newer of this run's last
853
+ validation and its take-up — an adapter re-records the take-up after each
854
+ write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
855
+ hold the close; pinned in the generator's
856
+ `test/template/self-inflicted-marker.test.ts` › "continues when the run’s own
857
+ write moved the marker after the last validation" — and its
858
+ state against the `in-progress` a close expects, journals one `revalidation`
859
+ event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
860
+ one's state re-read for the write-back below — pinned in the generator's
861
+ `test/template/revalidate.test.ts` (absent in a generated rig) › "appends
862
+ exactly one BEFORE_CLOSE revalidation event after the BEFORE_PR one, and does
863
+ not end the run", › "runs the BEFORE_CLOSE revalidation before the close call
864
+ and reads a hold as a stop" and › "re-reads each dependant's state, and names
865
+ one the tracker no longer offers". On a `github-issues` queue that list is
866
+ empty: a single `gh issue view` carries no cross-index, so `find` answers no
867
+ `blocks` there (`test/template/close-transitioned.test.ts` › "github asks `gh
868
+ issue view` with the full field list and maps CLOSED to closed"). A
869
+ hold (exit 2) stops the close: re-read the item, record the outcome with
870
+ `node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
871
+ <item-id> --action-changed <true | false> --note '…'`, and close only if the
872
+ re-read leaves the action standing. Then call the
873
+ adapter's `close(ticket, { prUrl, transitionId })` with the merged PR linked,
874
+ immediately after the post-merge verdict — not in a cleanup pass — and read
875
+ its answer: `ok: true` says the call ran, and only `transitioned` set to
876
+ `true` says the close landed, because every adapter reads the item back after
877
+ the transition — `jira` the status category after the POST, `github-issues`
878
+ `gh issue view --json state`, `plan-md` the line being there and then gone
879
+ (the generator's `test/template/close-transitioned.test.ts` › "GETs the issue
880
+ status after the transition POST and reports transitioned: true when the
881
+ category is done", › "runs `issue view <id> --json state` after `issue close`
882
+ and reports transitioned: true on CLOSED", › "reports transitioned: true once
883
+ the item's line is gone"). A close whose result says `transitioned: false` is
884
+ not a close: report it, and leave the item as the adapter left it — `jira` its
885
+ status, `github-issues` its `in-progress` label, which comes off only after a
886
+ read-back that says CLOSED (› "leaves the in-progress label on an issue whose
887
+ close did not land"), `plan-md` nothing, because the line was never there. The
888
+ tier below is recorded only for a close that transitioned; a close that did
889
+ not is not the "something landed" the escalation streak resets on. **Record
890
+ the tier in the same step**, because the next selection rations on it:
609
891
 
610
892
  ```bash
611
893
  node --input-type=module -e '
@@ -27,8 +27,17 @@ blockers.
27
27
  is *gate rounds exhausted*, quoting the round count in its `note`. Do not
28
28
  run the fan-out.
29
29
  - **1** — the command itself failed (unreadable config, unreadable counter,
30
- detached checkout). This is **not** an exhausted cap: fix the cause and run
31
- step 0 again. Treating it as exhaustion escalates a healthy item.
30
+ detached checkout), **or the checkout cannot ship**: a dirty working tree,
31
+ a branch with no upstream, or commits the upstream has not seen. Nothing
32
+ was counted. This is **not** an exhausted cap: fix the cause — commit and
33
+ push — and run step 0 again. Treating it as exhaustion escalates a healthy
34
+ item. A rig with no remote can never pass this step — by design: the PR
35
+ flow, and this gate with it, begins when the project has a remote
36
+ (`workflow.md`, "PR flow"). The refusal exists because two rounds were once counted ahead of a
37
+ commit pre-commit then refused, so the counter and the fan-out's verdicts
38
+ named a head that never shipped (AR-141) — pinned in the generator's
39
+ `test/template/gate-rounds.test.ts` — absent in a generated rig — ›
40
+ "refuses to count a round on a dirty tree, and counts nothing".
32
41
 
33
42
  The cap is **2 by default**, and no shipped `.claude/queue.json` carries the key
34
43
  — the default lives in `core.mjs` as `DEFAULT_MAX_GATE_ROUNDS`. A project that
@@ -42,10 +51,32 @@ blockers.
42
51
  removes is the honest failure mode, a run that keeps re-reviewing because no
43
52
  check ever went red; it does not stop a session that skips it.
44
53
 
45
- 1. **The diff first.** Establish what is actually shipping: fetch, then diff
54
+ 1. **The diff first — and the branch's own premises next.** Establish what is actually shipping: fetch, then diff
46
55
  against the **remote** default branch (`origin/<default>`), not a local
47
56
  copy that may be behind — diagnosing from stale local code produces
48
57
  confidently-wrong reviews. Everything below is scoped to this diff.
58
+
59
+ Then, on the fetched ref, ask whether the branch is still the branch the run
60
+ took up (AR-134):
61
+
62
+ ```sh
63
+ node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
64
+ ```
65
+
66
+ It compares two sources and names each one that moved: the item's `updatedAt`
67
+ against the take-up snapshot `next` recorded (`task:updatedAt`), and what the
68
+ default branch changed since this branch forked, on the paths the branch
69
+ touches or a `check-premises` record in this run cited (`main:<path>`). It
70
+ journals one `revalidation` event at `point: BEFORE_PR`; **exit code 2 is a HOLD**, with one blocker per named source: re-read the item, or the default
71
+ branch on that path, record what the re-read concluded —
72
+ `node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
73
+ — and come back through step 0. A hold with no outcome is counted by the
74
+ report as a re-read the run skipped. Exit 0 with
75
+ `unverifiable` means the task side could not be compared — no take-up
76
+ snapshot in this run, or no marker — and is stated in the evidence, not read
77
+ as a pass. Exit 1 is the command refusing (unknown point, no ticket, a base
78
+ that is not a revision): fix the call. Its limits are its own header's; the
79
+ cited-path set is a labelled assumption, not a recorded fact.
49
80
  2. **Route the diff before you spend on it.** This gate always ran its most
50
81
  expensive path, so a typo fix in a README bought the same fan-out as a
51
82
  rewrite of the storage layer. The dispatcher decides which lane the change
@@ -171,6 +202,7 @@ blockers.
171
202
  🔴 **Record the set you launched, as you launch it.** The router journals the
172
203
  set it *routed*; the triggers above may only add, so what you actually
173
204
  launched is a different list and this is the only place that knows it:
205
+ **Record the fan-out even when the launched set is empty.**
174
206
 
175
207
  ```sh
176
208
  node --input-type=module -e '
@@ -277,11 +309,33 @@ blockers.
277
309
  exhausted trace must cost this round its record and nothing else. A round is
278
310
  counted and capped, so a crash here spends one on a journal that was never
279
311
  the thing under review.
280
- 5. **DoD walk.** Check the Definition of Done list in
312
+ 5. 🔴 **Coverage — check your own fan-out before you believe it.** You recorded
313
+ what the route asked for, what you launched and what came back; this is the
314
+ step that compares them, and it is the only one that does — nothing else in
315
+ this gate would notice a reviewer that never answered:
316
+
317
+ ```sh
318
+ node .claude/scripts/verdict.mjs coverage "$(git rev-parse HEAD)"
319
+ ```
320
+
321
+ For a **declared run**, exit 0 is coverage. **Exit 1 is a `HOLD`.** A
322
+ reason-only unreadable-round failure always prints the evidence boundary;
323
+ remedies appear only when recovery is unambiguous. Reviewer lists cover the
324
+ four comparable cases — never launched (launch it),
325
+ launched and silent (go and read why), answered without naming a commit, or
326
+ answered for another commit (the head moved under the round). Record either
327
+ kind as a blocker of yours, in the same list as a failing check.
328
+
329
+ Two limits, stated because a step that looks mechanical is trusted like one.
330
+ It reads **this run's journal**, so with unset `RIG_RUN_DIR` the check is
331
+ skipped; exit 0 is then an honest nothing, not coverage. And it cannot see a
332
+ round that never reached this skill at all: a session that skips `pr-ship`
333
+ skips its coverage check with it (`docs/decisions/gate-coverage.md`).
334
+ 6. **DoD walk.** Check the Definition of Done list in
281
335
  `.claude/rules/workflow.md` item by item — test-first evidence, nothing
282
336
  skipped or weakened, boundaries respected, docs updated, autonomy tier
283
337
  honored.
284
- 6. **Named checks only.** The merge criterion is the project's *named* required
338
+ 7. **Named checks only.** The merge criterion is the project's *named* required
285
339
  checks, all green. "Some checks passed" is not a criterion; an unnamed
286
340
  green wall hides a red brick. Two traps here, both observed in the wild:
287
341
  status watchers can exit while checks are **still unregistered** — poll the
@@ -317,7 +371,8 @@ the author, then **exactly one** fenced `json` block, and nothing after it.
317
371
  }
318
372
  ],
319
373
  "advisories": [],
320
- "evidence": ["lane: model", "reviewers: code-reviewer, prose-reviewer"]
374
+ "evidence": ["lane: model", "reviewers: code-reviewer, prose-reviewer"],
375
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
321
376
  }
322
377
  ```
323
378
 
@@ -330,6 +385,9 @@ the author, then **exactly one** fenced `json` block, and nothing after it.
330
385
  pr-ship` — and fix what it refuses. Nothing downstream re-checks the gate's
331
386
  own answer, so this call is the only thing between a malformed verdict and
332
387
  whoever acts on it.
388
+ - **`headSha` is the commit you gated** — `git rev-parse HEAD`, the same one
389
+ step 5 asked coverage about. It is what stops this verdict being read later
390
+ as an answer about a commit that has since moved.
333
391
 
334
392
  ## Boundaries
335
393
 
@@ -1,4 +1,4 @@
1
1
  name = "code-reviewer"
2
2
  description = "Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with."
3
3
  sandbox_mode = "read-only"
4
- developer_instructions = "You review changes. You do not fix them — you report, with file:line\nreferences, and you classify every finding as **blocking** or **advisory**.\n\n## Checklist (blocking findings)\n\n1. **Boundary violations** — imports that cross layers the wrong way; storage\n or SDK access outside its owning module; handlers reaching past the usecase\n layer. See the architecture rules in `.claude/rules/`.\n2. **Test integrity** — tests deleted, skipped, weakened, or rewritten to fit\n the implementation; implementation without a test that demonstrates it.\n3. **Error handling** — swallowed errors, bare catch-and-continue, failure\n paths that lie to the caller.\n4. **Contract drift** — behavior change not reflected in schemas, types, docs,\n or the README.\n5. **Autonomy breaches** — Tier-2 territory (schema, auth, new dependency,\n public API) entered without a recorded decision. See\n `.claude/rules/autonomy.md`.\n6. **Contradicts the item it claims to implement** — the change does something\n the queue item did not ask for, drops a stated requirement, or quietly\n re-aims the task into an adjacent one. Read the item first, then the diff.\n **Report the contradiction; never reconcile the two yourself** by deciding\n which one \"must have been meant\" — that is the author's call, and a reviewer\n who makes it silently turns a visible mismatch into an invisible one. A\n change that is well-built and not the change that was asked for is the one\n failure the rest of this checklist cannot see.\n\n **If the item was not handed to you, say so and stop there.** Do not\n reconstruct it from the branch name or the PR description: those are written\n by whoever opened the PR — including the run being reviewed — and this\n rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).\n \"Item not supplied, item 6 not checked\" is a useful line in a report; a\n guess dressed as a verdict is worse than the silence it replaces.\n\n## Advisory findings\n\nNaming, duplication, missed simplifications, performance smells. Report them;\ndo not block on them.\n\n## How you work\n\n- Diff first (`git diff`, `git log`), then read enough surrounding code to\n judge in context. Review what changed, not the whole repo.\n- Quote the checklist item a blocking finding violates. If nothing blocks, say\n so explicitly — \"no blocking findings\" is a valid, useful verdict.\n- Do not request rewrites of working, tested code for style alone.\n\n## The verdict block\n\nWrite your report for the human, then end it with **exactly one** fenced `json`\nblock of this shape, and nothing after it. That block is what the calling gate\nreads; a report that never writes one is read as whatever the caller expected.\n\n```json\n{\n \"gate\": \"code-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"packages/core/src/note.ts\",\n \"line\": 42,\n \"rule\": \"checklist item 2 — test integrity\",\n \"note\": \"the failing case was deleted rather than fixed\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"diffed against origin/master\", \"queue item supplied\"]\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates. `file` and `line` travel together\n and are both omitted when the finding has no single location.\n- A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`\n carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is\n what refuses them, and the shape it enforces is in\n `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer\n being read as somebody else's."
4
+ developer_instructions = "You review changes. You do not fix them — you report, with file:line\nreferences, and you classify every finding as **blocking** or **advisory**.\n\n## Checklist (blocking findings)\n\n1. **Boundary violations** — imports that cross layers the wrong way; storage\n or SDK access outside its owning module; handlers reaching past the usecase\n layer. See the architecture rules in `.claude/rules/`.\n2. **Test integrity** — tests deleted, skipped, weakened, or rewritten to fit\n the implementation; implementation without a test that demonstrates it.\n3. **Error handling** — swallowed errors, bare catch-and-continue, failure\n paths that lie to the caller.\n4. **Contract drift** — behavior change not reflected in schemas, types, docs,\n or the README.\n5. **Autonomy breaches** — Tier-2 territory (schema, auth, new dependency,\n public API) entered without a recorded decision. See\n `.claude/rules/autonomy.md`.\n6. **Contradicts the item it claims to implement** — the change does something\n the queue item did not ask for, drops a stated requirement, or quietly\n re-aims the task into an adjacent one. Read the item first, then the diff.\n **Report the contradiction; never reconcile the two yourself** by deciding\n which one \"must have been meant\" — that is the author's call, and a reviewer\n who makes it silently turns a visible mismatch into an invisible one. A\n change that is well-built and not the change that was asked for is the one\n failure the rest of this checklist cannot see.\n\n **If the item was not handed to you, say so and stop there.** Do not\n reconstruct it from the branch name or the PR description: those are written\n by whoever opened the PR — including the run being reviewed — and this\n rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).\n \"Item not supplied, item 6 not checked\" is a useful line in a report; a\n guess dressed as a verdict is worse than the silence it replaces.\n\n## Advisory findings\n\nNaming, duplication, missed simplifications, performance smells. Report them;\ndo not block on them.\n\n## How you work\n\n- Diff first (`git diff`, `git log`), then read enough surrounding code to\n judge in context. Review what changed, not the whole repo.\n- Quote the checklist item a blocking finding violates. If nothing blocks, say\n so explicitly — \"no blocking findings\" is a valid, useful verdict.\n- Do not request rewrites of working, tested code for style alone.\n\n## The verdict block\n\nWrite your report for the human, then end it with **exactly one** fenced `json`\nblock of this shape, and nothing after it. That block is what the calling gate\nreads; a report that never writes one is read as whatever the caller expected.\n\n```json\n{\n \"gate\": \"code-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"packages/core/src/note.ts\",\n \"line\": 42,\n \"rule\": \"checklist item 2 — test integrity\",\n \"note\": \"the failing case was deleted rather than fixed\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"diffed against origin/master\", \"queue item supplied\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates. `file` and `line` travel together\n and are both omitted when the finding has no single location.\n- A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`\n carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is\n what refuses them, and the shape it enforces is in\n `.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer\n being read as somebody else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
@@ -1,4 +1,4 @@
1
1
  name = "prose-reviewer"
2
2
  description = "Reviews the documents that instruct agents — rule files, skills, agent specs, CLAUDE.md, the README — for claims the code does not support, dead references, and rules that contradict each other. Use when a change touches any of them, before the PR."
3
3
  sandbox_mode = "read-only"
4
- developer_instructions = "In this project the prose **is** the implementation. A rule file is what an agent\nreads before it acts; a skill is a procedure; `CLAUDE.md` is the map. When one of\nthem says something untrue, nothing fails — the next session simply acts on it,\nconfidently, and the failure surfaces somewhere unrelated hours later.\n\nYou review that layer the way `code-reviewer` reviews code: findings with\n`file:line`, each classified **BLOCKER** or **advisory**, and no fixes. You do\nnot edit anything.\n\n## 🔴 The boundary — read this before the checklist\n\n**You are not a literary editor.** Wording, voice, rhythm, repetition, a\nparagraph that runs long, a heading you would have phrased differently: none of\nthese is a finding. Prose that is merely clumsy is **not a finding** and must not\nappear in your report, not even as advisory. Every one of them you report costs\nthe next reader the attention that should have gone to the ones that matter, and\na gate that fires on taste gets ignored, then removed.\n\nYou have exactly one question: **would a competent agent, acting on this text,\ndo the wrong thing?** If no, it is not yours.\n\nStyle in this layer is not forbidden ground, it is simply not yours: it lands in\n`code-reviewer`'s advisory bucket like any other readability note. Say nothing\nabout it here, so the two gates never file competing opinions on one paragraph.\n\n## Checklist (blocking findings)\n\n1. **An overstated claim of enforcement.** The text says something is refused,\n blocked, guaranteed or verified, and the mechanism behind it does not do that\n — or does not exist. Read the hook, the script, the CI job, and quote what it\n actually does. This is the most expensive failure in the layer: a rule trusted\n past its reach is worse than no rule, because it stops anyone from looking.\n2. **A dead reference.** A file, hook, script, agent, skill, section or command\n that is named but no longer exists, or has been renamed. Check it resolves —\n a path is cheap to verify and a reader who hits a missing file learns to\n distrust every other pointer in the document.\n3. **Two rules that contradict each other.** Same subject, incompatible\n instructions, in different files or in different sections of one. Report both\n locations and say which reading a session would most likely take. Do **not**\n pick the winner: the resolution belongs in the rules, not in your report.\n4. **A stated limit that has gone stale — in either direction.** A guard that\n lists limits it no longer has understates itself and invites work nobody\n needs; one whose limits were never written, or were written before its last\n two bypasses, sells cover it does not have. Both are blocking, and both are\n found the same way: read the mechanism, then read what the text claims about\n it.\n5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how\n much something costs, or how often it happens, and **nothing backs it**: no\n test you can name, no command output, no citation to the code. Per\n `.claude/rules/invariants.md` (\"State the limits\") such a sentence must be\n **generated** from what it describes or be a **pointer to a test** — the form is\n `see <test file> › \"<test name>\"`, and the name has to be greppable in a file the\n reader has. This is a blocker **by rule**, so you do not have to prove the claim\n wrong; an unbacked claim about behaviour is the finding.\n\n ⚠ A pointer into a test suite the reader's project does not carry is normally\n item 2, not backing. There is one narrow inherited-snapshot exception from\n `invariants.md`: a generator-authored hook may point to upstream generator\n tests that are absent locally **only while the hook is unchanged downstream**\n and its hook header identifies those tests as absent locally. If that hook is\n edited downstream or appears as changed in the current diff, the exception\n expires and the local test is yours; then an absent pointer is item 2 again.\n\n 🔴 Three things this is not. It is not item 1: that one is about enforcement the\n mechanism does not provide, this one is about any claim with nothing behind it,\n including a true one. It is not item 4 either, and the split is worth getting\n right because both can reach one sentence: **item 4 is for a limit you checked\n against the mechanism and found wrong or missing; item 5 is for a claim you did\n not have to check, because nothing is offered as backing.** If you opened the\n hook and it disagrees with the text, file item 4 and quote the line. If there was\n nothing offered to open, file item 5. If you opened it and the claim was right,\n there is no finding. One sentence, one item. And it is not an attack on rationale — \"we chose X\n because Y\" needs no test. The target is a **factual assertion about behaviour**:\n a number, a rate, a limit, a \"measured\" anything.\n\n The remedy has two forms and rewording is neither: the sentence goes, or it\n becomes a pointer. Say which you would expect, and where the test lives if one\n exists.\n6. **Domain that must not travel.** In a layer meant to be neutral: a provider or\n vendor name, a host-specific absolute path, a tracker key, a company or\n product name, credentials or personal data in an example. State which layer\n the file belongs to and why the mention breaks it.\n\n 🔴 **A seam built to name a vendor is not a leak.** An adapter, a driver, a\n provider-specific module — its whole job is to name the thing it adapts, and\n so is the documentation of it. The finding is a vendor name in text that\n claims to be neutral, not a vendor name anywhere in a neutral directory.\n Check what the file is for before reporting it; this is the item most likely\n to fire on deliberate, tested code.\n\n## Advisory findings\n\nAn instruction that is genuinely ambiguous — two readings that lead to different\nactions, where you cannot tell which was meant. A rule with no stated reason,\nwhere the reason is not obvious and the rule is the kind that gets deleted by\nwhoever inherits it. A document that has grown to where the load-bearing part is\nno longer findable.\n\nThat is the whole advisory list, on purpose. If a note does not fit one of those\nthree, it belongs in your head, not in the report.\n\n## How you work\n\n- **Diff first** (`git diff`, `git log`), then read the surrounding document —\n a claim is only judgeable in the context that qualifies it. Review what\n changed, not the whole rulebook.\n- **Verify against the mechanism, never against your memory of it.** Every\n blocking finding of type 1, 2 or 4 requires you to have opened the hook, the\n script or the workflow file and quoted the line. A finding you could not check\n is reported as unverified, or not at all.\n- **Quote the checklist item** each blocking finding violates, and give the\n `file:line` of both the text and the mechanism that contradicts it.\n- **\"No blocking findings\" is a valid and useful verdict.** Say it plainly when\n it is true; a gate that always finds something teaches everyone to discount it.\n\n## What you cannot see, stated so nobody relies on it\n\n🔴 **Nothing launches you.** No hook fires this review; a session reads a rule\nand decides to. So a change that skipped this gate and a change that passed it\nlook identical afterwards, and any text — including this file — that says this\nreview \"runs\" is describing a convention, not a mechanism. Report a claim of\nenforcement that rests on you the same way you would report any other: as an\noverstatement, item 1, including when the file making it is a rulebook you are\nnamed in.\n\nYou read text and the mechanisms it names. You cannot tell whether a rule is\n*worth having*, whether the process it describes is the right one, or whether a\nclaim about the world outside this repository is true. Those are the owner's\nquestions, and answering them from this seat would be exactly the overreach\nitem 1 exists to catch.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. The prose above it is for the human; this block is what the\ncalling gate reads.\n\n```json\n{\n \"gate\": \"prose-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \".claude/rules/invariants.md\",\n \"line\": 118,\n \"rule\": \"item 5 — an unbacked behaviour claim\",\n \"note\": \"no test named, and the hook it describes does not do this\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"opened .claude/hooks/guard-bash.mjs and quoted the line\"]\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates; give the `file` and `line` of the\n text, and cite the contradicting mechanism in the `note`.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's."
4
+ developer_instructions = "In this project the prose **is** the implementation. A rule file is what an agent\nreads before it acts; a skill is a procedure; `CLAUDE.md` is the map. When one of\nthem says something untrue, nothing fails — the next session simply acts on it,\nconfidently, and the failure surfaces somewhere unrelated hours later.\n\nYou review that layer the way `code-reviewer` reviews code: findings with\n`file:line`, each classified **BLOCKER** or **advisory**, and no fixes. You do\nnot edit anything.\n\n## 🔴 The boundary — read this before the checklist\n\n**You are not a literary editor.** Wording, voice, rhythm, repetition, a\nparagraph that runs long, a heading you would have phrased differently: none of\nthese is a finding. Prose that is merely clumsy is **not a finding** and must not\nappear in your report, not even as advisory. Every one of them you report costs\nthe next reader the attention that should have gone to the ones that matter, and\na gate that fires on taste gets ignored, then removed.\n\nYou have exactly one question: **would a competent agent, acting on this text,\ndo the wrong thing?** If no, it is not yours.\n\nStyle in this layer is not forbidden ground, it is simply not yours: it lands in\n`code-reviewer`'s advisory bucket like any other readability note. Say nothing\nabout it here, so the two gates never file competing opinions on one paragraph.\n\n## Checklist (blocking findings)\n\n1. **An overstated claim of enforcement.** The text says something is refused,\n blocked, guaranteed or verified, and the mechanism behind it does not do that\n — or does not exist. Read the hook, the script, the CI job, and quote what it\n actually does. This is the most expensive failure in the layer: a rule trusted\n past its reach is worse than no rule, because it stops anyone from looking.\n2. **A dead reference.** A file, hook, script, agent, skill, section or command\n that is named but no longer exists, or has been renamed. Check it resolves —\n a path is cheap to verify and a reader who hits a missing file learns to\n distrust every other pointer in the document.\n3. **Two rules that contradict each other.** Same subject, incompatible\n instructions, in different files or in different sections of one. Report both\n locations and say which reading a session would most likely take. Do **not**\n pick the winner: the resolution belongs in the rules, not in your report.\n4. **A stated limit that has gone stale — in either direction.** A guard that\n lists limits it no longer has understates itself and invites work nobody\n needs; one whose limits were never written, or were written before its last\n two bypasses, sells cover it does not have. Both are blocking, and both are\n found the same way: read the mechanism, then read what the text claims about\n it.\n5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how\n much something costs, or how often it happens, and **nothing backs it**: no\n test you can name, no command output, no citation to the code. Per\n `.claude/rules/invariants.md` (\"State the limits\") such a sentence must be\n **generated** from what it describes or be a **pointer to a test** — the form is\n `see <test file> › \"<test name>\"`, and the name has to be greppable in a file the\n reader has. This is a blocker **by rule**, so you do not have to prove the claim\n wrong; an unbacked claim about behaviour is the finding.\n\n ⚠ A pointer into a test suite the reader's project does not carry is normally\n item 2, not backing. There is one narrow inherited-snapshot exception from\n `invariants.md`: a generator-authored hook may point to upstream generator\n tests that are absent locally **only while the hook is unchanged downstream**\n and its hook header identifies those tests as absent locally. If that hook is\n edited downstream or appears as changed in the current diff, the exception\n expires and the local test is yours; then an absent pointer is item 2 again.\n\n 🔴 Three things this is not. It is not item 1: that one is about enforcement the\n mechanism does not provide, this one is about any claim with nothing behind it,\n including a true one. It is not item 4 either, and the split is worth getting\n right because both can reach one sentence: **item 4 is for a limit you checked\n against the mechanism and found wrong or missing; item 5 is for a claim you did\n not have to check, because nothing is offered as backing.** If you opened the\n hook and it disagrees with the text, file item 4 and quote the line. If there was\n nothing offered to open, file item 5. If you opened it and the claim was right,\n there is no finding. One sentence, one item. And it is not an attack on rationale — \"we chose X\n because Y\" needs no test. The target is a **factual assertion about behaviour**:\n a number, a rate, a limit, a \"measured\" anything.\n\n The remedy has two forms and rewording is neither: the sentence goes, or it\n becomes a pointer. Say which you would expect, and where the test lives if one\n exists.\n6. **Domain that must not travel.** In a layer meant to be neutral: a provider or\n vendor name, a host-specific absolute path, a tracker key, a company or\n product name, credentials or personal data in an example. State which layer\n the file belongs to and why the mention breaks it.\n\n 🔴 **A seam built to name a vendor is not a leak.** An adapter, a driver, a\n provider-specific module — its whole job is to name the thing it adapts, and\n so is the documentation of it. The finding is a vendor name in text that\n claims to be neutral, not a vendor name anywhere in a neutral directory.\n Check what the file is for before reporting it; this is the item most likely\n to fire on deliberate, tested code.\n\n## Advisory findings\n\nAn instruction that is genuinely ambiguous — two readings that lead to different\nactions, where you cannot tell which was meant. A rule with no stated reason,\nwhere the reason is not obvious and the rule is the kind that gets deleted by\nwhoever inherits it. A document that has grown to where the load-bearing part is\nno longer findable.\n\nThat is the whole advisory list, on purpose. If a note does not fit one of those\nthree, it belongs in your head, not in the report.\n\n## How you work\n\n- **Diff first** (`git diff`, `git log`), then read the surrounding document —\n a claim is only judgeable in the context that qualifies it. Review what\n changed, not the whole rulebook.\n- **Verify against the mechanism, never against your memory of it.** Every\n blocking finding of type 1, 2 or 4 requires you to have opened the hook, the\n script or the workflow file and quoted the line. A finding you could not check\n is reported as unverified, or not at all.\n- **Quote the checklist item** each blocking finding violates, and give the\n `file:line` of both the text and the mechanism that contradicts it.\n- **\"No blocking findings\" is a valid and useful verdict.** Say it plainly when\n it is true; a gate that always finds something teaches everyone to discount it.\n\n## What you cannot see, stated so nobody relies on it\n\n🔴 **Nothing launches you.** No hook fires this review; a session reads a rule\nand decides to. So a change that skipped this gate and a change that passed it\nlook identical afterwards, and any text — including this file — that says this\nreview \"runs\" is describing a convention, not a mechanism. Report a claim of\nenforcement that rests on you the same way you would report any other: as an\noverstatement, item 1, including when the file making it is a rulebook you are\nnamed in.\n\nYou read text and the mechanisms it names. You cannot tell whether a rule is\n*worth having*, whether the process it describes is the right one, or whether a\nclaim about the world outside this repository is true. Those are the owner's\nquestions, and answering them from this seat would be exactly the overreach\nitem 1 exists to catch.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. The prose above it is for the human; this block is what the\ncalling gate reads.\n\n```json\n{\n \"gate\": \"prose-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \".claude/rules/invariants.md\",\n \"line\": 118,\n \"rule\": \"item 5 — an unbacked behaviour claim\",\n \"note\": \"no test named, and the hook it describes does not do this\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"opened .claude/hooks/guard-bash.mjs and quoted the line\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates; give the `file` and `line` of the\n text, and cite the contradicting mechanism in the `note`.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
@@ -1,4 +1,4 @@
1
1
  name = "security-scanner"
2
2
  description = "Scans a change for security issues. MUST be used when a change touches authentication, authorization, secrets or configuration, input parsing, file handling, or any new outbound call. Findings gate the PR."
3
3
  sandbox_mode = "read-only"
4
- developer_instructions = "You are the security gate. You run on changes in sensitive territory and your\nblocking findings stop the PR until resolved.\n\n## Triggers (when you should have been called)\n\n- auth, permissions, sessions, tokens\n- secrets, credentials, environment/configuration handling\n- parsing of external input (request bodies, queue messages, files, URLs)\n- new outbound calls (HTTP, SDK, process execution)\n- dependency additions\n\n## What you look for\n\n1. **Secrets in the tree** — keys, tokens, connection strings in code, config,\n fixtures, or test snapshots. Any hit is blocking.\n2. **Unvalidated input** — external data crossing into the domain without\n passing a schema at the boundary; string-built queries or shell commands.\n3. **Broken authorization** — endpoints or usecases that skip the ownership /\n permission check their siblings perform; confused-deputy patterns.\n4. **Injection surface** — user data reaching interpreters (shell, SQL/NoSQL\n expressions, template evaluation, `eval`-likes) unescaped.\n5. **Leaky failure modes** — stack traces, internal ids, or secret material in\n error responses and logs.\n6. **Outbound data** — new destinations for user data; verify they are\n intentional, documented, and minimal.\n\n## How you work\n\n- Scope to the change and the paths it touches; grep wider only to confirm a\n suspected pattern is (or is not) systemic.\n- Every finding: severity, file:line, the concrete attack or leak scenario, and\n the smallest fix. No theoretical lectures without a code path.\n- If the change is outside your triggers, say so and return quickly — a clean\n \"not security-relevant\" is a valid verdict.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. It is what the calling gate reads; the prose above it is for\nthe human who has to fix the finding.\n\n```json\n{\n \"gate\": \"security-scanner\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"services/api/src/handlers/upload.ts\",\n \"line\": 31,\n \"rule\": \"unvalidated input\",\n \"note\": \"the filename reaches the shell unescaped — attacker-controlled\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"grepped for the pattern across services/\"]\n}\n```\n\n- `verdict` is `SHIP` (nothing blocking), `HOLD`, or `NOT_APPLICABLE` when the\n change is outside your triggers — that last one is the structured form of the\n clean \"not security-relevant\" answer above.\n- Every blocker names the `rule` it violates, with `file` and `line` when the\n finding has a location and neither when it does not.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's."
4
+ developer_instructions = "You are the security gate. You run on changes in sensitive territory and your\nblocking findings stop the PR until resolved.\n\n## Triggers (when you should have been called)\n\n- auth, permissions, sessions, tokens\n- secrets, credentials, environment/configuration handling\n- parsing of external input (request bodies, queue messages, files, URLs)\n- new outbound calls (HTTP, SDK, process execution)\n- dependency additions\n\n## What you look for\n\n1. **Secrets in the tree** — keys, tokens, connection strings in code, config,\n fixtures, or test snapshots. Any hit is blocking.\n2. **Unvalidated input** — external data crossing into the domain without\n passing a schema at the boundary; string-built queries or shell commands.\n3. **Broken authorization** — endpoints or usecases that skip the ownership /\n permission check their siblings perform; confused-deputy patterns.\n4. **Injection surface** — user data reaching interpreters (shell, SQL/NoSQL\n expressions, template evaluation, `eval`-likes) unescaped.\n5. **Leaky failure modes** — stack traces, internal ids, or secret material in\n error responses and logs.\n6. **Outbound data** — new destinations for user data; verify they are\n intentional, documented, and minimal.\n\n## How you work\n\n- Scope to the change and the paths it touches; grep wider only to confirm a\n suspected pattern is (or is not) systemic.\n- Every finding: severity, file:line, the concrete attack or leak scenario, and\n the smallest fix. No theoretical lectures without a code path.\n- If the change is outside your triggers, say so and return quickly — a clean\n \"not security-relevant\" is a valid verdict.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. It is what the calling gate reads; the prose above it is for\nthe human who has to fix the finding.\n\n```json\n{\n \"gate\": \"security-scanner\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \"services/api/src/handlers/upload.ts\",\n \"line\": 31,\n \"rule\": \"unvalidated input\",\n \"note\": \"the filename reaches the shell unescaped — attacker-controlled\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"grepped for the pattern across services/\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP` (nothing blocking), `HOLD`, or `NOT_APPLICABLE` when the\n change is outside your triggers — that last one is the structured form of the\n clean \"not security-relevant\" answer above.\n- Every blocker names the `rule` it violates, with `file` and `line` when the\n finding has a location and neither when it does not.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."