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
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * The run's own state — three of the four values `stopConditionOf` asks for and
3
- * nothing used to answer. The fourth, `killSwitch`, is deliberately not here:
3
+ * nothing used to answer, plus one value it does not ask for: the take-up
4
+ * snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
5
+ * the other three. The fourth stop input, `killSwitch`, is deliberately not here:
4
6
  * it is already mechanical in `guard-bash` and scripted in preflight, and a
5
7
  * second answer to "is the brake on" is the disagreement `invariants.md`
6
8
  * forbids.
@@ -47,8 +49,8 @@
47
49
  * per run is the caller's part of the contract, and the `loop` skill states it.
48
50
  */
49
51
 
50
- import { readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
51
- import { join } from 'node:path';
52
+ import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
53
+ import { basename, dirname, join } from 'node:path';
52
54
  import { fileURLToPath } from 'node:url';
53
55
 
54
56
  const STATE = 'state.json';
@@ -199,6 +201,102 @@ export const recordEscalation = (runDir) => {
199
201
  }
200
202
  };
201
203
 
204
+ /**
205
+ * The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
206
+ * seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
207
+ * against it, so a stale take-up is reported rather than silently continued.
208
+ *
209
+ * Per run, like everything else here — a snapshot from yesterday's run is not
210
+ * a take-up this run made. Merged by id, so a second item does not erase the
211
+ * first; re-recording an id moves its baseline forward. A marker that is not a
212
+ * string is not recorded at all: `plan-md` has none, and writing `null` would
213
+ * later compare equal to `null` and read as "unchanged".
214
+ *
215
+ * No run directory → nothing written, `null` back, no throw: an attended
216
+ * selection has no run to snapshot into. (Like {@link recordEscalation} it
217
+ * refuses to throw there; unlike it, there is no count to hand back, so `null`.)
218
+ */
219
+ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
220
+ if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
221
+ const state = readState(runDir);
222
+ const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
223
+ return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
224
+ };
225
+
226
+ /**
227
+ * The take-up an EARLIER run recorded for this item, or null (AR-138).
228
+ *
229
+ * {@link recordTakeUp} is per run on purpose, and that left a hole RX1
230
+ * measured: an item taken up yesterday and re-offered today compared against
231
+ * nothing and reported a first sight, however far its marker had moved. So
232
+ * when this run has no take-up for the item, SELECT asks the sibling run
233
+ * directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
234
+ * `loop` skill declares — and takes the first that recorded one. The answer
235
+ * names the run it came from, so the revalidation event can say whose
236
+ * baseline it compared against.
237
+ *
238
+ * 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
239
+ * skill declares — and so is the run asking. The first version took every
240
+ * sibling directory, and two measurements showed why that is wrong. On CI,
241
+ * with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
242
+ * directory carrying a `state.json` was read as yesterday's run, and a first
243
+ * sight came back as a hold against a marker nobody had taken up. Locally,
244
+ * the same temp root held 454 000 entries, and every SELECT paid a 540 ms
245
+ * directory read for a question it could not answer. So a run declared under
246
+ * another naming looks at no siblings at all, and the limit is the mirror
247
+ * image: an earlier run declared under another naming is not seen here.
248
+ *
249
+ * Bounded and fail-soft: the runs root is walked through one directory handle
250
+ * and at most 10 000 entries are looked at, whatever is in there; at most 200
251
+ * candidate runs are read, an unreadable state is skipped rather than trusted,
252
+ * and the answer is `null` for no run directory, an unnamed one, or no runs
253
+ * root. Never this run's own state — that is {@link readState}'s answer, and
254
+ * the caller asks it first.
255
+ */
256
+ const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
257
+ const RUNS_ROOT_ENTRY_BUDGET = 10_000;
258
+ const RUNS_READ_CAP = 200;
259
+
260
+ export const previousTakeUp = (runDir, id) => {
261
+ if (!runDir || id === undefined || id === null) return null;
262
+ const root = dirname(runDir);
263
+ const self = basename(runDir);
264
+ if (!RUN_DIR_NAME.test(self)) return null;
265
+ const names = [];
266
+ let dir;
267
+ try {
268
+ dir = opendirSync(root);
269
+ } catch {
270
+ return null;
271
+ }
272
+ try {
273
+ for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
274
+ const entry = dir.readSync();
275
+ if (entry === null) break;
276
+ if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
277
+ names.push(entry.name);
278
+ }
279
+ }
280
+ } catch {
281
+ return null;
282
+ } finally {
283
+ dir.closeSync();
284
+ }
285
+ names.sort().reverse();
286
+ for (const name of names.slice(0, RUNS_READ_CAP)) {
287
+ const candidate = join(root, name);
288
+ let state;
289
+ try {
290
+ state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
291
+ } catch {
292
+ continue;
293
+ }
294
+ const updatedAt = state?.takeUps?.[String(id)];
295
+ if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
296
+ }
297
+ return null;
298
+ };
299
+
202
300
  /**
203
301
  * The stop inputs, read out of a state object that anything may have written.
204
302
  *
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
21
21
  import { homedir, userInfo } from 'node:os';
22
22
  import { delimiter, join } from 'node:path';
23
23
 
24
- /** Every path that arms the brake. The machine-level default is always first. */
25
- export const stopFlags = (env = process.env) => {
26
- // BOTH homes: `homedir()` honours $HOME, which `.claude/settings.json` can set —
27
- // pointing it at an empty directory disarmed the brake. `userInfo()` reads the
28
- // password database and ignores the environment, so the operator's real flag is
29
- // always among the paths checked.
30
- const homes = new Set([homedir()]);
24
+ /**
25
+ * BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
26
+ * pointing it at an empty directory once disarmed the brake) and the password
27
+ * database's, which ignores the environment, so the operator's real flag is
28
+ * always among the paths checked. Shared with the unattended flag (AR-51): one
29
+ * lookup, one place to be wrong.
30
+ */
31
+ export const homesOf = (env = process.env) => {
32
+ const homes = new Set([env.HOME || homedir()]);
31
33
  try {
32
34
  homes.add(userInfo().homedir);
33
35
  } catch {
34
36
  // no password entry — the env-derived home is all there is
35
37
  }
36
- const paths = [...homes].map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
38
+ return [...homes];
39
+ };
40
+
41
+ /** Every path that arms the brake. The machine-level default is always first. */
42
+ export const stopFlags = (env = process.env) => {
43
+ const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
37
44
  const extra = env.AGENT_LOOP_STOP;
38
45
  if (extra) {
39
46
  // Filtered and CAPPED before the spread, never after. Spreading an
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ // The unattended flag — how a hook learns that a loop is running, and what the
3
+ // current item is allowed to touch (AR-51).
4
+ //
5
+ // node .claude/scripts/unattended-flag.mjs on --item AR-51 --run-dir <dir> --allow <prefix> [<prefix>…]
6
+ // node .claude/scripts/unattended-flag.mjs off
7
+ //
8
+ // It is a FILE, not an environment variable: a `PreToolUse` hook is spawned by
9
+ // the harness with the harness's own environment, never with a variable the
10
+ // session exported — the generator's `test/template/guard-rulebook.test.ts` ›
11
+ // "only a flag arms it — an exported RIG_UNATTENDED=1 with no flag changes
12
+ // nothing" pins that side of it — and in some harnesses an `export` does not
13
+ // even survive to the next Bash call. The kill switch (`stop-flag.mjs`) is a
14
+ // file for the same reason,
15
+ // and this module copies its shape: machine-level, under BOTH homes, so a
16
+ // worktree sees it and a `$HOME` set from `.claude/settings.json` cannot hide it.
17
+ //
18
+ // The flag is JSON, `{ item, runDir, allow }`. `allow` is the list of
19
+ // repo-relative prefixes the current item may write under even though they are
20
+ // part of the rulebook — the loop writes it at claim time from the paths the
21
+ // item names, and clears the flag when the run ends. A blanket "no rulebook
22
+ // edits while unattended" would stall on the first queue item that touches
23
+ // `queue/*.mjs`, which is most of this queue; the allow-list is what makes the
24
+ // guard livable, and the guard is what makes the allow-list a decision rather
25
+ // than a default.
26
+ //
27
+ // 🔴 Three answers, and the third is not the first: `{ on: false }` when no flag
28
+ // exists — an attended session, the guard does nothing; `{ on: true, item,
29
+ // runDir, allow }` when it reads; `{ on: true, unreadable: true, why }` when a
30
+ // file is THERE and this module cannot read it as the shape above. A guard that
31
+ // treated the third as the first would be disarmed by a corrupt flag, which is
32
+ // the fail-open bypass `.claude/rules/invariants.md` names.
33
+ //
34
+ // 🔴 An `allow` entry may not WIDEN the rulebook: one that is a prefix of a
35
+ // rulebook prefix — `.`, `.claude/`, `.claude/scripts/`, `CLAUDE` — would let
36
+ // the flag disarm the guard for a whole tree while it reports itself as on, so
37
+ // the writer refuses it and a flag carrying one is unreadable. An entry outside
38
+ // the rulebook (`src/`, `.claude/skills/loop/`) is harmless — such a path is
39
+ // never judged — and items name those all the time, so it is kept, not
40
+ // refused — › "an allow entry that widens the rulebook — a prefix of a rulebook prefix such as `.` — makes the flag unreadable".
41
+ //
42
+ // Bounded: the file is read up to 64 KiB, `allow` is capped at 64 entries, and
43
+ // both limits are refusals, never silent truncation.
44
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from 'node:fs';
45
+ import { dirname, join } from 'node:path';
46
+ import { realpathSync } from 'node:fs';
47
+ import { fileURLToPath } from 'node:url';
48
+ import { homesOf } from './stop-flag.mjs';
49
+
50
+ export const FLAG_BASENAME = '__PROJECT_NAME__-loop-UNATTENDED';
51
+ export const MAX_FLAG_BYTES = 64 * 1024;
52
+ export const MAX_ALLOW_ENTRIES = 64;
53
+
54
+ /**
55
+ * The files that decide what a session may do — repo-relative prefixes. Owned
56
+ * here because two things read them: the guard, to judge an edit, and the
57
+ * writer above, to refuse an allow-list that reaches outside them.
58
+ */
59
+ export const RULEBOOK_PREFIXES = Object.freeze([
60
+ '.claude/hooks/',
61
+ '.claude/settings.json',
62
+ '.claude/queue.json',
63
+ '.claude/scripts/queue/',
64
+ '.claude/scripts/decision-router.mjs',
65
+ '.claude/scripts/detect-missed-gate.mjs',
66
+ '.claude/rules/',
67
+ 'CLAUDE.md',
68
+ ]);
69
+
70
+ /** Is this repo-relative path part of the rulebook? */
71
+ export const isRulebookPath = (rel) =>
72
+ RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
73
+
74
+ /**
75
+ * Does this allow entry widen the rulebook — is it a proper prefix of a rulebook
76
+ * prefix, so that it would admit the whole prefix and more? `.claude/scripts/`
77
+ * widens (it covers `.claude/scripts/queue/` and its siblings); `src/` does not
78
+ * (it covers nothing the guard judges); `.claude/scripts/queue/` does not (it is
79
+ * exactly a rulebook prefix, the ordinary allow entry).
80
+ */
81
+ export const isWidening = (entry) =>
82
+ typeof entry !== 'string' ||
83
+ entry === '' ||
84
+ RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
85
+
86
+ /** Every path that arms unattended mode. The env-derived home is first. */
87
+ export const unattendedFlags = (env = process.env) =>
88
+ homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
89
+
90
+ const readCapped = (path) => {
91
+ const fd = openSync(path, 'r');
92
+ try {
93
+ const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
94
+ const bytes = readSync(fd, buffer, 0, buffer.length, 0);
95
+ return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
96
+ } finally {
97
+ closeSync(fd);
98
+ }
99
+ };
100
+
101
+ const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
102
+
103
+ /** The mode the flag declares — see the header for the three answers. */
104
+ export const readUnattended = (env = process.env) => {
105
+ const path = unattendedFlags(env).find((candidate) => {
106
+ try {
107
+ return existsSync(candidate);
108
+ } catch {
109
+ return false;
110
+ }
111
+ });
112
+ if (!path) return { on: false };
113
+ let raw;
114
+ try {
115
+ raw = readCapped(path);
116
+ } catch (error) {
117
+ return unreadable(path, `cannot be read: ${error?.code ?? 'read failed'}`);
118
+ }
119
+ if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(raw.text);
123
+ } catch {
124
+ return unreadable(path, 'not valid JSON');
125
+ }
126
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
127
+ return unreadable(path, 'not a JSON object');
128
+ }
129
+ if (!Array.isArray(parsed.allow) || parsed.allow.some((entry) => typeof entry !== 'string')) {
130
+ return unreadable(path, '`allow` is not an array of strings');
131
+ }
132
+ if (parsed.allow.length > MAX_ALLOW_ENTRIES) {
133
+ return unreadable(path, `\`allow\` carries more than ${MAX_ALLOW_ENTRIES} entries`);
134
+ }
135
+ const allow = parsed.allow.map((entry) => entry.trim()).filter(Boolean);
136
+ const wide = allow.find(isWidening);
137
+ if (wide !== undefined) {
138
+ return unreadable(
139
+ path,
140
+ `\`allow\` entry ${JSON.stringify(wide)} widens the rulebook (it is a prefix of a rulebook prefix) — an allow-list narrows the rulebook, never widens it`,
141
+ );
142
+ }
143
+ return {
144
+ on: true,
145
+ item: typeof parsed.item === 'string' ? parsed.item : null,
146
+ runDir: typeof parsed.runDir === 'string' ? parsed.runDir : null,
147
+ allow,
148
+ };
149
+ };
150
+
151
+ /** Write the flag under the env-derived home. Returns the paths written. */
152
+ export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
153
+ if (typeof item !== 'string' || item.trim() === '') {
154
+ throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
155
+ }
156
+ const list = (Array.isArray(allow) ? allow : []).map((entry) => String(entry).trim()).filter(Boolean);
157
+ if (list.length > MAX_ALLOW_ENTRIES) {
158
+ throw new Error(`the allow-list is capped at ${MAX_ALLOW_ENTRIES} entries`);
159
+ }
160
+ const wide = list.find(isWidening);
161
+ if (wide !== undefined) {
162
+ throw new Error(
163
+ `allow entry ${JSON.stringify(wide)} widens the rulebook — it is a prefix of one of ${RULEBOOK_PREFIXES.join(', ')}; ` +
164
+ 'an allow-list narrows the rulebook, never widens it. A directory entry needs its trailing slash ' +
165
+ '(`.claude/hooks/`, not `.claude/hooks`).',
166
+ );
167
+ }
168
+ const [path] = unattendedFlags(env);
169
+ mkdirSync(dirname(path), { recursive: true });
170
+ writeFileSync(path, `${JSON.stringify({ item: item.trim(), runDir, allow: list }, null, 2)}\n`);
171
+ return [path];
172
+ };
173
+
174
+ /** Remove every flag that exists. Returns the paths removed. */
175
+ export const clearUnattended = (env = process.env) => {
176
+ const removed = [];
177
+ for (const path of unattendedFlags(env)) {
178
+ try {
179
+ if (existsSync(path)) {
180
+ rmSync(path);
181
+ removed.push(path);
182
+ }
183
+ } catch {
184
+ // a home this process cannot write is not this run's flag to remove
185
+ }
186
+ }
187
+ return removed;
188
+ };
189
+
190
+ const invokedDirectly = () => {
191
+ if (!process.argv[1]) return false;
192
+ const real = (p) => {
193
+ try {
194
+ return realpathSync(p);
195
+ } catch {
196
+ return p;
197
+ }
198
+ };
199
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
200
+ };
201
+
202
+ if (invokedDirectly()) {
203
+ const [word, ...rest] = process.argv.slice(2);
204
+ const valueOf = (flag) => {
205
+ const index = rest.indexOf(flag);
206
+ return index === -1 ? null : (rest[index + 1] ?? null);
207
+ };
208
+ if (word === 'on') {
209
+ const item = valueOf('--item');
210
+ if (!item || item.startsWith('--')) {
211
+ process.stderr.write('unattended-flag on: --item <id> is required — the flag names the item whose paths are allowed\n');
212
+ process.exit(1);
213
+ }
214
+ const allowIndex = rest.indexOf('--allow');
215
+ const allow =
216
+ allowIndex === -1
217
+ ? []
218
+ : rest.slice(allowIndex + 1).filter((entry, i, all) => {
219
+ const stop = all.findIndex((e) => e.startsWith('--'));
220
+ return stop === -1 || i < stop;
221
+ });
222
+ let path;
223
+ try {
224
+ [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow });
225
+ } catch (error) {
226
+ process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
227
+ process.exit(1);
228
+ }
229
+ process.stdout.write(`${path}\n`);
230
+ process.exit(0);
231
+ }
232
+ if (word === 'off') {
233
+ const removed = clearUnattended();
234
+ process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
235
+ process.exit(0);
236
+ }
237
+ process.stderr.write(`unknown word: ${word ?? '(none)'}. This CLI has two: on, off.\n`);
238
+ process.exit(1);
239
+ }
@@ -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