create-agent-rig 0.5.0 → 0.6.1

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 (66) hide show
  1. package/CHANGELOG.md +170 -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 +15 -5
  10. package/templates/agent-os/init/CLAUDE.md +15 -5
  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 +330 -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 +18 -6
  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 +42 -17
  23. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  24. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
  25. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  26. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
  27. package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
  28. package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
  29. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
  30. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
  31. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  32. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  33. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
  34. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  35. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  37. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  40. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  41. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
  42. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
  43. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  44. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
  45. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
  46. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  47. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  48. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
  49. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  50. package/templates/agent-os/universal/.claude/settings.json +5 -1
  51. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  52. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
  53. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  54. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  55. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  56. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  57. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  58. package/templates/agent-os/universal/AGENTS.md +3 -1
  59. package/templates/agent-os/universal/CLAUDE.md +3 -1
  60. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  61. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  62. package/templates/agent-os/universal/layers.json +9 -0
  63. package/templates/hash-history.json +404 -51
  64. package/templates/release-ledger.json +10 -0
  65. package/templates/skeleton/aws-serverless/gitignore +2 -0
  66. package/templates/skeleton/node-service/gitignore +2 -0
@@ -3,11 +3,18 @@
3
3
  * The verdict CLI — what a gate runs before it believes a reviewer.
4
4
  *
5
5
  * node .claude/scripts/verdict.mjs check <file> [gate] # `-` reads stdin
6
+ * node .claude/scripts/verdict.mjs coverage <commit> # reads the run journal
6
7
  *
7
- * It reads a gate's report, hands it to `lib/verdict.mjs`, and either prints the
8
- * parsed verdict on stdout (exit 0) or refuses with a diagnosis on stderr
8
+ * `check` reads a gate's report, hands it to `lib/verdict.mjs`, and either prints
9
+ * the parsed verdict on stdout (exit 0) or refuses with a diagnosis on stderr
9
10
  * (exit 1). All the deciding lives in the module; this file is the call site.
10
11
  *
12
+ * `coverage` answers the question one report cannot: did every reviewer this
13
+ * round asked for actually answer, for the commit being merged? It compares the
14
+ * three sets the run journal already holds — routed, launched, answered — through
15
+ * `lib/gate-coverage.mjs`, and it is a READ. It never launches a reviewer and it
16
+ * never writes.
17
+ *
11
18
  * 🔴 **Name the gate you launched.** The module reads the report's LAST block
12
19
  * (its limit 3), so a capture holding two reviewers' answers end to end says
13
20
  * only what the second one said — a stop that vanishes behind a later pass.
@@ -27,11 +34,15 @@
27
34
 
28
35
  import { readFileSync } from 'node:fs';
29
36
 
30
- import { parseVerdict, safeForDiagnosis } from './lib/verdict.mjs';
37
+ import { coverageOf } from './lib/gate-coverage.mjs';
38
+ import { isCommitId, parseVerdict, safeForDiagnosis } from './lib/verdict.mjs';
39
+ import { readRun } from './run-journal.mjs';
31
40
 
32
41
  const USAGE =
33
42
  'usage: node .claude/scripts/verdict.mjs check <file> [gate] ' +
34
- '(`-` reads the report from stdin)\n';
43
+ '(`-` reads the report from stdin)\n' +
44
+ ' node .claude/scripts/verdict.mjs coverage <commit> ' +
45
+ '(reads the run journal in $RIG_RUN_DIR)\n';
35
46
 
36
47
  const refuse = (message) => {
37
48
  process.stderr.write(message);
@@ -58,6 +69,92 @@ const [subcommand, source, expectedGate] = process.argv.slice(2);
58
69
  // either: an operator told the subcommand is unknown goes looking for a typo
59
70
  // that is not there.
60
71
  if (subcommand === undefined) refuse(USAGE);
72
+
73
+ if (subcommand === 'coverage') {
74
+ // The four cases and the fix each one needs, said in the line that names the
75
+ // reviewer — a single "missing" list makes the reader guess between
76
+ // relaunching a reviewer and going to read why one stayed silent.
77
+ const CASES = [
78
+ ['neverLaunched', 'the route asked for it and the fan-out never launched it — launch it'],
79
+ ['unanswered', 'launched, and it did not answer — no verdict of its own parsed'],
80
+ ['unattributed', 'it answered, and its verdict named no commit — so it cannot say it answered for this one'],
81
+ ['stale', 'it answered for another commit — the head moved after the verdict'],
82
+ ];
83
+
84
+ const commit = source;
85
+ // The same two arms `check` keeps apart: the subcommand was right and the
86
+ // argument was not supplied. Reporting the opposite sends the operator
87
+ // looking for a typo that is not there.
88
+ if (commit === undefined) {
89
+ refuse(
90
+ 'verdict: `coverage` needs the commit the round is about; no commit was given. ' +
91
+ 'It is the head the reviewers were launched against — `git rev-parse HEAD` in the ' +
92
+ `reviewed checkout.\n${USAGE}`,
93
+ );
94
+ }
95
+
96
+ // The one commit field this command owns, and the only one shaped before it is
97
+ // compared: the journal is the other way in and `recordDecision` takes any
98
+ // non-blank string, which is why `sameCommit` enforces its own floor and
99
+ // ceiling rather than trusting an upstream check. Without this arm
100
+ // `coverage <a-full-sha>garbage` prefix-matched its way to "covered" — the
101
+ // answer that ends in a merge.
102
+ if (!isCommitId(commit)) {
103
+ refuse(
104
+ `verdict: \`${safeForDiagnosis(commit)}\` is not a commit to ask about. ` +
105
+ 'It is 7 to 64 hex characters (0-9a-f), the same shape a verdict may name — ' +
106
+ '`git rev-parse HEAD` in the reviewed checkout.\n',
107
+ );
108
+ }
109
+
110
+ const runDir = process.env.RIG_RUN_DIR;
111
+ if (!runDir) {
112
+ // 🔴 Exit 0 with nothing printed is indistinguishable from a clean round,
113
+ // and an unattended session reads it as one. The skip is the honest answer —
114
+ // this run kept no trace — and it has to be said out loud.
115
+ process.stdout.write(
116
+ 'verdict: coverage skipped — no run directory is declared (RIG_RUN_DIR is unset), so ' +
117
+ 'this run journalled no fan-out and no verdicts. Nothing was checked, which is not ' +
118
+ 'the same as nothing being outstanding.\n',
119
+ );
120
+ process.exit(0);
121
+ }
122
+
123
+ let decisions;
124
+ try {
125
+ ({ decisions } = readRun({ runDir }));
126
+ } catch (error) {
127
+ refuse(
128
+ `verdict: the run journal in ${runDir} could not be read, so coverage was not ` +
129
+ `checked (${error?.message ?? 'unknown error'}).\n`,
130
+ );
131
+ }
132
+
133
+ const coverage = coverageOf({ records: decisions, headSha: commit });
134
+ if (coverage.ok) {
135
+ process.stdout.write(
136
+ `verdict: coverage complete for ${safeForDiagnosis(commit)} — ` +
137
+ `${coverage.launched.length} reviewer(s) launched, every one of them answered for ` +
138
+ 'that commit.\n',
139
+ );
140
+ process.exit(0);
141
+ }
142
+
143
+ const lines = [];
144
+ if (coverage.reason !== undefined) lines.push(` ${coverage.reason}`);
145
+ for (const [key, why] of CASES) {
146
+ // Through the sanitiser like every other quoted value here: the names come
147
+ // from the fan-out record, which `recordDecision` checks as strings and
148
+ // nothing more, and a name carrying a cursor sequence repaints this refusal
149
+ // as a pass for whoever is watching the scrollback.
150
+ for (const reviewer of coverage[key]) lines.push(` ${safeForDiagnosis(reviewer)} — ${why}`);
151
+ }
152
+ refuse(
153
+ `verdict: the fan-out for ${safeForDiagnosis(commit)} is not covered.\n` +
154
+ `${lines.join('\n')}\n`,
155
+ );
156
+ }
157
+
61
158
  if (subcommand !== 'check') {
62
159
  refuse(`verdict: \`${subcommand}\` is not a subcommand of this tool.\n${USAGE}`);
63
160
  }
@@ -2,7 +2,7 @@
2
2
  "hooks": {
3
3
  "PreToolUse": [
4
4
  {
5
- "matcher": "Write|Edit|apply_patch",
5
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
@@ -15,6 +15,10 @@
15
15
  {
16
16
  "type": "command",
17
17
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-secret-file.mjs\""
18
+ },
19
+ {
20
+ "type": "command",
21
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-rulebook.mjs\""
18
22
  }
19
23
  ]
20
24
  },
@@ -173,7 +173,8 @@ inside the block; both forms are the contract.
173
173
  }
174
174
  ],
175
175
  "advisories": [],
176
- "evidence": ["read the validator and both call sites"]
176
+ "evidence": ["read the validator and both call sites"],
177
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
177
178
  }
178
179
  ```
179
180
 
@@ -187,6 +188,8 @@ inside the block; both forms are the contract.
187
188
  and the caller is what checks: the `loop` skill runs
188
189
  `node .claude/scripts/verdict.mjs check <report> check-premises` on what you
189
190
  return. Nothing in this skill runs it — this skill writes nothing at all.
191
+ - **`headSha` is the commit you read** — `git rev-parse HEAD` in the checkout
192
+ you examined. A premise check answers about a tree, and the tree moves.
190
193
 
191
194
  ## Examples — the three shapes this actually catches
192
195
 
@@ -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
@@ -39,6 +39,28 @@ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies
39
39
  (`JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`) and never from a file in the
40
40
  repo; the project or the JQL goes in `.claude/queue.json`.
41
41
 
42
+ A config may declare several boards (`boards: { <name>: options }` plus a
43
+ default `board`); the active one is chosen per checkout, not by editing the
44
+ composed file:
45
+
46
+ ```bash
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
49
+ ```
50
+
51
+ The selector is per-checkout runtime state, the same class as
52
+ `.claude/queue.state.json`: it needs its own `.gitignore` line, which a generated
53
+ project ships and an `init`-installed rig adds by hand. An undeclared name is
54
+ refused, never read as "no board" (see `test/template/queue-board.test.ts` ›
55
+ "refuses a board nobody declared instead of falling back" — in the generator,
56
+ absent in a generated rig). It is a rulebook path for `guard-rulebook`:
57
+ `.claude/queue.board` is refused even when an item allow-list names it,
58
+ and the `board` command itself refuses a switch while the checkout is unattended. This
59
+ does not prevent an arbitrary direct shell write to the selector — edit-tool
60
+ hooks cannot see one. `.claude/queue.state.json` stays per config, not per board:
61
+ the tier the last close recorded rations the next selection whichever board it
62
+ lands on.
63
+
42
64
  Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
43
65
  decision and each adapter only maps its tracker's records onto the neutral shape.
44
66
 
@@ -53,13 +75,13 @@ state-vs-queue split exists to prevent.
53
75
  node .claude/scripts/preflight.mjs
54
76
  ```
55
77
 
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
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
59
81
  leaves no record cannot tell you it was skipped.
60
82
 
61
83
  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
84
+ knowing which ground is soft. **GO** → the scripted four are clean; the rest are
63
85
  still yours.
64
86
 
65
87
  **An `unknown` never becomes a `pass`.** A probe that could not run tells you
@@ -87,6 +109,43 @@ export RIG_RUN_DIR="$PWD/.claude/runs/$(date +%Y%m%d-%H%M%S)" # one per run
87
109
  mkdir -p "$RIG_RUN_DIR"
88
110
  ```
89
111
 
112
+ ⚠ **That export reaches the commands THIS shell runs and nothing else.** A
113
+ `PreToolUse` hook is spawned by the harness with the harness's own environment,
114
+ never with a variable the session exported — pinned in the generator's
115
+ `test/template/guard-rulebook.test.ts` (absent in a generated rig) › "only a
116
+ 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:
120
+
121
+ ```bash
122
+ # at claim time, from the paths the item names (repo-relative prefixes, with
123
+ # their trailing slash); the guard refuses every other rulebook edit while it is on
124
+ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
125
+ ```
126
+
127
+ `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
128
+ on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
129
+ rulebook — both harnesses' rules, skills, agents and hook wiring, plus their
130
+ scripts, queue config and integrity manifest — is refused unless its
131
+ path starts with an allowed prefix; the board selector is the one always-refused
132
+ exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
133
+ item that needs a rulebook path names it here — a decision made at claim
134
+ time, never a default — and the stop step below turns the flag off. Pinned in
135
+ the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
136
+ rig — › "blocks a hook-config edit with an empty allow-list, naming path, item and the rule".
137
+
138
+ ⚠ **The export outlives the run's own calls.** Everything the session spawns
139
+ 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
143
+ 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
146
+ into a generated rig**: here, nothing scrubs it, and a rig's own test setup is
147
+ the place to do the same.
148
+
90
149
  🔴 **One directory per run, never shared and never reused — and the journal
91
150
  cannot enforce this for you.** A collision or an already-ended directory is
92
151
  refused loudly; two runs whose records merely do not collide are merged into one
@@ -127,6 +186,60 @@ not deleting the old marker, and the silent resolution goes to the **less**
127
186
  restrictive gate. Until that is fixed, treat a double-marked item as
128
187
  human-gated by hand.
129
188
 
189
+ 🔴 **An item marked for another repository is held, never taken.** A label
190
+ `owner-<name>` names the repository an item belongs to; the checkout names
191
+ itself in `options.owner` of `.claude/queue.json`, and a mismatch — or an
192
+ owned item in a checkout that declares no owner — is the holding cause `owner`,
193
+ reported by `hygiene` as `owner-mismatch`. It clears the way `trigger-human`
194
+ does: a human moves the item or re-marks it. An unmarked item is unconditional.
195
+ Two items of another product once entered this queue as normal spacers and
196
+ 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
198
+ `test/template/queue-owner.test.ts` — absent in a generated rig — › "holds an
199
+ item whose owner is another repository, with the cause named".
200
+
201
+ 🔴 **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
203
+ means the same thing — three of lifecycle, one of scheduling:
204
+
205
+ - `keep-core` — the problem and the responsibility are valid and the item is
206
+ executable as written. A statement about the item, never a condition on taking
207
+ it: a bare `keep-core` item is selected like an unmarked one.
208
+ - `re-scope` — the problem is valid but the item is **not executable
209
+ literally**: a path, a mechanism, a boundary or an acceptance criterion has
210
+ drifted. It is the holding cause `re-scope`, reported by `hygiene` as
211
+ `re-scope-pending` until it clears — and it clears **only** by a human
212
+ re-reading the code, rewriting the item and removing the label. It is a
213
+ short-lived quarantine, not a backlog category, and never a synonym for
214
+ obsolete, low priority or parked. The loop surfaces it and never rewrites it:
215
+ a re-aimed item is work the agent authored (§8).
216
+ - `obsolete` — the responsibility is gone or fully superseded by a proven
217
+ mechanism. A **human verdict, terminal**: the loop never applies it and never
218
+ closes an item because it believes another mechanism supersedes it. Closing as
219
+ obsolete needs external evidence and a comment naming the replacement or the
220
+ reason — so an obsolete item is out of play (the cause `obsolete`), not held,
221
+ and the stop line says so.
222
+ - `parked` — valid work deliberately not active now. The **scheduling** axis,
223
+ orthogonal to the three above: `keep-core + parked` is the ordinary shape of a
224
+ deferred item and means "still needed, not now". It is the holding cause
225
+ **`deferred`**, freed by a human un-parking it. The cause is not spelled
226
+ `parked` on purpose: §3 already uses that word for the **out-of-play pile** —
227
+ items waiting on a human that no session will take — and a parked-labelled
228
+ item is the opposite, held and takeable. So the stop line reads "held by
229
+ deferred" for the label, and "are parked" for the pile; `obsolete` lands in
230
+ the pile, the `parked` label never does.
231
+
232
+ The rule under all four: **nothing infers `obsolete`** — not age, not a key
233
+ range, old terminology, `parked`, absence from a roadmap, or a migration marker.
234
+ `legacy-backlog` is that marker, retired: `hygiene` reports an open item still
235
+ carrying it (`stale-legacy-backlog-label`), and two lifecycle labels on one item
236
+ (`contradictory-lifecycle-labels`) — selection meanwhile reads the most
237
+ restrictive one. Reported, never corrected: which label is wrong is the human's
238
+ call. Pinned in the generator's `test/template/queue-lifecycle.test.ts` — absent
239
+ in a generated rig — › "holds a re-scope item and says a human rewrites it" and
240
+ › "refuses an obsolete item and says a human closes it with a comment naming the
241
+ evidence".
242
+
130
243
  **For a `trigger-auto` item, record the declaration** — it has to outlive the
131
244
  turn it was made in, or the next selection holds the item back again:
132
245
 
@@ -168,6 +281,60 @@ and the work turns out to touch an elevated path (`CLAUDE.md` →
168
281
  `elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
169
282
  as this run's elevated item for spacing.
170
283
 
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:
300
+
301
+ ```bash
302
+ node .claude/scripts/revalidate.mjs outcome --point SELECT --ticket <item-id> --action-changed <true | false> --note '<what changed, or why it changes nothing>'
303
+ ```
304
+
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
307
+ note is stored verbatim from argv, so keep it in single quotes: inside double
308
+ quotes the shell expands a backtick or a `$` before the command sees it.
309
+ Nothing forces this record — a `revalidation` event with no matching outcome is
310
+ counted as `unresolved`, which is the honest word for a re-read the run skipped.
311
+
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
330
+ four-week view is `node .claude/scripts/revalidation-report.mjs --since <date>`,
331
+ 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`.
337
+
171
338
  **Then, before the Red step: `check-premises`.** The item was written by someone
172
339
  who was not reading the code at the time, and everything downstream — the failing
173
340
  test, the implementation, the reviewer comparing diff to item — inherits its
@@ -193,10 +360,50 @@ returns one fenced `json` verdict like every other gate, and this loop is its ca
193
360
  node .claude/scripts/verdict.mjs check <report> check-premises
194
361
  ```
195
362
 
363
+ **`<report>` is a file you write, not one the harness leaves behind.** The
364
+ subagent's answer arrives as text in the conversation; its transcript on disk is
365
+ a JSONL file whose last fenced block does not parse, so pointing the check at it
366
+ exits 1 whatever the reviewer said. Save the whole answer to a file under the run
367
+ directory — `$RIG_RUN_DIR/check-premises.md`, one file per gate so two answers
368
+ never overwrite each other — and pass that path (`-` reads stdin instead). The
369
+ 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`
371
+ — absent in a generated rig — › "states that the report is a file the session
372
+ writes from the subagent answer, before the first check".
373
+
196
374
  Exit 1 means it did not answer: a stop verdict naming no premise, or no block at all.
197
375
  That is `incomplete` — neither "the premises hold" nor a reason to escalate — so run
198
376
  the pass again rather than reading the silence as a pass.
199
377
 
378
+ **Then journal the block that parsed**, exactly as `pr-ship` journals a reviewer's —
379
+ the paths a premise check names are what `revalidate.mjs` reads at BEFORE_PR as the
380
+ task's cited paths, and a verdict held only in context cites nothing after a
381
+ compaction:
382
+
383
+ ```sh
384
+ node --input-type=module -e '
385
+ const runDir = process.env.RIG_RUN_DIR;
386
+ if (!runDir) process.exit(0); // an undeclared run has no trace to write
387
+ if (!process.argv[1]) process.exit(0); // `check` printed nothing: nothing to record
388
+ const journal = await import("./.claude/scripts/run-journal.mjs");
389
+ const v = JSON.parse(process.argv[1]);
390
+ try {
391
+ console.log(journal.recordDecision({
392
+ runDir,
393
+ gate: "check-premises",
394
+ verdict: v.verdict,
395
+ blockers: v.blockers,
396
+ headSha: v.headSha,
397
+ now: new Date().toISOString(),
398
+ }));
399
+ } catch (error) {
400
+ // The same split pr-ship makes: an exhausted trace is over, the task is not.
401
+ if (!journal.isTraceExhausted?.(error)) throw error;
402
+ process.stderr.write(`run journal: ${error.message}\n the premise verdict above was NOT recorded.\n`);
403
+ }
404
+ ' "$(node .claude/scripts/verdict.mjs check <report> check-premises)"
405
+ ```
406
+
200
407
  ## 3. What keeps the loop running, and what stops it
201
408
 
202
409
  Per-task stops (three strikes, attempt budget, invariant conflict, a blocking
@@ -282,10 +489,13 @@ Four of them deserve their reasons repeated:
282
489
  queue**: the elevated spacing (a normal or prose-only item lands), a blocker (its item
283
490
  closes), in-progress (the other session finishes), a trigger (a human
284
491
  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
492
+ §2, so this is the one hold that needs a command rather than only time), and
493
+ an owner (§2: the item is another repository's, and a human moves it or
494
+ re-marks it — neither time nor interleaving frees it). The
286
495
  stop line names how many and by which, because the two endings ask the owner
287
496
  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
497
+ interleaving, time, or — for a trigger or an owner — a human act the line
498
+ names. 🔴 **A parked cause outranks a holding one on the
289
499
  same item** — an escalated item is left claimed on purpose, so it arrives
290
500
  carrying `in-progress` too. **Neither ending is an invitation to refill the
291
501
  queue or invent work.** Why the two are split, and how the parked pile grows
@@ -351,8 +561,9 @@ perfectly good reason having produced something nobody should build on.
351
561
  🔴 **What `documented-stall` requires is the STAGE and the wall, not a full
352
562
  inventory of findings** — and this had to be settled, because the two readings
353
563
  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
564
+ gate-round cap names its stage (the gate) and its wall (the branch's rounds are
565
+ 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
356
567
  per-round verdicts exist. That is a `documented-stall`: the record locates the wall
357
568
  and the next reader knows where to look.
358
569
 
@@ -525,6 +736,23 @@ If no run directory was declared, there is nothing to close and this step is
525
736
  skipped — say so in the journal entry rather than leaving the reader to guess
526
737
  which of the two happened.
527
738
 
739
+ **And turn the unattended flag off** — it outlives the run otherwise, and the
740
+ next attended session would find its rulebook edits refused in the name of an
741
+ item nobody is working:
742
+
743
+ ```bash
744
+ node .claude/scripts/unattended-flag.mjs off --root "$PWD"
745
+ ```
746
+
747
+ If that command reports a legacy machine-wide flag, it deliberately leaves a
748
+ foreign pre-upgrade authorization in place and the checkout stays fail-closed.
749
+ Inspect the exact reported record and confirm that no pre-upgrade run still uses
750
+ it, then remove only that record with
751
+ `node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>`.
752
+ Run scoped `off --root "$PWD"` again to surface the next record, and repeat the
753
+ inspection one at a time; do not record the flag as off until the scoped command
754
+ succeeds.
755
+
528
756
  At every **stop** — not at a checkpoint — turn the run's findings into **at most
529
757
  three** improvement proposals. **The cap is the mechanism, not a budget:** an
530
758
  unbounded improvement list is another diary, and three forces a choice. Each names
@@ -550,12 +778,54 @@ node --input-type=module -e '
550
778
  part: "<skill | agent | hook | rule | CLAUDE.md | workflow>",
551
779
  change: "<concretely enough to diff>",
552
780
  proof: "<the observation that would differ next run>",
553
- }));
781
+ // a pair: what the probe touched, and what is concluded from it. The
782
+ // mechanism accepts a proposal without them; this procedure does not.
783
+ measured: "<the paths the probe actually exercised>",
784
+ inferred: "<the conclusion, citing only surfaces named in measured>",
785
+ }, { project: "<KEY>" })); // jira only — the ACTIVE board's key: `queue/index.mjs board --json` → options.project;
786
+ // plan-md and github-issues take no second argument
554
787
  '
555
788
  ```
556
789
 
557
790
  A proposal missing any of the four parts is refused rather than filed half-formed.
558
791
 
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`
796
+ are separate, and `validateProposal` refuses an `inferred` that cites a path
797
+ `measured` does not, naming both fields and the path; one field without the
798
+ other is refused too, and neither files as before. The surface is a cited path
799
+ (`citedPathsOf`), so a conclusion that names no path passes this check — it
800
+ catches the path-shaped overreach and nothing subtler.
801
+
802
+ ⚠ **The pair is how a proposal opts into the check, and a proposal filed without
803
+ 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
805
+ stated limit, not an oversight: making the fields mandatory would refuse every
806
+ proposal the three adapters already file, and the loop is the author this rule
807
+ is for. So **every proposal this loop files carries both fields** — the snippet
808
+ above supplies them, and a stop that cannot say what it measured has nothing
809
+ to propose. A reviewer reading a filed proposal without the pair reads a
810
+ proposal that skipped this procedure. Pinned in the generator's
811
+ `test/template/queue.test.ts` — absent in a generated rig — › "refuses a
812
+ proposal whose inference names a surface its measurement did not touch", ›
813
+ "files a proposal whose inference stays inside what it measured", › "refuses one
814
+ of the two fields without the other" and › "a proposal with neither field files
815
+ as today".
816
+
817
+ **The filed item also records the commit it was measured against** — an `asOf:`
818
+ line, HEAD of this checkout unless the call passes its own `asOf` (`null` files
819
+ without one). It is there for `hygiene`, which lists the proposals on file and
820
+ reports one whose cited paths changed since its `asOf` as
821
+ `proposal-possibly-overtaken`, one with no `asOf` as `proposal-asof-missing`, and
822
+ one git cannot diff from as `proposal-asof-unanswerable` — never as clean. Two
823
+ proposals in a row once escalated `PREMISE FALSE` because the merge that
824
+ 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
826
+ `test/template/proposal-asof.test.ts` — absent in a generated rig — ›
827
+ "names the overtaken one, the unanswerable one, and stays silent on the current one".
828
+
559
829
  **All three adapters write it themselves** — `jira` and `github-issues` create a
560
830
  `triage`-labelled issue, `plan-md` appends a bullet to the **Operator queue**, and
561
831
  each increments an existing proposal carrying the same fingerprint rather than
@@ -567,9 +837,9 @@ rather than a step in the procedure: `plan-md` returns it when the plan file has
567
837
  no `## Operator queue` heading, because a proposal then has nowhere to land that
568
838
  the selection query cannot reach. Add the heading — never the Agent queue.
569
839
 
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.
840
+ One adapter needs the second argument the snippet above carries: `jira` requires
841
+ `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).
573
843
 
574
844
  🔴 **The loop proposes; the owner patches.** Self-applying a change to its own
575
845
  rulebook is how an unattended run drifts irreversibly, and it collides head-on
@@ -603,9 +873,53 @@ three poisons the only channel by which this project learns.
603
873
  that creates the branch or worktree. Not when the PR opens. An item being worked
604
874
  while it still reads as available is invisible to the human and re-selectable by
605
875
  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:
876
+ - **Closing:** first ask whether the item is still the item you took up — a
877
+ late comment or a status somebody else moved is not published as `Done`
878
+ underneath it (AR-135):
879
+
880
+ ```bash
881
+ node .claude/scripts/revalidate.mjs --point BEFORE_CLOSE --ticket <item-id>
882
+ ```
883
+
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
892
+ one's state re-read for the write-back below — pinned in the generator's
893
+ `test/template/revalidate.test.ts` (absent in a generated rig) › "appends
894
+ exactly one BEFORE_CLOSE revalidation event after the BEFORE_PR one, and does
895
+ not end the run", › "runs the BEFORE_CLOSE revalidation before the close call
896
+ and reads a hold as a stop" and › "re-reads each dependant's state, and names
897
+ one the tracker no longer offers". On a `github-issues` queue that list is
898
+ empty: a single `gh issue view` carries no cross-index, so `find` answers no
899
+ `blocks` there (`test/template/close-transitioned.test.ts` (absent in a generated rig) › "github asks `gh
900
+ issue view` with the full field list and maps CLOSED to closed"). A
901
+ hold (exit 2) stops the close: re-read the item, record the outcome with
902
+ `node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
903
+ <item-id> --action-changed <true | false> --note '…'`, and close only if the
904
+ re-read leaves the action standing. Then call the
905
+ adapter's `close(ticket, { prUrl, transitionId })` with the merged PR linked,
906
+ immediately after the post-merge verdict — not in a cleanup pass — and read
907
+ its answer: `ok: true` says the call ran, and only `transitioned` set to
908
+ `true` says the close landed, because every adapter reads the item back after
909
+ the transition — `jira` the status category after the POST, `github-issues`
910
+ `gh issue view --json state`, `plan-md` the line being there and then gone
911
+ (the generator's `test/template/close-transitioned.test.ts` (absent in a generated rig) › "GETs the issue
912
+ status after the transition POST and reports transitioned: true when the
913
+ category is done", › "runs `issue view <id> --json state` after `issue close`
914
+ and reports transitioned: true on CLOSED", › "reports transitioned: true once
915
+ the item's line is gone"). A close whose result says `transitioned: false` is
916
+ not a close: report it, and leave the item as the adapter left it — `jira` its
917
+ status, `github-issues` its `in-progress` label, which comes off only after a
918
+ read-back that says CLOSED (› "leaves the in-progress label on an issue whose
919
+ close did not land"), `plan-md` nothing, because the line was never there. The
920
+ tier below is recorded only for a close that transitioned; a close that did
921
+ not is not the "something landed" the escalation streak resets on. **Record
922
+ the tier in the same step**, because the next selection rations on it:
609
923
 
610
924
  ```bash
611
925
  node --input-type=module -e '