create-agent-rig 0.6.0 → 0.6.2

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 (38) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -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 +46 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
  9. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
  10. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
  11. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
  13. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
  14. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
  15. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  16. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  17. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
  18. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  19. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  20. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  21. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  22. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  25. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  26. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
  27. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  28. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  29. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
  31. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  32. package/templates/agent-os/universal/.codex/hooks.json +16 -16
  33. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  34. package/templates/agent-os/universal/layers.json +1 -0
  35. package/templates/hash-history.json +158 -43
  36. package/templates/release-ledger.json +3 -1
  37. package/templates/skeleton/aws-serverless/gitignore +2 -0
  38. 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,21 @@ 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/doctor-exemptions.json',
84
+ '.claude/agents/',
60
85
  '.claude/hooks/',
61
86
  '.claude/settings.json',
62
87
  '.claude/queue.json',
63
- '.claude/scripts/queue/',
64
- '.claude/scripts/decision-router.mjs',
65
- '.claude/scripts/detect-missed-gate.mjs',
88
+ // the per-checkout board selector: picks among the boards queue.json declares,
89
+ // so an unattended run must not be able to re-aim itself through it either
90
+ '.claude/queue.board',
91
+ '.claude/scripts/',
66
92
  '.claude/rules/',
93
+ '.claude/skills/',
94
+ '.codex/',
95
+ 'AGENTS.md',
67
96
  'CLAUDE.md',
68
97
  ]);
69
98
 
@@ -72,24 +101,82 @@ export const isRulebookPath = (rel) =>
72
101
  RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
73
102
 
74
103
  /**
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).
104
+ * Does this allow entry widen the rulebook? It is unsafe when it is an
105
+ * exact protected prefix deliberately unavailable as an allow root (`.agents/`,
106
+ * `.claude/scripts/`, `.codex/`), or when it is a proper prefix of any
107
+ * rulebook prefix and would therefore admit that prefix plus siblings. All
108
+ * protected script paths sit under `.claude/scripts/`; a narrower path such as
109
+ * `.claude/scripts/queue/` is an ordinary allow entry and does not widen it.
110
+ * `src/` also does not widen it because the guard judges nothing there.
80
111
  */
81
112
  export const isWidening = (entry) =>
82
113
  typeof entry !== 'string' ||
83
114
  entry === '' ||
115
+ entry === '.agents/' ||
116
+ entry === '.claude/scripts/' ||
117
+ entry === '.codex/' ||
84
118
  RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
85
119
 
86
- /** Every path that arms unattended mode. The env-derived home is first. */
120
+ /**
121
+ * One spelling for one directory — the single canonicaliser this file compares
122
+ * and hashes with (`invariants.md`, "One mechanism, one implementation").
123
+ *
124
+ * RP-54: it is `realpathSync.native`, not `realpathSync`, and on Windows those
125
+ * differ. Both normalise separators; only the native one expands an 8.3 short
126
+ * name, so `C:\Users\RUNNER~1\…` and `C:\Users\runneradmin\…` survive
127
+ * `realpathSync` as two strings for one directory. The flag is named by a hash
128
+ * of this value while the generated Codex Windows hook supplies
129
+ * `git rev-parse --show-toplevel` — a different spelling of the same checkout —
130
+ * so the guard looked for a file nobody wrote and, being fail-open, allowed the
131
+ * rulebook edit it exists to refuse. Proven by
132
+ * unattended-flag.test.ts › "scopes the flag by the checkout, so two spellings
133
+ * of one directory arm one file".
134
+ *
135
+ * A path that does not exist has no real path; `resolve` is the fallback, and
136
+ * it is the same one on both sides of every comparison below.
137
+ */
138
+ const canonicalPath = (p) => {
139
+ try {
140
+ return realpathSync.native(p);
141
+ } catch {
142
+ return resolve(p);
143
+ }
144
+ };
145
+
146
+ const canonicalCheckout = (env) => {
147
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' ? env.CLAUDE_PROJECT_DIR.trim() : '';
148
+ if (declared === '') return null;
149
+ return canonicalPath(declared);
150
+ };
151
+
152
+ const checkoutId = (env) => {
153
+ const canonical = canonicalCheckout(env);
154
+ if (canonical === null) return null;
155
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
156
+ };
157
+
158
+ const scopedBasename = (env) => {
159
+ const id = checkoutId(env);
160
+ return id === null ? FLAG_BASENAME : FLAG_BASENAME.replace('-loop-UNATTENDED', `-${id}-loop-UNATTENDED`);
161
+ };
162
+
163
+ /** Every checkout-scoped path that arms unattended mode. The env-derived home is first. */
87
164
  export const unattendedFlags = (env = process.env) =>
88
- homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
165
+ homesOf(env).map((home) => join(home, '.claude', scopedBasename(env)));
166
+
167
+ /** Legacy machine-wide candidates are never accepted as scoped authorization. */
168
+ const legacyFlags = (env) => homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
169
+
170
+ const isMissing = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
89
171
 
90
172
  const readCapped = (path) => {
91
- const fd = openSync(path, 'r');
173
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
92
174
  try {
175
+ if (!fstatSync(fd).isFile()) {
176
+ const error = new Error('unattended flag is not a regular file');
177
+ error.code = 'EINVAL';
178
+ throw error;
179
+ }
93
180
  const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
94
181
  const bytes = readSync(fd, buffer, 0, buffer.length, 0);
95
182
  return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
@@ -100,22 +187,54 @@ const readCapped = (path) => {
100
187
 
101
188
  const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
102
189
 
190
+ const inspectCandidates = (candidates) => {
191
+ const present = [];
192
+ for (const path of candidates) {
193
+ try {
194
+ present.push({ path, raw: readCapped(path) });
195
+ } catch (error) {
196
+ if (isMissing(error)) continue;
197
+ return {
198
+ present,
199
+ failure: unreadable(path, `cannot be read: ${error?.code ?? 'read failed'}`),
200
+ };
201
+ }
202
+ }
203
+ return { present, failure: null };
204
+ };
205
+
103
206
  /** The mode the flag declares — see the header for the three answers. */
104
207
  export const readUnattended = (env = process.env) => {
105
- const path = unattendedFlags(env).find((candidate) => {
106
- try {
107
- return existsSync(candidate);
108
- } catch {
109
- return false;
208
+ const scoped = checkoutId(env) !== null;
209
+ const inspected = inspectCandidates(unattendedFlags(env));
210
+ if (inspected.failure) return inspected.failure;
211
+ const { present } = inspected;
212
+ let path = present[0]?.path;
213
+ let raw = present[0]?.raw ?? null;
214
+ if (scoped && present.length > 1) {
215
+ const first = present[0];
216
+ if (
217
+ present.some(
218
+ ({ raw: candidate }) =>
219
+ candidate.bytes !== first.raw.bytes || candidate.text !== first.raw.text,
220
+ )
221
+ ) {
222
+ return unreadable(first.path, 'mirrored checkout-scoped unattended flags disagree');
223
+ }
224
+ raw = first.raw;
225
+ }
226
+ if (!path && scoped) {
227
+ const legacy = inspectCandidates(legacyFlags(env));
228
+ if (legacy.failure) return legacy.failure;
229
+ path = legacy.present[0]?.path;
230
+ if (path) {
231
+ return unreadable(
232
+ path,
233
+ 'legacy machine-wide unattended flag cannot authorize a scoped checkout; migrate or remove it explicitly',
234
+ );
110
235
  }
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
236
  }
237
+ if (!path) return { on: false };
119
238
  if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
120
239
  let parsed;
121
240
  try {
@@ -148,7 +267,12 @@ export const readUnattended = (env = process.env) => {
148
267
  };
149
268
  };
150
269
 
151
- /** Write the flag under the env-derived home. Returns the paths written. */
270
+ /**
271
+ * Write the flag. Scoped records are mirrored into both trusted homes, with the
272
+ * password-database home first, so a caller whose HOME differs still observes
273
+ * the target checkout's state. An unscoped legacy-compatible write keeps the
274
+ * historical first-home behaviour.
275
+ */
152
276
  export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
153
277
  if (typeof item !== 'string' || item.trim() === '') {
154
278
  throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
@@ -165,38 +289,103 @@ export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env =
165
289
  '(`.claude/hooks/`, not `.claude/hooks`).',
166
290
  );
167
291
  }
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];
292
+ const candidates = unattendedFlags(env);
293
+ const targets = checkoutId(env) === null ? candidates.slice(0, 1) : [...candidates].reverse();
294
+ const written = [];
295
+ const content = `${JSON.stringify({ item: item.trim(), runDir, allow: list }, null, 2)}\n`;
296
+ try {
297
+ for (const path of targets) {
298
+ mkdirSync(dirname(path), { recursive: true });
299
+ writeFileSync(path, content);
300
+ written.push(path);
301
+ }
302
+ } catch (error) {
303
+ for (const path of written) {
304
+ try {
305
+ rmSync(path);
306
+ } catch {
307
+ // best-effort rollback; a surviving record keeps readers fail-closed
308
+ }
309
+ }
310
+ throw error;
311
+ }
312
+ return written;
172
313
  };
173
314
 
174
- /** Remove every flag that exists. Returns the paths removed. */
315
+ const pathBelongsToCheckout = (candidate, checkout) => {
316
+ // `checkout` is `canonicalCheckout`'s output, so the candidate takes the same
317
+ // canonicaliser: two spellings compared here would put an in-checkout runDir
318
+ // outside its own checkout.
319
+ const resolved = canonicalPath(candidate);
320
+ const rel = relative(checkout, resolved);
321
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
322
+ };
323
+
324
+ const legacyBelongsToCheckout = (flagPath, env) => {
325
+ const checkout = canonicalCheckout(env);
326
+ if (checkout === null) return false;
327
+ try {
328
+ const raw = readCapped(flagPath);
329
+ if (raw.bytes > MAX_FLAG_BYTES) return false;
330
+ const parsed = JSON.parse(raw.text);
331
+ return typeof parsed?.runDir === 'string' && pathBelongsToCheckout(parsed.runDir, checkout);
332
+ } catch (error) {
333
+ if (!isMissing(error)) {
334
+ throw new Error(
335
+ `legacy unattended flag at ${flagPath} cannot be read: ${error?.code ?? error?.message ?? 'read failed'}`,
336
+ { cause: error },
337
+ );
338
+ }
339
+ return false;
340
+ }
341
+ };
342
+
343
+ /** Remove this checkout's flags and a provably-owned legacy record. */
175
344
  export const clearUnattended = (env = process.env) => {
176
345
  const removed = [];
177
- for (const path of unattendedFlags(env)) {
346
+ const failures = [];
347
+ const candidates = checkoutId(env) === null
348
+ ? unattendedFlags(env)
349
+ : [
350
+ ...unattendedFlags(env),
351
+ ...legacyFlags(env).filter((path) => legacyBelongsToCheckout(path, env)),
352
+ ];
353
+ for (const path of [...new Set(candidates)]) {
178
354
  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
355
+ rmSync(path);
356
+ removed.push(path);
357
+ } catch (error) {
358
+ if (isMissing(error)) continue;
359
+ failures.push(`${path}: ${error?.code ?? error?.message ?? 'remove failed'}`);
185
360
  }
186
361
  }
362
+ if (failures.length > 0) {
363
+ throw new Error(`failed to remove unattended flag(s): ${failures.join('; ')}`);
364
+ }
187
365
  return removed;
188
366
  };
189
367
 
368
+ /** Explicit operator migration: remove exactly the inspected legacy record. */
369
+ export const clearLegacyUnattended = (selectedPath) => {
370
+ if (typeof selectedPath !== 'string' || selectedPath.trim() === '') {
371
+ throw new Error('off --legacy requires --path <reported-path>');
372
+ }
373
+ const path = resolve(selectedPath);
374
+ if (basename(path) !== FLAG_BASENAME || basename(dirname(path)) !== '.claude') {
375
+ throw new Error(`refusing legacy cleanup outside .claude/${FLAG_BASENAME}`);
376
+ }
377
+ try {
378
+ rmSync(path);
379
+ return [path];
380
+ } catch (error) {
381
+ if (isMissing(error)) return [];
382
+ throw error;
383
+ }
384
+ };
385
+
190
386
  const invokedDirectly = () => {
191
387
  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]);
388
+ return canonicalPath(fileURLToPath(import.meta.url)) === canonicalPath(process.argv[1]);
200
389
  };
201
390
 
202
391
  if (invokedDirectly()) {
@@ -205,6 +394,10 @@ if (invokedDirectly()) {
205
394
  const index = rest.indexOf(flag);
206
395
  return index === -1 ? null : (rest[index + 1] ?? null);
207
396
  };
397
+ const root = valueOf('--root');
398
+ const cliEnv = root && !root.startsWith('--')
399
+ ? { ...process.env, CLAUDE_PROJECT_DIR: root }
400
+ : process.env;
208
401
  if (word === 'on') {
209
402
  const item = valueOf('--item');
210
403
  if (!item || item.startsWith('--')) {
@@ -221,7 +414,7 @@ if (invokedDirectly()) {
221
414
  });
222
415
  let path;
223
416
  try {
224
- [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow });
417
+ [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow }, cliEnv);
225
418
  } catch (error) {
226
419
  process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
227
420
  process.exit(1);
@@ -230,7 +423,25 @@ if (invokedDirectly()) {
230
423
  process.exit(0);
231
424
  }
232
425
  if (word === 'off') {
233
- const removed = clearUnattended();
426
+ const legacy = rest.includes('--legacy');
427
+ let removed;
428
+ try {
429
+ removed = legacy ? clearLegacyUnattended(valueOf('--path')) : clearUnattended(cliEnv);
430
+ } catch (error) {
431
+ process.stderr.write(`unattended-flag off: ${error?.message ?? error}\n`);
432
+ process.exit(1);
433
+ }
434
+ if (!legacy && root) {
435
+ const remaining = readUnattended(cliEnv);
436
+ if (remaining.on) {
437
+ const reason = remaining.why ?? 'an unattended flag is still armed';
438
+ process.stderr.write(
439
+ `unattended-flag off: ${reason} at ${remaining.path}. ` +
440
+ 'Inspect that exact record; if no pre-upgrade run still uses it, remove it with `off --legacy --path <reported-path>`.\n',
441
+ );
442
+ process.exit(1);
443
+ }
444
+ }
234
445
  process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
235
446
  process.exit(0);
236
447
  }
@@ -39,6 +39,32 @@ 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. This is repository-risk state, not tracker metadata: switching from
63
+ one independent queue to another must not turn a previous mechanism close into
64
+ permission for a second one in the same checkout. Pinned in the generator's
65
+ `test/template/queue-board.test.ts` (absent in a generated rig) › "keeps
66
+ completed-tier spacing repository-global when the active board switches".
67
+
42
68
  Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
43
69
  decision and each adapter only maps its tracker's records onto the neutral shape.
44
70
 
@@ -99,14 +125,15 @@ invocation. What a hook CAN see is a file, so the unattended signal is one:
99
125
  ```bash
100
126
  # at claim time, from the paths the item names (repo-relative prefixes, with
101
127
  # 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>…]
128
+ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
103
129
  ```
104
130
 
105
131
  `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
132
+ on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
133
+ rulebook both harnesses' rules, skills, agents and hook wiring, plus their
134
+ scripts, queue config and integrity manifest is refused unless its
135
+ path starts with an allowed prefix; the board selector is the one always-refused
136
+ exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
110
137
  item that needs a rulebook path names it here — a decision made at claim
111
138
  time, never a default — and the stop step below turns the flag off. Pinned in
112
139
  the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
@@ -718,9 +745,18 @@ next attended session would find its rulebook edits refused in the name of an
718
745
  item nobody is working:
719
746
 
720
747
  ```bash
721
- node .claude/scripts/unattended-flag.mjs off
748
+ node .claude/scripts/unattended-flag.mjs off --root "$PWD"
722
749
  ```
723
750
 
751
+ If that command reports a legacy machine-wide flag, it deliberately leaves a
752
+ foreign pre-upgrade authorization in place and the checkout stays fail-closed.
753
+ Inspect the exact reported record and confirm that no pre-upgrade run still uses
754
+ it, then remove only that record with
755
+ `node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>`.
756
+ Run scoped `off --root "$PWD"` again to surface the next record, and repeat the
757
+ inspection one at a time; do not record the flag as off until the scoped command
758
+ succeeds.
759
+
724
760
  At every **stop** — not at a checkpoint — turn the run's findings into **at most
725
761
  three** improvement proposals. **The cap is the mechanism, not a budget:** an
726
762
  unbounded improvement list is another diary, and three forces a choice. Each names
@@ -750,7 +786,7 @@ node --input-type=module -e '
750
786
  // mechanism accepts a proposal without them; this procedure does not.
751
787
  measured: "<the paths the probe actually exercised>",
752
788
  inferred: "<the conclusion, citing only surfaces named in measured>",
753
- }, { project: "<KEY>" })); // jira only — the project key from .claude/queue.json;
789
+ }, { project: "<KEY>" })); // jira only — the ACTIVE board's key: `queue/index.mjs board --json` → options.project;
754
790
  // plan-md and github-issues take no second argument
755
791
  '
756
792
  ```
@@ -853,7 +889,7 @@ three poisons the only channel by which this project learns.
853
889
  validation and its take-up — an adapter re-records the take-up after each
854
890
  write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
855
891
  hold the close; pinned in the generator's
856
- `test/template/self-inflicted-marker.test.ts` › "continues when the run’s own
892
+ `test/template/self-inflicted-marker.test.ts` (absent in a generated rig) › "continues when the run’s own
857
893
  write moved the marker after the last validation" — and its
858
894
  state against the `in-progress` a close expects, journals one `revalidation`
859
895
  event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
@@ -864,7 +900,7 @@ three poisons the only channel by which this project learns.
864
900
  and reads a hold as a stop" and › "re-reads each dependant's state, and names
865
901
  one the tracker no longer offers". On a `github-issues` queue that list is
866
902
  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
903
+ `blocks` there (`test/template/close-transitioned.test.ts` (absent in a generated rig) › "github asks `gh
868
904
  issue view` with the full field list and maps CLOSED to closed"). A
869
905
  hold (exit 2) stops the close: re-read the item, record the outcome with
870
906
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
@@ -876,7 +912,7 @@ three poisons the only channel by which this project learns.
876
912
  `true` says the close landed, because every adapter reads the item back after
877
913
  the transition — `jira` the status category after the POST, `github-issues`
878
914
  `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
915
+ (the generator's `test/template/close-transitioned.test.ts` (absent in a generated rig) › "GETs the issue
880
916
  status after the transition POST and reports transitioned: true when the
881
917
  category is done", › "runs `issue view <id> --json state` after `issue close`
882
918
  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."