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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/templates/agent-os/init/AGENTS.md +4 -2
- package/templates/agent-os/init/CLAUDE.md +4 -2
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
- package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/hash-history.json +132 -39
- package/templates/release-ledger.json +2 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- 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
|
|
16
|
-
//
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
'.claude/
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
/**
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
path starts with an allowed prefix;
|
|
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
|
|
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."
|