create-agent-rig 0.6.1 → 0.7.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 (39) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/package.json +3 -1
  3. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
  4. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +122 -81
  5. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
  6. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +24 -8
  7. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +3 -6
  8. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +67 -12
  9. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +5 -9
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +20 -11
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +5 -9
  13. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  14. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +164 -0
  15. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  16. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  17. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +61 -27
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  25. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
  26. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  27. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  28. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +33 -19
  29. package/templates/agent-os/universal/.claude/settings.json +1 -1
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +122 -81
  31. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
  32. package/templates/agent-os/universal/.codex/hooks.json +17 -17
  33. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  34. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  35. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  36. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  37. package/templates/agent-os/universal/layers.json +6 -0
  38. package/templates/hash-history.json +71 -28
  39. package/templates/release-ledger.json +3 -1
package/CHANGELOG.md CHANGED
@@ -11,6 +11,149 @@ Numbering is ordinary semver — **additive is a minor, a fix is a patch** — s
11
11
  that "I only take minors" remains a usable policy; 0.3.2 shipped additive
12
12
  content as a patch by the owner's call and stays recorded as one.
13
13
 
14
+ ## 0.7.0
15
+
16
+ **A durable claim record under the revalidation 0.6.2 already had, and the
17
+ governance fixes that followed it.** 0.6.2 could already re-check at a
18
+ checkpoint whether the branch about to ship is still the branch the run took
19
+ up. What a newly scaffolded project gains here is the layer under that: a
20
+ content-blind record of what was claimed, so the re-check no longer rests on
21
+ the run's own take-up snapshot. The public CLI is unchanged — no new command,
22
+ no new flag, no new dependency.
23
+
24
+ **Numbered a minor deliberately.** The rule at the top of this file is that
25
+ additive is a minor and a fix is a patch, so that "I only take minors" stays a
26
+ usable policy. **Five** files land in a generated rig that the published 0.6.2
27
+ does not contain — the four listed under Added, plus
28
+ `.claude/scripts/lib/shell-tools.mjs`, the shared shell-tool list the Never-tier
29
+ guards read. One of the five is a config file the project owns. Shipping that as
30
+ a patch is the case the rule exists to prevent.
31
+
32
+ ### Added
33
+
34
+ - **Content-blind revalidation claims.** A run records what it claimed at each
35
+ checkpoint and compares it later without copying the tracker's content, so the
36
+ re-check does not depend on the run remembering correctly. A generated project
37
+ receives `.claude/scripts/lib/claim-records.mjs`,
38
+ `.claude/scripts/lib/revalidation-evidence.mjs`, `.rig/revalidation.json` and
39
+ the decision record `docs/decisions/content-blind-revalidation.md`.
40
+
41
+ ### Fixed
42
+
43
+ - **The Never-tier guards, and the kill switch they carry, run on every shell
44
+ surface — where before they ran on one.** They were wired under a single tool
45
+ matcher, so a second shell surface reached none of them: a force-push of a shared branch, a filesystem wipe and a
46
+ pre-commit bypass all ran there with the brake armed. The guards now read one
47
+ shared list of shell tools rather than each comparing its own literal, and the
48
+ tests spawn them on every entry in that list instead of checking the wiring
49
+ alone — which is how the gap survived its own test suite.
50
+
51
+ ⚠ Running is not the same as covering, and the limit is stated where the list
52
+ is. The rules match a command NAME, so they refuse an operation only in that
53
+ spelling: with the brake armed, `gh pr merge …` is refused on both surfaces
54
+ while `gh.exe pr merge …` and `Remove-Item -Recurse -Force C:\` are not. That
55
+ bound belongs to the rule set rather than to the matcher — the `.exe` spelling
56
+ was allowed on the original surface too — and it is unchanged by this
57
+ release.
58
+
59
+ - **An adapter it cannot read is `UNVERIFIABLE`, not a stack trace.** A
60
+ revalidation whose queue adapter could not be reached exited on a raw Node
61
+ stack trace, which a caller could read as noise rather than as a hold. It now
62
+ takes the same hold path a real drift takes and never returns a pass. The
63
+ reason it prints is withheld whenever it carries userinfo — as a class, not as
64
+ a list of spellings, after two rounds in which each fix closed the form just
65
+ found and left the next one open. A queue configuration that cannot be
66
+ resolved at all is a refusal with a readable message rather than a crash.
67
+
68
+ - **The instruction surface no longer cites this repository's backlog.** The
69
+ `loop` and `pr-ship` skills and the Node/TypeScript stack rule carried ticket
70
+ identifiers, commit SHAs and PR numbers from the tracker that built them —
71
+ provenance a downstream reader cannot open, in artifacts whose only job is to
72
+ instruct. A mechanical check now scans every layer's rules, skills, agent
73
+ specs and top-level instruction files and fails on a backlog identifier,
74
+ without banning those letters repository-wide.
75
+
76
+ ⚠ Scoped deliberately, and the scope is not the whole rig. Two of the check's
77
+ exclusions ship: the `.claude/scripts/**` and `.claude/hooks/**` trees, whose
78
+ citations are comments addressed to whoever edits the mechanism rather than
79
+ instructions the agent follows, and `docs/decisions/`, where a record's whole
80
+ job is to say what happened. Together **86 citations across 27 files** still
81
+ arrive with a generated project — 78 in the first pair, 8 in the records.
82
+ Whether they are the same defect is a live question this release does not
83
+ settle.
84
+
85
+ - **Evidence pointers are checked rather than trusted.** A pointer of the form
86
+ `file › "test name"` is what keeps a claim about a mechanism honest; several
87
+ named tests that had been renamed or moved. A check now resolves them, and a
88
+ pointer into a suite a generated project never receives has to say so.
89
+
90
+ ⚠ Its coverage is partial and it states that itself: it reads a citation's
91
+ names from the line the file is named on and the two after it, and resolves a
92
+ target by basename, so a test moved to another directory still passes. A green
93
+ run means no citation it read has gone dead — not that every pointer was
94
+ verified.
95
+
96
+ - **The `loop` skill now describes how the run directory actually reaches each
97
+ command.** It had described a directory most of its own commands could not be
98
+ told about, so a reader could journal into one place while a check looked in
99
+ another. The correction is to the skill's text, not to the plumbing: the two
100
+ commands that take it as an argument rather than from the environment are now
101
+ named, and a correspondence check walks every script rather than a list
102
+ somebody maintains.
103
+
104
+ ### Documentation
105
+
106
+ - `docs/command-contract.md` records what the CLI promises and which of its
107
+ commands conform today. Generator-only; it does not ship into a rig.
108
+
109
+ ## 0.6.2
110
+
111
+ **Patch hardening for the Agent OS shipped by 0.6.1.** This release closes six
112
+ downstream-found governance and transport defects without changing the public
113
+ CLI or adding a dependency.
114
+
115
+ ### Fixed
116
+
117
+ - **A UTF-8 BOM on a hook's stdin no longer disarms it.** PowerShell prepends
118
+ one on some Windows hosts, `JSON.parse` throws on a leading U+FEFF, and every
119
+ hook resolved that to its documented fail-open — so a well-formed refusal
120
+ became an allow. On such a host all eight hooks failed open together,
121
+ `guard-bash` among them, which carries the Never tier and the kill switch.
122
+ They now read through one shared `.claude/hooks/lib/hook-input.mjs`. Pinned in
123
+ `test/template/hook-stdin.test.ts` › "blocks the same command when PowerShell
124
+ prepends a UTF-8 BOM" and › "reads stdin through the one shared reader, in
125
+ every hook that reads it".
126
+ - **Windows 8.3 short paths no longer hide a rulebook edit from the guard.**
127
+ `realpathSync` normalises separators but leaves a short name (`RUNNER~1`,
128
+ `SERHII~1`) unexpanded, so a checkout reached by two spellings hashed to two
129
+ unattended-flag names and compared as two directories. Both
130
+ `guard-rulebook` and `unattended-flag` now canonicalise with
131
+ `realpathSync.native`. Pinned in `test/template/unattended-flag.test.ts` ›
132
+ "scopes the flag by the checkout, so two spellings of one directory arm one
133
+ file".
134
+ - **Codex hooks carry the canonical repository root in `CLAUDE_PROJECT_DIR` on
135
+ POSIX and Windows.** A session started in a nested directory therefore judges
136
+ a rulebook edit against the checkout the hook came from. Pinned in
137
+ `test/template/codex.test.ts` › "anchors a nested-cwd Codex rulebook edit to
138
+ the canonical repository root"; the same test file decodes and checks the
139
+ Windows command.
140
+ - **Jira retry is limited to safe reads and the semantically read-only search
141
+ POST.** Comment, transition, issue-create and issue-update mutations return
142
+ the first ambiguous transient failure instead of replaying the write. Pinned
143
+ in `test/template/queue-jira.test.ts` › "does not retry %s" and › "retries a
144
+ semantically read-only search POST after a 429".
145
+ - **`.claude/doctor-exemptions.json` is protected as rulebook input.** An
146
+ unattended edit is refused unless the current item's allow-list names that
147
+ exact file. Pinned in `test/template/guard-rulebook.test.ts` › "allows doctor
148
+ exemptions only when the item names that exact rulebook file" and the guarded
149
+ path table in the same suite.
150
+ - **`lastCompletedTier` is explicitly repository-global across board switches.**
151
+ A selector change cannot reset the spacing brake and admit a second elevated
152
+ mechanism change in the same checkout. The ruling is in
153
+ `docs/decisions/spacing-rations-mechanisms.md`, pinned by
154
+ `test/template/queue-board.test.ts` › "keeps completed-tier spacing
155
+ repository-global when the active board switches".
156
+
14
157
  ## 0.6.1
15
158
 
16
159
  **Security and upgrade hardening for the Agent OS shipped by 0.6.0.** This patch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -51,6 +51,8 @@
51
51
  "devDependencies": {
52
52
  "@eslint/js": "^10.0.1",
53
53
  "@types/node": "^26.1.1",
54
+ "ajv": "^8.20.0",
55
+ "ajv-formats": "^3.0.1",
54
56
  "eslint": "^10.7.0",
55
57
  "eslint-config-prettier": "^10.1.8",
56
58
  "globals": "^17.7.0",
@@ -58,9 +58,8 @@ gh api "repos/{owner}/{repo}/commits/$SHA/check-runs" \
58
58
  **A head that gets no run at all is a third state, not a slow one.** A
59
59
  `pull_request` push can register no workflow run and emit no failure signal —
60
60
  the head sits with a scanner only, and the poll above waits forever while the
61
- previous head's green sits one line up in the same PR (AR-149: `69b5d65` on
62
- #130 got no `ci` run; `8ca26e7` on #134 got `ci` and no `e2e`). Tell "not
63
- registered" apart from "pending" by asking for runs by head, not by PR:
61
+ previous head's green sits one line up in the same PR. Tell "not registered"
62
+ apart from "pending" by asking for runs by head, not by PR:
64
63
 
65
64
  ```sh
66
65
  gh api "repos/{owner}/{repo}/actions/runs?head_sha=$SHA" \
@@ -45,7 +45,7 @@ composed file:
45
45
 
46
46
  ```bash
47
47
  node .claude/scripts/queue/index.mjs board # the active board and the declared ones
48
- node .claude/scripts/queue/index.mjs board RP # switch this checkout: writes .claude/queue.board
48
+ node .claude/scripts/queue/index.mjs board <board-id> # switch this checkout: writes .claude/queue.board
49
49
  ```
50
50
 
51
51
  The selector is per-checkout runtime state, the same class as
@@ -59,7 +59,11 @@ and the `board` command itself refuses a switch while the checkout is unattended
59
59
  does not prevent an arbitrary direct shell write to the selector — edit-tool
60
60
  hooks cannot see one. `.claude/queue.state.json` stays per config, not per board:
61
61
  the tier the last close recorded rations the next selection whichever board it
62
- lands on.
62
+ lands on. This is repository-risk state, not tracker metadata: switching from
63
+ one independent queue to another must not turn a previous mechanism close into
64
+ permission for a second one in the same checkout. Pinned in the generator's
65
+ `test/template/queue-board.test.ts` (absent in a generated rig) › "keeps
66
+ completed-tier spacing repository-global when the active board switches".
63
67
 
64
68
  Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
65
69
  decision and each adapter only maps its tracker's records onto the neutral shape.
@@ -75,13 +79,14 @@ state-vs-queue split exists to prevent.
75
79
  node .claude/scripts/preflight.mjs
76
80
  ```
77
81
 
78
- Four items are scripted (kill switch absent · `RIG_RUN_DIR` not already
79
- exported · local default branch matches the remote · the last deploy concluded
80
- successfully) and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
82
+ Five items are scripted (kill switch absent · `RIG_RUN_DIR` not already
83
+ exported · the versioned revalidation detection contract is supported · local
84
+ default branch matches the remote · the last deploy concluded successfully)
85
+ and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
81
86
  leaves no record cannot tell you it was skipped.
82
87
 
83
88
  Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
84
- knowing which ground is soft. **GO** → the scripted four are clean; the rest are
89
+ knowing which ground is soft. **GO** → the scripted five are clean; the rest are
85
90
  still yours.
86
91
 
87
92
  **An `unknown` never becomes a `pass`.** A probe that could not run tells you
@@ -114,9 +119,31 @@ mkdir -p "$RIG_RUN_DIR"
114
119
  never with a variable the session exported — pinned in the generator's
115
120
  `test/template/guard-rulebook.test.ts` (absent in a generated rig) › "only a
116
121
  flag arms it — an exported RIG_UNATTENDED=1 with no flag changes nothing" —
117
- and in some harnesses the export does not even survive to the next Bash call,
118
- which is why every command in this skill can also take the run directory per
119
- invocation. What a hook CAN see is a file, so the unattended signal is one:
122
+ and in some harnesses the export does not even survive to the next Bash call.
123
+
124
+ 🔴 **So re-export it in every call that needs it.** `RIG_RUN_DIR` is how the run
125
+ directory reaches the commands that read it from the environment — and two of
126
+ the commands this skill invokes read it from somewhere else instead.
127
+ `unattended-flag.mjs` does not read the variable; it takes the directory as
128
+ `--run-dir`, which is why the call below passes it explicitly.
129
+ `revalidation-report.mjs` does not read it either; it takes `--runs <dir>`.
130
+ For every other command this skill invokes the variable is the whole of it, and
131
+ passing the flag is not a fallback but an unrecognised argument — after which a
132
+ command writes into a run directory nobody declared, or refuses for want of one
133
+ while the caller believes it was told. Modules this skill *imports* rather than
134
+ invokes are a third case again: `run-journal.mjs` and `queue/state.mjs` are
135
+ handed a `runDir` by their caller, which is why §9 lists it among the details
136
+ that must be copied rather than re-derived.
137
+
138
+ `--run-dir` is taken by `unattended-flag.mjs`, and by no other script under
139
+ `.claude/scripts/`. That is measured over the whole tree rather than against a
140
+ list, in both directions, by the generator's
141
+ `test/template/correspondence.test.ts` (absent in a generated rig) › "names
142
+ exactly the commands that take --run-dir, and only those" — so a script anywhere
143
+ in that tree that gains the flag without this sentence gaining its name goes
144
+ red.
145
+
146
+ What a hook CAN see is a file, so the unattended signal is one:
120
147
 
121
148
  ```bash
122
149
  # at claim time, from the paths the item names (repo-relative prefixes, with
@@ -137,12 +164,11 @@ rig — › "blocks a hook-config edit with an empty allow-list, naming path, it
137
164
 
138
165
  ⚠ **The export outlives the run's own calls.** Everything the session spawns
139
166
  inherits it — and a test suite that spawns the queue CLI would write fixture
140
- records into this run's trace (AR-139: 38 fixture selections and 22 fixture
141
- revalidation events in one session, two tests exiting 1). So preflight refuses
142
- to start on a `RIG_RUN_DIR` already exported. The generator's own test harness
167
+ records into this run's trace. So preflight refuses to start on a
168
+ `RIG_RUN_DIR` already exported. The generator's own test harness
143
169
  also scrubs the variable before any test file loads — its `test/setup-env.ts`,
144
- pinned by its `test/template/rig-run-dir-scrub.test.ts` › "holds with the
145
- variable exported around the whole vitest process" — and **neither file ships
170
+ pinned by its `test/template/rig-run-dir-scrub.test.ts` (absent in a generated
171
+ rig) › "holds with the variable exported around the whole vitest process" — and **neither file ships
146
172
  into a generated rig**: here, nothing scrubs it, and a rig's own test setup is
147
173
  the place to do the same.
148
174
 
@@ -194,12 +220,12 @@ reported by `hygiene` as `owner-mismatch`. It clears the way `trigger-human`
194
220
  does: a human moves the item or re-marks it. An unmarked item is unconditional.
195
221
  Two items of another product once entered this queue as normal spacers and
196
222
  escalated `PREMISE FALSE` back to back — a run-level stop spent on work that
197
- was never this checkout's (AR-132). Pinned in the generator's
223
+ was never this checkout's. Pinned in the generator's
198
224
  `test/template/queue-owner.test.ts` — absent in a generated rig — › "holds an
199
225
  item whose owner is another repository, with the cause named".
200
226
 
201
227
  🔴 **An item's lifecycle is a label a human wrote, and the loop infers none of
202
- it** (AR-144). Four words, read by `lifecycleOf` in `core.mjs` so every adapter
228
+ it**. Four words, read by `lifecycleOf` in `core.mjs` so every adapter
203
229
  means the same thing — three of lifecycle, one of scheduling:
204
230
 
205
231
  - `keep-core` — the problem and the responsibility are valid and the item is
@@ -281,59 +307,72 @@ and the work turns out to touch an elevated path (`CLAUDE.md` →
281
307
  `elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
282
308
  as this run's elevated item for spacing.
283
309
 
284
- **Selection also revalidates the item against its last take-up.** `next`
285
- records the selected item's `updatedAt` marker in the run state (`takeUps`) and,
286
- when the item is offered again, compares the two — against this run's take-up
287
- when it has one, otherwise against the newest earlier run's under `.claude/runs/`
288
- (AR-138: before that, an item taken up yesterday compared against nothing and
289
- read as a first sight). A proposal the loop files carries its own baseline: the
290
- `jira` and `github-issues` adapters record the filed item's marker as a take-up
291
- in the run that filed it. The event names which it used — `baseline:
292
- this-run | previous-run | null` — pinned in the generator's
293
- `test/template/revalidation-baseline.test.ts` — absent in a generated rig — ›
294
- "holds when the marker moved past the earlier run’s take-up, and names that
295
- baseline". A marker that
296
- moved prints a `revalidate:` line and the JSON carries `revalidation.changed:
297
- true`: **re-read the item before acting on it**, then record what the re-read
298
- concluded — whether the change altered the action is the evidence this exists to
299
- collect, and the comparison alone cannot supply it:
310
+ **Selection is the first point of the one revalidation chain.** On the first
311
+ successful SELECT, `next` creates a versioned content-blind baseline at
312
+ `.rig/claims/<item-id>.json` and reports `BASELINE_CREATED`. Add that record to
313
+ the task's branch: a later SELECT, BEFORE_PR or BEFORE_CLOSE refuses an
314
+ untracked record, and a resumed checkpoint with no record is `UNVERIFIABLE`.
315
+ The `scope` fingerprint set is authoritative at SELECT and BEFORE_PR and
316
+ includes workflow state normalised to the state that checkpoint expects;
317
+ `commentary` is observed there but becomes hold-authoritative only at
318
+ BEFORE_CLOSE. Neither set stores title, description or comment bodies.
319
+ For Jira and GitHub issues, the adapter's successful `claim` writes its
320
+ observable `in-progress` transition into that same record as `workflowClaim`.
321
+ Only that durable acknowledgement makes the claimed state expected at a resumed
322
+ SELECT, BEFORE_PR or BEFORE_CLOSE; the same tracker state reached without it is
323
+ external drift and HOLDs. PLAN.md remains `open` because it has no observable
324
+ claim transition. The checkpoint-aware edge is pinned in the generator's
325
+ `test/template/content-blind-revalidation.test.ts` (absent in a generated rig)
326
+ › "keeps a resumed SELECT current after the Jira adapter records its own claim
327
+ transition", › "GitHub claim records the durable transition that makes
328
+ in-progress CURRENT", › "holds claim:scope at resumed SELECT for the same transition
329
+ made outside the adapter", › "accepts an in-progress transition performed by
330
+ the Jira adapter claim operation", and › "holds claim:scope when an external
331
+ actor moves the item to the expected claimed state".
332
+ The durable/evidence boundary and rollback rule are recorded in
333
+ `docs/decisions/content-blind-revalidation.md`.
334
+
335
+ `takeUps` and `updatedAt` remain in run state and in the event's `task` field as
336
+ evidence/compatibility state. They do not decide `CURRENT`, `CHANGED`,
337
+ `CONFLICT` or `UNVERIFIABLE`, do not decide whether a first baseline may be
338
+ created, and never appear as a drift source. The event may still name
339
+ `baseline: this-run | previous-run | null` so older evidence remains readable.
340
+ First sight versus resume comes only from the SELECT events in the current and
341
+ bounded sibling run journals. If those journals cannot prove first sight, a
342
+ missing claim is `UNVERIFIABLE`; an `updatedAt` marker can neither create nor
343
+ withhold the claim. Hitting the sibling entry/read cap makes that proof
344
+ incomplete and therefore fails closed; "not found in the bounded subset" never
345
+ means "never selected".
346
+
347
+ On `CHANGED`, `CONFLICT` or `UNVERIFIABLE`, **re-read before acting**, then
348
+ record what the re-read concluded:
300
349
 
301
350
  ```bash
302
351
  node .claude/scripts/revalidate.mjs outcome --point SELECT --ticket <item-id> --action-changed <true | false> --note '<what changed, or why it changes nothing>'
303
352
  ```
304
353
 
305
- It appends one `revalidation-outcome` record whose `answers` names the
306
- revalidation it resolves, so the report can pair the two without guessing. The
354
+ It appends one typed `revalidation-outcome` record whose `detectionId` names the
355
+ stable detection it resolves (and retains the legacy `answers` sequence), so
356
+ the report can pair the two across harness runs without guessing. The
307
357
  note is stored verbatim from argv, so keep it in single quotes: inside double
308
358
  quotes the shell expands a backtick or a `$` before the command sees it.
309
359
  Nothing forces this record — a `revalidation` event with no matching outcome is
310
360
  counted as `unresolved`, which is the honest word for a re-read the run skipped.
311
361
 
312
- Under a declared run directory, every selection logs one `revalidation` event
313
- `{ticket, point: SELECT, changed, source, action, task}` — the same shape the
314
- BEFORE_PR and BEFORE_CLOSE points write. **No-change is always recorded**, one
315
- line per selection and no sampling: the rule is explicit so the report's
316
- `opportunities` is a count and not an estimate. An adapter with no marker
317
- (`plan-md`) logs `changed: null`, never "unchanged". ⚠ The marker also moves on
318
- the run's own claim and comments. The `jira` and `github-issues` adapters
319
- re-record the take-up after each write they make — claim, comment, close,
320
- escalate — so a move made through the adapter is not a hold (AR-140, from the
321
- journal's RX3/RX4 entry: every BEFORE_PR catch of that run was the run's own
322
- comment, counted by `revalidation-report.mjs`); a
323
- comment posted by any other route — a REST call by hand, a connector — still
324
- moves it like anyone else's, and a `true` can still be self-inflicted that way
325
- — the re-read decides, which is why the outcome is recorded separately, and a
326
- hold the re-read overturns is counted as a false hold with its source named.
327
- Pinned in the generator's `test/template/self-inflicted-marker.test.ts` — absent
328
- in a generated rig — › "%s leaves the take-up at the marker the write produced",
329
- an `it.each` over claim, comment, close and escalate. The
362
+ Under a declared run directory, every selection logs one versioned
363
+ `revalidation` detection — the same shape BEFORE_PR and BEFORE_CLOSE write.
364
+ **No-change is always recorded**, one line per selection and no sampling: the
365
+ rule is explicit so the report's `opportunities` is a count and not an estimate.
366
+ The `jira` and `github-issues` adapters still re-record take-ups after their own
367
+ writes for compatibility and attribution evidence; that state is never the
368
+ fingerprint baseline. The
330
369
  four-week view is `node .claude/scripts/revalidation-report.mjs --since <date>`,
331
370
  over this rig's `.claude/runs/` (or a `--runs <dir>`). The behaviour is pinned in the
332
- generator's `test/template/queue-revalidation.test.ts` — absent in a generated
333
- rig — › "an adapter with no marker records a blind spot, not \"unchanged\"", ›
334
- "a moved marker holds on task:updatedAt, re-snapshots, and journals the change"
335
- and › "the loop skill's outcome command records what the re-read concluded", and
336
- in `test/template/revalidation-evidence.test.ts`.
371
+ generator's `test/template/content-blind-revalidation.test.ts` (absent in a generated rig) › "creates a
372
+ versioned content-blind claim and returns BASELINE_CREATED" and › "defers an
373
+ added comment through SELECT and BEFORE_PR, then holds at BEFORE_CLOSE", plus
374
+ `test/template/revalidation-evidence.test.ts` (absent in a generated rig) › "the journaled events of SELECT,
375
+ BEFORE_PR and BEFORE_CLOSE share exactly the common keys and value types".
337
376
 
338
377
  **Then, before the Red step: `check-premises`.** The item was written by someone
339
378
  who was not reading the code at the time, and everything downstream — the failing
@@ -367,7 +406,7 @@ exits 1 whatever the reviewer said. Save the whole answer to a file under the ru
367
406
  directory — `$RIG_RUN_DIR/check-premises.md`, one file per gate so two answers
368
407
  never overwrite each other — and pass that path (`-` reads stdin instead). The
369
408
  same holds for every `<report>` in this skill, as `pr-ship` already does for its
370
- reviewers (AR-117). Pinned in the generator's `test/template/loop-report-file.test.ts`
409
+ reviewers. Pinned in the generator's `test/template/loop-report-file.test.ts`
371
410
  — absent in a generated rig — › "states that the report is a file the session
372
411
  writes from the subagent answer, before the first check".
373
412
 
@@ -412,8 +451,8 @@ reviewer verdict, an exhausted gate-round cap, a false premise in the item itsel
412
451
 
413
452
  The run-level conditions are in `stopConditionOf` in `core.mjs`, checked in
414
453
  severity order: **queue unreadable** · **runtime regression** · **kill switch** ·
415
- **two escalations in a row** · **budget** · **nothing selectable** · **queue
416
- empty**.
454
+ **revalidation hold** · **two escalations in a row** · **budget** · **nothing
455
+ selectable** · **queue empty**.
417
456
 
418
457
  🔴 **Their inputs come from a file, not from your memory — and that is why they
419
458
  fire at all.** `escalations` and `lastDeployVerdict` live in
@@ -563,7 +602,7 @@ inventory of findings** — and this had to be settled, because the two readings
563
602
  disagreed the first time a stop arrived without an inventory. An exhausted
564
603
  gate-round cap names its stage (the gate) and its wall (the branch's rounds are
565
604
  spent — the count, not a verdict on whether the fixes were converging, which the
566
- counter never measured; AR-115), while the individual blockers behind it are not persisted anywhere until
605
+ counter never measured), while the individual blockers behind it are not persisted anywhere until
567
606
  per-round verdicts exist. That is a `documented-stall`: the record locates the wall
568
607
  and the next reader knows where to look.
569
608
 
@@ -699,10 +738,13 @@ declared in §1. Five things about it are worth knowing before relying on it:
699
738
  so a stale record cannot read as the current one — which is the whole failure a
700
739
  journal exists to prevent.
701
740
  - ⚠ **The trace can stop before the run does, and the two failures part ways
702
- here.** A journal that can no longer accept records — a sequence already
703
- broken, a file that will not parse, a run already marked ended — is a lost
704
- trace, **not** a reason to withhold work the queue can still hand out: the
705
- selection prints, stderr carries a `run journal:` line, the exit code stays 0.
741
+ here.** After a durable claim exists, a journal that can no longer accept
742
+ records — a sequence already broken, a file that will not parse, a run already
743
+ marked ended — is a lost trace, **not** by itself a reason to withhold work the
744
+ queue can still hand out: the selection prints, stderr carries a `run journal:`
745
+ line, and the claim still decides drift. Before the first claim, the boundary
746
+ is stricter: an unreadable current or bounded sibling journal means SELECT
747
+ cannot prove first sight, so an absent claim is `UNVERIFIABLE` and exits 2.
706
748
  The refusals are the ones where nothing has happened yet and a second fixes
707
749
  it, and there are **four**: the declaration is empty, its directory does not
708
750
  exist, the path is not a directory, or the journal module is missing. Each
@@ -789,10 +831,10 @@ node --input-type=module -e '
789
831
 
790
832
  A proposal missing any of the four parts is refused rather than filed half-formed.
791
833
 
792
- **A finding can say what it measured and what it inferred, as two paired fields**
793
- (AR-142). A proposal whose premise was never true had no check at filing, only at
794
- take-up — AR-124 was filed, promoted and claimed before its platform conclusion
795
- was traced to a probe that had touched one hook. So `measured` and `inferred`
834
+ **A finding can say what it measured and what it inferred, as two paired fields.**
835
+ A proposal whose premise was never true had no check at filing, only at take-up
836
+ — one such proposal was filed, promoted and claimed before its platform
837
+ conclusion was traced to a probe that had touched one hook. So `measured` and `inferred`
796
838
  are separate, and `validateProposal` refuses an `inferred` that cites a path
797
839
  `measured` does not, naming both fields and the path; one field without the
798
840
  other is refused too, and neither files as before. The surface is a cited path
@@ -801,7 +843,7 @@ catches the path-shaped overreach and nothing subtler.
801
843
 
802
844
  ⚠ **The pair is how a proposal opts into the check, and a proposal filed without
803
845
  it is not checked at all** — `validateProposal` keeps the four-part contract, so
804
- the AR-124 shape with neither field still files as it always did. That is the
846
+ that shape with neither field still files as it always did. That is the
805
847
  stated limit, not an oversight: making the fields mandatory would refuse every
806
848
  proposal the three adapters already file, and the loop is the author this rule
807
849
  is for. So **every proposal this loop files carries both fields** — the snippet
@@ -822,7 +864,7 @@ reports one whose cited paths changed since its `asOf` as
822
864
  one git cannot diff from as `proposal-asof-unanswerable` — never as clean. Two
823
865
  proposals in a row once escalated `PREMISE FALSE` because the merge that
824
866
  falsified each landed after it was filed, and selection hands out the oldest
825
- first (AR-116). The behaviour is pinned in the generator's
867
+ first. The behaviour is pinned in the generator's
826
868
  `test/template/proposal-asof.test.ts` — absent in a generated rig — ›
827
869
  "names the overtaken one, the unanswerable one, and stays silent on the current one".
828
870
 
@@ -839,7 +881,7 @@ the selection query cannot reach. Add the heading — never the Agent queue.
839
881
 
840
882
  One adapter needs the second argument the snippet above carries: `jira` requires
841
883
  `options.project` and throws rather than filing without it — loudly, so nothing
842
- is lost, but a call that drops it files nothing (AR-117).
884
+ is lost, but a call that drops it files nothing.
843
885
 
844
886
  🔴 **The loop proposes; the owner patches.** Self-applying a change to its own
845
887
  rulebook is how an unattended run drifts irreversibly, and it collides head-on
@@ -875,20 +917,19 @@ three poisons the only channel by which this project learns.
875
917
  the very next query.
876
918
  - **Closing:** first ask whether the item is still the item you took up — a
877
919
  late comment or a status somebody else moved is not published as `Done`
878
- underneath it (AR-135):
920
+ underneath it:
879
921
 
880
922
  ```bash
881
923
  node .claude/scripts/revalidate.mjs --point BEFORE_CLOSE --ticket <item-id>
882
924
  ```
883
925
 
884
- It compares the item's marker against the newer of this run's last
885
- validation and its take-up — an adapter re-records the take-up after each
886
- write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
887
- hold the close; pinned in the generator's
888
- `test/template/self-inflicted-marker.test.ts` (absent in a generated rig) › "continues when the run’s own
889
- write moved the marker after the last validation" — and its
890
- state against the `in-progress` a close expects, journals one `revalidation`
891
- event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
926
+ It compares the tracked claim's `scope` and `commentary` fingerprint sets;
927
+ commentary becomes hold-authoritative only here. Marker/take-up movement is
928
+ retained in evidence but cannot decide drift. A missing claim is
929
+ `UNVERIFIABLE` and stops the close. The adapter's expected claimed state is
930
+ part of the same `claim:scope` comparison rather than a second state-drift
931
+ decision. The check journals one `revalidation` event at `point:
932
+ BEFORE_CLOSE` and lists the item's dependants with each
892
933
  one's state re-read for the write-back below — pinned in the generator's
893
934
  `test/template/revalidate.test.ts` (absent in a generated rig) › "appends
894
935
  exactly one BEFORE_CLOSE revalidation event after the BEFORE_PR one, and does
@@ -35,7 +35,7 @@ blockers.
35
35
  flow, and this gate with it, begins when the project has a remote
36
36
  (`workflow.md`, "PR flow"). The refusal exists because two rounds were once counted ahead of a
37
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
38
+ named a head that never shipped — pinned in the generator's
39
39
  `test/template/gate-rounds.test.ts` — absent in a generated rig — ›
40
40
  "refuses to count a round on a dirty tree, and counts nothing".
41
41
 
@@ -57,26 +57,33 @@ blockers.
57
57
  confidently-wrong reviews. Everything below is scoped to this diff.
58
58
 
59
59
  Then, on the fetched ref, ask whether the branch is still the branch the run
60
- took up (AR-134):
60
+ took up:
61
61
 
62
62
  ```sh
63
63
  node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
64
64
  ```
65
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
66
+ It runs the existing revalidation chain against the tracked, versioned
67
+ `.rig/claims/<item-id>.json`: the content-blind `scope` fingerprint set is
68
+ authoritative here, while `takeUps` / `updatedAt` remain evidence only. It
69
+ also names what the default branch changed since this branch forked on paths
70
+ the branch touches or a `check-premises` record cited (`main:<path>`). It
70
71
  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
72
  branch on that path, record what the re-read concluded —
72
73
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
73
74
  — 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.
75
+ report as a re-read the run skipped. A missing, untracked, unreadable or
76
+ unsupported claim is `UNVERIFIABLE`, exits 2, and stops automatic progress;
77
+ so is a tracker whose adapter the command cannot READ, which means the
78
+ question was never put rather than that the claim record is unreadable.
79
+ Neither is ever read as a pass. Exit 1 is the command refusing (unknown
80
+ point, no ticket, a base that is not a revision, or a queue config that does
81
+ not resolve): fix the call or the config — the message says which. Its limits are its own header's; the
82
+ cited-path set is a labelled assumption, not a recorded fact. Pinned in the
83
+ generator's `test/template/revalidate.test.ts` (absent in a generated rig) ›
84
+ "continues when only updatedAt moved and still reports the marker evidence"
85
+ and `test/template/content-blind-revalidation.test.ts` › "refuses a deleted
86
+ tracked claim in a fresh run without take-up markers".
80
87
  2. **Route the diff before you spend on it.** This gate always ran its most
81
88
  expensive path, so a typo fix in a README bought the same fan-out as a
82
89
  rewrite of the storage layer. The dispatcher decides which lane the change
@@ -12,17 +12,33 @@
12
12
  // to tokenise: it owns exactly one invariant and stays readable because of it
13
13
  // (see .claude/rules/invariants.md, "One invariant per hook"). Use a file rather
14
14
  // than a heredoc, or quote the example.
15
- import { readFileSync } from 'node:fs';
15
+ import { readHookInput, refusalText, shellCommandOf } from './lib/hook-input.mjs';
16
+ import { SHELL_TOOLS } from '../scripts/lib/shell-tools.mjs';
16
17
 
17
18
  function main() {
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- return 0;
19
+ const input = readHookInput();
20
+ if (input === null) return 0;
21
+ // One list, not a literal: this hook was launched for every shell surface
22
+ // and then excused itself from all but Bash, so the pre-commit gate stayed
23
+ // bypassable on the other one.
24
+ if (!SHELL_TOOLS.includes(input.tool_name)) return 0;
25
+ // Three outcomes, from the one shared contract (RP-80). `String(argv)` used
26
+ // to stand here, and it did not merely fail to read an array — it produced a
27
+ // plausible-looking string: `["git","commit","--no-verify"]` became
28
+ // `git,commit,--no-verify`, where the tokeniser below never splits on a comma,
29
+ // so the one flag this hook exists to refuse was silently absent. A guard that
30
+ // converts what it cannot read into something it can is worse than one that
31
+ // refuses, because it reports a check it did not perform.
32
+ const parsed = shellCommandOf(input);
33
+ if (parsed.kind === 'unreadable') {
34
+ process.stderr.write(`${refusalText(parsed)}\n`);
35
+ return 2;
23
36
  }
24
- if (input.tool_name !== 'Bash') return 0;
25
- const raw = String(input.tool_input?.command ?? '');
37
+ // Positive test, not a list of the others: a member added later must not
38
+ // reach `raw.replace(…)` below with `undefined`, which throws, exits 1, and
39
+ // is read as allow.
40
+ if (parsed.kind !== 'string') return 0;
41
+ const raw = parsed.command;
26
42
 
27
43
  // Strip quoted segments first: a commit message that merely MENTIONS a
28
44
  // forbidden flag is prose, not a bypass. Only unquoted flags count.