create-agent-rig 0.6.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 (30) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/templates/agent-os/init/AGENTS.md +4 -2
  5. package/templates/agent-os/init/CLAUDE.md +4 -2
  6. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
  9. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  12. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  13. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  14. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  15. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  16. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  17. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  19. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  21. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
  22. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
  26. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  27. package/templates/hash-history.json +132 -39
  28. package/templates/release-ledger.json +2 -1
  29. package/templates/skeleton/aws-serverless/gitignore +2 -0
  30. package/templates/skeleton/node-service/gitignore +2 -0
@@ -1,19 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  // The unattended flag — how a hook learns that a loop is running, and what the
3
3
  // current item is allowed to touch (AR-51).
4
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
4
5
  //
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
6
+ // node .claude/scripts/unattended-flag.mjs on --root <checkout> --item AR-51 --run-dir <dir> --allow <prefix> [<prefix>…]
7
+ // node .claude/scripts/unattended-flag.mjs off --root <checkout>
8
+ // node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>
7
9
  //
8
10
  // It is a FILE, not an environment variable: a `PreToolUse` hook is spawned by
9
11
  // 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` ›
12
+ // session exported — the generator's `test/template/guard-rulebook.test.ts` (absent in a generated rig)
11
13
  // "only a flag arms it — an exported RIG_UNATTENDED=1 with no flag changes
12
14
  // nothing" pins that side of it — and in some harnesses an `export` does not
13
15
  // even survive to the next Bash call. The kill switch (`stop-flag.mjs`) is a
14
16
  // 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
+ // and this module copies its two-home lookup. Unlike the machine-wide brake,
18
+ // each unattended record is scoped to the canonical checkout, so concurrent
19
+ // worktrees cannot overwrite or clear one another's authorization.
17
20
  //
18
21
  // The flag is JSON, `{ item, runDir, allow }`. `allow` is the list of
19
22
  // repo-relative prefixes the current item may write under even though they are
@@ -35,15 +38,33 @@
35
38
  // rulebook prefix — `.`, `.claude/`, `.claude/scripts/`, `CLAUDE` — would let
36
39
  // the flag disarm the guard for a whole tree while it reports itself as on, so
37
40
  // 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
+ // the rulebook (`src/`) is harmless because it is never judged; a narrow entry
42
+ // inside it (`.claude/skills/loop/`) authorizes only that subtree. Items name
43
+ // both forms, so they are kept — › "an allow entry that widens the rulebook — a prefix of a rulebook prefix such as `.` — makes the flag unreadable".
41
44
  //
42
45
  // 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';
46
+ // both limits are refusals, never silent truncation. A candidate is opened
47
+ // nonblocking and must be a regular file see the generator's
48
+ // `test/template/unattended-flag.test.ts` (absent in a generated rig) ›
49
+ // "returns promptly and fails closed when a candidate is a FIFO". An access
50
+ // error is unreadable, not absent — › "is on-but-unreadable when access to an
51
+ // existing flag fails at the stat boundary".
52
+ // Cleanup preserves the same distinction: an owned legacy record that cannot
53
+ // be inspected is an error, not evidence that nothing remains — › "exits
54
+ // nonzero and leaves an unreadable owned legacy flag in place".
55
+ import { createHash } from 'node:crypto';
56
+ import {
57
+ closeSync,
58
+ constants,
59
+ fstatSync,
60
+ mkdirSync,
61
+ openSync,
62
+ readSync,
63
+ realpathSync,
64
+ rmSync,
65
+ writeFileSync,
66
+ } from 'node:fs';
67
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
47
68
  import { fileURLToPath } from 'node:url';
48
69
  import { homesOf } from './stop-flag.mjs';
49
70
 
@@ -57,13 +78,20 @@ export const MAX_ALLOW_ENTRIES = 64;
57
78
  * writer above, to refuse an allow-list that reaches outside them.
58
79
  */
59
80
  export const RULEBOOK_PREFIXES = Object.freeze([
81
+ '.agents/',
82
+ '.claude/.rig-manifest.json',
83
+ '.claude/agents/',
60
84
  '.claude/hooks/',
61
85
  '.claude/settings.json',
62
86
  '.claude/queue.json',
63
- '.claude/scripts/queue/',
64
- '.claude/scripts/decision-router.mjs',
65
- '.claude/scripts/detect-missed-gate.mjs',
87
+ // the per-checkout board selector: picks among the boards queue.json declares,
88
+ // so an unattended run must not be able to re-aim itself through it either
89
+ '.claude/queue.board',
90
+ '.claude/scripts/',
66
91
  '.claude/rules/',
92
+ '.claude/skills/',
93
+ '.codex/',
94
+ 'AGENTS.md',
67
95
  'CLAUDE.md',
68
96
  ]);
69
97
 
@@ -72,24 +100,60 @@ export const isRulebookPath = (rel) =>
72
100
  RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
73
101
 
74
102
  /**
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).
103
+ * Does this allow entry widen the rulebook? It is unsafe when it is an
104
+ * exact protected prefix deliberately unavailable as an allow root (`.agents/`,
105
+ * `.claude/scripts/`, `.codex/`), or when it is a proper prefix of any
106
+ * rulebook prefix and would therefore admit that prefix plus siblings. All
107
+ * protected script paths sit under `.claude/scripts/`; a narrower path such as
108
+ * `.claude/scripts/queue/` is an ordinary allow entry and does not widen it.
109
+ * `src/` also does not widen it because the guard judges nothing there.
80
110
  */
81
111
  export const isWidening = (entry) =>
82
112
  typeof entry !== 'string' ||
83
113
  entry === '' ||
114
+ entry === '.agents/' ||
115
+ entry === '.claude/scripts/' ||
116
+ entry === '.codex/' ||
84
117
  RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
85
118
 
86
- /** Every path that arms unattended mode. The env-derived home is first. */
119
+ const canonicalCheckout = (env) => {
120
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' ? env.CLAUDE_PROJECT_DIR.trim() : '';
121
+ if (declared === '') return null;
122
+ try {
123
+ return realpathSync(declared);
124
+ } catch {
125
+ return resolve(declared);
126
+ }
127
+ };
128
+
129
+ const checkoutId = (env) => {
130
+ const canonical = canonicalCheckout(env);
131
+ if (canonical === null) return null;
132
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
133
+ };
134
+
135
+ const scopedBasename = (env) => {
136
+ const id = checkoutId(env);
137
+ return id === null ? FLAG_BASENAME : FLAG_BASENAME.replace('-loop-UNATTENDED', `-${id}-loop-UNATTENDED`);
138
+ };
139
+
140
+ /** Every checkout-scoped path that arms unattended mode. The env-derived home is first. */
87
141
  export const unattendedFlags = (env = process.env) =>
88
- homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
142
+ homesOf(env).map((home) => join(home, '.claude', scopedBasename(env)));
143
+
144
+ /** Legacy machine-wide candidates are never accepted as scoped authorization. */
145
+ const legacyFlags = (env) => homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
146
+
147
+ const isMissing = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
89
148
 
90
149
  const readCapped = (path) => {
91
- const fd = openSync(path, 'r');
150
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
92
151
  try {
152
+ if (!fstatSync(fd).isFile()) {
153
+ const error = new Error('unattended flag is not a regular file');
154
+ error.code = 'EINVAL';
155
+ throw error;
156
+ }
93
157
  const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
94
158
  const bytes = readSync(fd, buffer, 0, buffer.length, 0);
95
159
  return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
@@ -100,22 +164,54 @@ const readCapped = (path) => {
100
164
 
101
165
  const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
102
166
 
167
+ const inspectCandidates = (candidates) => {
168
+ const present = [];
169
+ for (const path of candidates) {
170
+ try {
171
+ present.push({ path, raw: readCapped(path) });
172
+ } catch (error) {
173
+ if (isMissing(error)) continue;
174
+ return {
175
+ present,
176
+ failure: unreadable(path, `cannot be read: ${error?.code ?? 'read failed'}`),
177
+ };
178
+ }
179
+ }
180
+ return { present, failure: null };
181
+ };
182
+
103
183
  /** The mode the flag declares — see the header for the three answers. */
104
184
  export const readUnattended = (env = process.env) => {
105
- const path = unattendedFlags(env).find((candidate) => {
106
- try {
107
- return existsSync(candidate);
108
- } catch {
109
- return false;
185
+ const scoped = checkoutId(env) !== null;
186
+ const inspected = inspectCandidates(unattendedFlags(env));
187
+ if (inspected.failure) return inspected.failure;
188
+ const { present } = inspected;
189
+ let path = present[0]?.path;
190
+ let raw = present[0]?.raw ?? null;
191
+ if (scoped && present.length > 1) {
192
+ const first = present[0];
193
+ if (
194
+ present.some(
195
+ ({ raw: candidate }) =>
196
+ candidate.bytes !== first.raw.bytes || candidate.text !== first.raw.text,
197
+ )
198
+ ) {
199
+ return unreadable(first.path, 'mirrored checkout-scoped unattended flags disagree');
200
+ }
201
+ raw = first.raw;
202
+ }
203
+ if (!path && scoped) {
204
+ const legacy = inspectCandidates(legacyFlags(env));
205
+ if (legacy.failure) return legacy.failure;
206
+ path = legacy.present[0]?.path;
207
+ if (path) {
208
+ return unreadable(
209
+ path,
210
+ 'legacy machine-wide unattended flag cannot authorize a scoped checkout; migrate or remove it explicitly',
211
+ );
110
212
  }
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
213
  }
214
+ if (!path) return { on: false };
119
215
  if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
120
216
  let parsed;
121
217
  try {
@@ -148,7 +244,12 @@ export const readUnattended = (env = process.env) => {
148
244
  };
149
245
  };
150
246
 
151
- /** Write the flag under the env-derived home. Returns the paths written. */
247
+ /**
248
+ * Write the flag. Scoped records are mirrored into both trusted homes, with the
249
+ * password-database home first, so a caller whose HOME differs still observes
250
+ * the target checkout's state. An unscoped legacy-compatible write keeps the
251
+ * historical first-home behaviour.
252
+ */
152
253
  export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
153
254
  if (typeof item !== 'string' || item.trim() === '') {
154
255
  throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
@@ -165,28 +266,102 @@ export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env =
165
266
  '(`.claude/hooks/`, not `.claude/hooks`).',
166
267
  );
167
268
  }
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];
269
+ const candidates = unattendedFlags(env);
270
+ const targets = checkoutId(env) === null ? candidates.slice(0, 1) : [...candidates].reverse();
271
+ const written = [];
272
+ const content = `${JSON.stringify({ item: item.trim(), runDir, allow: list }, null, 2)}\n`;
273
+ try {
274
+ for (const path of targets) {
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ writeFileSync(path, content);
277
+ written.push(path);
278
+ }
279
+ } catch (error) {
280
+ for (const path of written) {
281
+ try {
282
+ rmSync(path);
283
+ } catch {
284
+ // best-effort rollback; a surviving record keeps readers fail-closed
285
+ }
286
+ }
287
+ throw error;
288
+ }
289
+ return written;
290
+ };
291
+
292
+ const pathBelongsToCheckout = (candidate, checkout) => {
293
+ let resolved;
294
+ try {
295
+ resolved = realpathSync(candidate);
296
+ } catch {
297
+ resolved = resolve(candidate);
298
+ }
299
+ const rel = relative(checkout, resolved);
300
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
172
301
  };
173
302
 
174
- /** Remove every flag that exists. Returns the paths removed. */
303
+ const legacyBelongsToCheckout = (flagPath, env) => {
304
+ const checkout = canonicalCheckout(env);
305
+ if (checkout === null) return false;
306
+ try {
307
+ const raw = readCapped(flagPath);
308
+ if (raw.bytes > MAX_FLAG_BYTES) return false;
309
+ const parsed = JSON.parse(raw.text);
310
+ return typeof parsed?.runDir === 'string' && pathBelongsToCheckout(parsed.runDir, checkout);
311
+ } catch (error) {
312
+ if (!isMissing(error)) {
313
+ throw new Error(
314
+ `legacy unattended flag at ${flagPath} cannot be read: ${error?.code ?? error?.message ?? 'read failed'}`,
315
+ { cause: error },
316
+ );
317
+ }
318
+ return false;
319
+ }
320
+ };
321
+
322
+ /** Remove this checkout's flags and a provably-owned legacy record. */
175
323
  export const clearUnattended = (env = process.env) => {
176
324
  const removed = [];
177
- for (const path of unattendedFlags(env)) {
325
+ const failures = [];
326
+ const candidates = checkoutId(env) === null
327
+ ? unattendedFlags(env)
328
+ : [
329
+ ...unattendedFlags(env),
330
+ ...legacyFlags(env).filter((path) => legacyBelongsToCheckout(path, env)),
331
+ ];
332
+ for (const path of [...new Set(candidates)]) {
178
333
  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
334
+ rmSync(path);
335
+ removed.push(path);
336
+ } catch (error) {
337
+ if (isMissing(error)) continue;
338
+ failures.push(`${path}: ${error?.code ?? error?.message ?? 'remove failed'}`);
185
339
  }
186
340
  }
341
+ if (failures.length > 0) {
342
+ throw new Error(`failed to remove unattended flag(s): ${failures.join('; ')}`);
343
+ }
187
344
  return removed;
188
345
  };
189
346
 
347
+ /** Explicit operator migration: remove exactly the inspected legacy record. */
348
+ export const clearLegacyUnattended = (selectedPath) => {
349
+ if (typeof selectedPath !== 'string' || selectedPath.trim() === '') {
350
+ throw new Error('off --legacy requires --path <reported-path>');
351
+ }
352
+ const path = resolve(selectedPath);
353
+ if (basename(path) !== FLAG_BASENAME || basename(dirname(path)) !== '.claude') {
354
+ throw new Error(`refusing legacy cleanup outside .claude/${FLAG_BASENAME}`);
355
+ }
356
+ try {
357
+ rmSync(path);
358
+ return [path];
359
+ } catch (error) {
360
+ if (isMissing(error)) return [];
361
+ throw error;
362
+ }
363
+ };
364
+
190
365
  const invokedDirectly = () => {
191
366
  if (!process.argv[1]) return false;
192
367
  const real = (p) => {
@@ -205,6 +380,10 @@ if (invokedDirectly()) {
205
380
  const index = rest.indexOf(flag);
206
381
  return index === -1 ? null : (rest[index + 1] ?? null);
207
382
  };
383
+ const root = valueOf('--root');
384
+ const cliEnv = root && !root.startsWith('--')
385
+ ? { ...process.env, CLAUDE_PROJECT_DIR: root }
386
+ : process.env;
208
387
  if (word === 'on') {
209
388
  const item = valueOf('--item');
210
389
  if (!item || item.startsWith('--')) {
@@ -221,7 +400,7 @@ if (invokedDirectly()) {
221
400
  });
222
401
  let path;
223
402
  try {
224
- [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow });
403
+ [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow }, cliEnv);
225
404
  } catch (error) {
226
405
  process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
227
406
  process.exit(1);
@@ -230,7 +409,25 @@ if (invokedDirectly()) {
230
409
  process.exit(0);
231
410
  }
232
411
  if (word === 'off') {
233
- const removed = clearUnattended();
412
+ const legacy = rest.includes('--legacy');
413
+ let removed;
414
+ try {
415
+ removed = legacy ? clearLegacyUnattended(valueOf('--path')) : clearUnattended(cliEnv);
416
+ } catch (error) {
417
+ process.stderr.write(`unattended-flag off: ${error?.message ?? error}\n`);
418
+ process.exit(1);
419
+ }
420
+ if (!legacy && root) {
421
+ const remaining = readUnattended(cliEnv);
422
+ if (remaining.on) {
423
+ const reason = remaining.why ?? 'an unattended flag is still armed';
424
+ process.stderr.write(
425
+ `unattended-flag off: ${reason} at ${remaining.path}. ` +
426
+ 'Inspect that exact record; if no pre-upgrade run still uses it, remove it with `off --legacy --path <reported-path>`.\n',
427
+ );
428
+ process.exit(1);
429
+ }
430
+ }
234
431
  process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
235
432
  process.exit(0);
236
433
  }
@@ -39,6 +39,28 @@ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, o
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
 
@@ -99,14 +121,15 @@ invocation. What a hook CAN see is a file, so the unattended signal is one:
99
121
  ```bash
100
122
  # at claim time, from the paths the item names (repo-relative prefixes, with
101
123
  # their trailing slash); the guard refuses every other rulebook edit while it is on
102
- node .claude/scripts/unattended-flag.mjs on --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
124
+ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
103
125
  ```
104
126
 
105
127
  `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
106
- on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under `.claude/hooks/`,
107
- `.claude/settings.json`, `.claude/queue.json`, `.claude/scripts/queue/`, the
108
- router, the gate sweep, `.claude/rules/` or `CLAUDE.md` is refused unless its
109
- path starts with an allowed prefix; with no flag the guard does nothing. An
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
110
133
  item that needs a rulebook path names it here — a decision made at claim
111
134
  time, never a default — and the stop step below turns the flag off. Pinned in
112
135
  the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
@@ -718,9 +741,18 @@ next attended session would find its rulebook edits refused in the name of an
718
741
  item nobody is working:
719
742
 
720
743
  ```bash
721
- node .claude/scripts/unattended-flag.mjs off
744
+ node .claude/scripts/unattended-flag.mjs off --root "$PWD"
722
745
  ```
723
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
+
724
756
  At every **stop** — not at a checkpoint — turn the run's findings into **at most
725
757
  three** improvement proposals. **The cap is the mechanism, not a budget:** an
726
758
  unbounded improvement list is another diary, and three forces a choice. Each names
@@ -750,7 +782,7 @@ node --input-type=module -e '
750
782
  // mechanism accepts a proposal without them; this procedure does not.
751
783
  measured: "<the paths the probe actually exercised>",
752
784
  inferred: "<the conclusion, citing only surfaces named in measured>",
753
- }, { project: "<KEY>" })); // jira only — the project key from .claude/queue.json;
785
+ }, { project: "<KEY>" })); // jira only — the ACTIVE board's key: `queue/index.mjs board --json` → options.project;
754
786
  // plan-md and github-issues take no second argument
755
787
  '
756
788
  ```
@@ -853,7 +885,7 @@ three poisons the only channel by which this project learns.
853
885
  validation and its take-up — an adapter re-records the take-up after each
854
886
  write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
855
887
  hold the close; pinned in the generator's
856
- `test/template/self-inflicted-marker.test.ts` › "continues when the run’s own
888
+ `test/template/self-inflicted-marker.test.ts` (absent in a generated rig) › "continues when the run’s own
857
889
  write moved the marker after the last validation" — and its
858
890
  state against the `in-progress` a close expects, journals one `revalidation`
859
891
  event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
@@ -864,7 +896,7 @@ three poisons the only channel by which this project learns.
864
896
  and reads a hold as a stop" and › "re-reads each dependant's state, and names
865
897
  one the tracker no longer offers". On a `github-issues` queue that list is
866
898
  empty: a single `gh issue view` carries no cross-index, so `find` answers no
867
- `blocks` there (`test/template/close-transitioned.test.ts` › "github asks `gh
899
+ `blocks` there (`test/template/close-transitioned.test.ts` (absent in a generated rig) › "github asks `gh
868
900
  issue view` with the full field list and maps CLOSED to closed"). A
869
901
  hold (exit 2) stops the close: re-read the item, record the outcome with
870
902
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
@@ -876,7 +908,7 @@ three poisons the only channel by which this project learns.
876
908
  `true` says the close landed, because every adapter reads the item back after
877
909
  the transition — `jira` the status category after the POST, `github-issues`
878
910
  `gh issue view --json state`, `plan-md` the line being there and then gone
879
- (the generator's `test/template/close-transitioned.test.ts` › "GETs the issue
911
+ (the generator's `test/template/close-transitioned.test.ts` (absent in a generated rig) › "GETs the issue
880
912
  status after the transition POST and reports transitioned: true when the
881
913
  category is done", › "runs `issue view <id> --json state` after `issue close`
882
914
  and reports transitioned: true on CLOSED", › "reports transitioned: true once
@@ -1,4 +1,4 @@
1
1
  name = "prose-reviewer"
2
2
  description = "Reviews the documents that instruct agents — rule files, skills, agent specs, CLAUDE.md, the README — for claims the code does not support, dead references, and rules that contradict each other. Use when a change touches any of them, before the PR."
3
3
  sandbox_mode = "read-only"
4
- developer_instructions = "In this project the prose **is** the implementation. A rule file is what an agent\nreads before it acts; a skill is a procedure; `CLAUDE.md` is the map. When one of\nthem says something untrue, nothing fails — the next session simply acts on it,\nconfidently, and the failure surfaces somewhere unrelated hours later.\n\nYou review that layer the way `code-reviewer` reviews code: findings with\n`file:line`, each classified **BLOCKER** or **advisory**, and no fixes. You do\nnot edit anything.\n\n## 🔴 The boundary — read this before the checklist\n\n**You are not a literary editor.** Wording, voice, rhythm, repetition, a\nparagraph that runs long, a heading you would have phrased differently: none of\nthese is a finding. Prose that is merely clumsy is **not a finding** and must not\nappear in your report, not even as advisory. Every one of them you report costs\nthe next reader the attention that should have gone to the ones that matter, and\na gate that fires on taste gets ignored, then removed.\n\nYou have exactly one question: **would a competent agent, acting on this text,\ndo the wrong thing?** If no, it is not yours.\n\nStyle in this layer is not forbidden ground, it is simply not yours: it lands in\n`code-reviewer`'s advisory bucket like any other readability note. Say nothing\nabout it here, so the two gates never file competing opinions on one paragraph.\n\n## Checklist (blocking findings)\n\n1. **An overstated claim of enforcement.** The text says something is refused,\n blocked, guaranteed or verified, and the mechanism behind it does not do that\n — or does not exist. Read the hook, the script, the CI job, and quote what it\n actually does. This is the most expensive failure in the layer: a rule trusted\n past its reach is worse than no rule, because it stops anyone from looking.\n2. **A dead reference.** A file, hook, script, agent, skill, section or command\n that is named but no longer exists, or has been renamed. Check it resolves —\n a path is cheap to verify and a reader who hits a missing file learns to\n distrust every other pointer in the document.\n3. **Two rules that contradict each other.** Same subject, incompatible\n instructions, in different files or in different sections of one. Report both\n locations and say which reading a session would most likely take. Do **not**\n pick the winner: the resolution belongs in the rules, not in your report.\n4. **A stated limit that has gone stale — in either direction.** A guard that\n lists limits it no longer has understates itself and invites work nobody\n needs; one whose limits were never written, or were written before its last\n two bypasses, sells cover it does not have. Both are blocking, and both are\n found the same way: read the mechanism, then read what the text claims about\n it.\n5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how\n much something costs, or how often it happens, and **nothing backs it**: no\n test you can name, no command output, no citation to the code. Per\n `.claude/rules/invariants.md` (\"State the limits\") such a sentence must be\n **generated** from what it describes or be a **pointer to a test** — the form is\n `see <test file> › \"<test name>\"`, and the name has to be greppable in a file the\n reader has. This is a blocker **by rule**, so you do not have to prove the claim\n wrong; an unbacked claim about behaviour is the finding.\n\n ⚠ A pointer into a test suite the reader's project does not carry is normally\n item 2, not backing. There is one narrow inherited-snapshot exception from\n `invariants.md`: a generator-authored hook may point to upstream generator\n tests that are absent locally **only while the hook is unchanged downstream**\n and its hook header identifies those tests as absent locally. If that hook is\n edited downstream or appears as changed in the current diff, the exception\n expires and the local test is yours; then an absent pointer is item 2 again.\n\n 🔴 Three things this is not. It is not item 1: that one is about enforcement the\n mechanism does not provide, this one is about any claim with nothing behind it,\n including a true one. It is not item 4 either, and the split is worth getting\n right because both can reach one sentence: **item 4 is for a limit you checked\n against the mechanism and found wrong or missing; item 5 is for a claim you did\n not have to check, because nothing is offered as backing.** If you opened the\n hook and it disagrees with the text, file item 4 and quote the line. If there was\n nothing offered to open, file item 5. If you opened it and the claim was right,\n there is no finding. One sentence, one item. And it is not an attack on rationale — \"we chose X\n because Y\" needs no test. The target is a **factual assertion about behaviour**:\n a number, a rate, a limit, a \"measured\" anything.\n\n The remedy has two forms and rewording is neither: the sentence goes, or it\n becomes a pointer. Say which you would expect, and where the test lives if one\n exists.\n6. **Domain that must not travel.** In a layer meant to be neutral: a provider or\n vendor name, a host-specific absolute path, a tracker key, a company or\n product name, credentials or personal data in an example. State which layer\n the file belongs to and why the mention breaks it.\n\n 🔴 **A seam built to name a vendor is not a leak.** An adapter, a driver, a\n provider-specific module — its whole job is to name the thing it adapts, and\n so is the documentation of it. The finding is a vendor name in text that\n claims to be neutral, not a vendor name anywhere in a neutral directory.\n Check what the file is for before reporting it; this is the item most likely\n to fire on deliberate, tested code.\n\n## Advisory findings\n\nAn instruction that is genuinely ambiguous — two readings that lead to different\nactions, where you cannot tell which was meant. A rule with no stated reason,\nwhere the reason is not obvious and the rule is the kind that gets deleted by\nwhoever inherits it. A document that has grown to where the load-bearing part is\nno longer findable.\n\nThat is the whole advisory list, on purpose. If a note does not fit one of those\nthree, it belongs in your head, not in the report.\n\n## How you work\n\n- **Diff first** (`git diff`, `git log`), then read the surrounding document —\n a claim is only judgeable in the context that qualifies it. Review what\n changed, not the whole rulebook.\n- **Verify against the mechanism, never against your memory of it.** Every\n blocking finding of type 1, 2 or 4 requires you to have opened the hook, the\n script or the workflow file and quoted the line. A finding you could not check\n is reported as unverified, or not at all.\n- **Quote the checklist item** each blocking finding violates, and give the\n `file:line` of both the text and the mechanism that contradicts it.\n- **\"No blocking findings\" is a valid and useful verdict.** Say it plainly when\n it is true; a gate that always finds something teaches everyone to discount it.\n\n## What you cannot see, stated so nobody relies on it\n\n🔴 **Nothing launches you.** No hook fires this review; a session reads a rule\nand decides to. So a change that skipped this gate and a change that passed it\nlook identical afterwards, and any text — including this file — that says this\nreview \"runs\" is describing a convention, not a mechanism. Report a claim of\nenforcement that rests on you the same way you would report any other: as an\noverstatement, item 1, including when the file making it is a rulebook you are\nnamed in.\n\nYou read text and the mechanisms it names. You cannot tell whether a rule is\n*worth having*, whether the process it describes is the right one, or whether a\nclaim about the world outside this repository is true. Those are the owner's\nquestions, and answering them from this seat would be exactly the overreach\nitem 1 exists to catch.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. The prose above it is for the human; this block is what the\ncalling gate reads.\n\n```json\n{\n \"gate\": \"prose-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \".claude/rules/invariants.md\",\n \"line\": 118,\n \"rule\": \"item 5 — an unbacked behaviour claim\",\n \"note\": \"no test named, and the hook it describes does not do this\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"opened .claude/hooks/guard-bash.mjs and quoted the line\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates; give the `file` and `line` of the\n text, and cite the contradicting mechanism in the `note`.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."
4
+ developer_instructions = "In this project the prose **is** the implementation. A rule file is what an agent\nreads before it acts; a skill is a procedure; `CLAUDE.md` is the map. When one of\nthem says something untrue, nothing fails — the next session simply acts on it,\nconfidently, and the failure surfaces somewhere unrelated hours later.\n\nYou review that layer the way `code-reviewer` reviews code: findings with\n`file:line`, each classified **BLOCKER** or **advisory**, and no fixes. You do\nnot edit anything.\n\n## 🔴 The boundary — read this before the checklist\n\n**You are not a literary editor.** Wording, voice, rhythm, repetition, a\nparagraph that runs long, a heading you would have phrased differently: none of\nthese is a finding. Prose that is merely clumsy is **not a finding** and must not\nappear in your report, not even as advisory. Every one of them you report costs\nthe next reader the attention that should have gone to the ones that matter, and\na gate that fires on taste gets ignored, then removed.\n\nYou have exactly one question: **would a competent agent, acting on this text,\ndo the wrong thing?** If no, it is not yours.\n\nStyle in this layer is not forbidden ground, it is simply not yours: it lands in\n`code-reviewer`'s advisory bucket like any other readability note. Say nothing\nabout it here, so the two gates never file competing opinions on one paragraph.\n\n## Checklist (blocking findings)\n\n1. **An overstated claim of enforcement.** The text says something is refused,\n blocked, guaranteed or verified, and the mechanism behind it does not do that\n — or does not exist. Read the hook, the script, the CI job, and quote what it\n actually does. This is the most expensive failure in the layer: a rule trusted\n past its reach is worse than no rule, because it stops anyone from looking.\n2. **A dead reference.** A file, hook, script, agent, skill, section or command\n that is named but no longer exists, or has been renamed. Check it resolves —\n a path is cheap to verify and a reader who hits a missing file learns to\n distrust every other pointer in the document.\n3. **Two rules that contradict each other.** Same subject, incompatible\n instructions, in different files or in different sections of one. Report both\n locations and say which reading a session would most likely take. Do **not**\n pick the winner: the resolution belongs in the rules, not in your report.\n4. **A stated limit that has gone stale — in either direction.** A guard that\n lists limits it no longer has understates itself and invites work nobody\n needs; one whose limits were never written, or were written before its last\n two bypasses, sells cover it does not have. Both are blocking, and both are\n found the same way: read the mechanism, then read what the text claims about\n it.\n5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how\n much something costs, or how often it happens, and **nothing backs it**: no\n test you can name, no command output, no citation to the code. Per\n `.claude/rules/invariants.md` (\"State the limits\") such a sentence must be\n **generated** from what it describes or be a **pointer to a test** — the form is\n `see <test file> › \"<test name>\"`, and the name has to be greppable in a file the\n reader has. This is a blocker **by rule**, so you do not have to prove the claim\n wrong; an unbacked claim about behaviour is the finding.\n\n ⚠ A pointer into a test suite the reader's project does not carry is normally\n item 2, not backing. There is one narrow inherited-snapshot exception from\n `invariants.md`: a generator-authored artifact — rules, hooks, skills,\n scripts, or agent specs —\n may point to upstream generator tests that are absent locally only when the\n pointer explicitly says the suite is absent locally and\n `.claude/.rig-manifest.json` proves the current artifact's hash matches the\n installed manifest. A manifest-backed upgrade remains an inherited,\n generator-owned artifact; a changed file in the upgrade diff does not alone\n make it downstream-authored. The exception applies **only while the manifest\n hash matches**. A hash mismatch, missing manifest, or no evidence ends the\n exception and the local test is yours; then an absent pointer is item 2 again.\n\n 🔴 Three things this is not. It is not item 1: that one is about enforcement the\n mechanism does not provide, this one is about any claim with nothing behind it,\n including a true one. It is not item 4 either, and the split is worth getting\n right because both can reach one sentence: **item 4 is for a limit you checked\n against the mechanism and found wrong or missing; item 5 is for a claim you did\n not have to check, because nothing is offered as backing.** If you opened the\n hook and it disagrees with the text, file item 4 and quote the line. If there was\n nothing offered to open, file item 5. If you opened it and the claim was right,\n there is no finding. One sentence, one item. And it is not an attack on rationale — \"we chose X\n because Y\" needs no test. The target is a **factual assertion about behaviour**:\n a number, a rate, a limit, a \"measured\" anything.\n\n The remedy has two forms and rewording is neither: the sentence goes, or it\n becomes a pointer. Say which you would expect, and where the test lives if one\n exists.\n6. **Domain that must not travel.** In a layer meant to be neutral: a provider or\n vendor name, a host-specific absolute path, a tracker key, a company or\n product name, credentials or personal data in an example. State which layer\n the file belongs to and why the mention breaks it.\n\n 🔴 **A seam built to name a vendor is not a leak.** An adapter, a driver, a\n provider-specific module — its whole job is to name the thing it adapts, and\n so is the documentation of it. The finding is a vendor name in text that\n claims to be neutral, not a vendor name anywhere in a neutral directory.\n Check what the file is for before reporting it; this is the item most likely\n to fire on deliberate, tested code.\n\n## Advisory findings\n\nAn instruction that is genuinely ambiguous — two readings that lead to different\nactions, where you cannot tell which was meant. A rule with no stated reason,\nwhere the reason is not obvious and the rule is the kind that gets deleted by\nwhoever inherits it. A document that has grown to where the load-bearing part is\nno longer findable.\n\nThat is the whole advisory list, on purpose. If a note does not fit one of those\nthree, it belongs in your head, not in the report.\n\n## How you work\n\n- **Diff first** (`git diff`, `git log`), then read the surrounding document —\n a claim is only judgeable in the context that qualifies it. Review what\n changed, not the whole rulebook.\n- **Verify against the mechanism, never against your memory of it.** Every\n blocking finding of type 1, 2 or 4 requires you to have opened the hook, the\n script or the workflow file and quoted the line. A finding you could not check\n is reported as unverified, or not at all.\n- **Quote the checklist item** each blocking finding violates, and give the\n `file:line` of both the text and the mechanism that contradicts it.\n- **\"No blocking findings\" is a valid and useful verdict.** Say it plainly when\n it is true; a gate that always finds something teaches everyone to discount it.\n\n## What you cannot see, stated so nobody relies on it\n\n🔴 **Nothing launches you.** No hook fires this review; a session reads a rule\nand decides to. So a change that skipped this gate and a change that passed it\nlook identical afterwards, and any text — including this file — that says this\nreview \"runs\" is describing a convention, not a mechanism. Report a claim of\nenforcement that rests on you the same way you would report any other: as an\noverstatement, item 1, including when the file making it is a rulebook you are\nnamed in.\n\nYou read text and the mechanisms it names. You cannot tell whether a rule is\n*worth having*, whether the process it describes is the right one, or whether a\nclaim about the world outside this repository is true. Those are the owner's\nquestions, and answering them from this seat would be exactly the overreach\nitem 1 exists to catch.\n\n## The verdict block\n\nEnd your report with **exactly one** fenced `json` block of this shape, and\nnothing after it. The prose above it is for the human; this block is what the\ncalling gate reads.\n\n```json\n{\n \"gate\": \"prose-reviewer\",\n \"verdict\": \"HOLD\",\n \"blockers\": [\n {\n \"file\": \".claude/rules/invariants.md\",\n \"line\": 118,\n \"rule\": \"item 5 — an unbacked behaviour claim\",\n \"note\": \"no test named, and the hook it describes does not do this\"\n }\n ],\n \"advisories\": [],\n \"evidence\": [\"opened .claude/hooks/guard-bash.mjs and quoted the line\"],\n \"headSha\": \"9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70\"\n}\n```\n\n- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.\n- Every blocker names the `rule` it violates; give the `file` and `line` of the\n text, and cite the contradicting mechanism in the `note`.\n- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:\n `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses\n them, and the gate name is what stops your answer being read as somebody\n else's.\n- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the\n checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage\n <commit>` tell \"this gate answered for the commit being merged\" from \"it\n answered two pushes ago\". A verdict naming no commit is counted as neither\n covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook\n runs that check, so a session that skips the gate skips this with it."