create-agent-rig 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +11 -3
- package/templates/agent-os/init/CLAUDE.md +11 -3
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +309 -49
- package/templates/release-ledger.json +9 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The run's own state — three of the four values `stopConditionOf` asks for and
|
|
3
|
-
* nothing used to answer
|
|
3
|
+
* nothing used to answer, plus one value it does not ask for: the take-up
|
|
4
|
+
* snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
|
|
5
|
+
* the other three. The fourth stop input, `killSwitch`, is deliberately not here:
|
|
4
6
|
* it is already mechanical in `guard-bash` and scripted in preflight, and a
|
|
5
7
|
* second answer to "is the brake on" is the disagreement `invariants.md`
|
|
6
8
|
* forbids.
|
|
@@ -47,8 +49,8 @@
|
|
|
47
49
|
* per run is the caller's part of the contract, and the `loop` skill states it.
|
|
48
50
|
*/
|
|
49
51
|
|
|
50
|
-
import { readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
51
|
-
import { join } from 'node:path';
|
|
52
|
+
import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { basename, dirname, join } from 'node:path';
|
|
52
54
|
import { fileURLToPath } from 'node:url';
|
|
53
55
|
|
|
54
56
|
const STATE = 'state.json';
|
|
@@ -199,6 +201,102 @@ export const recordEscalation = (runDir) => {
|
|
|
199
201
|
}
|
|
200
202
|
};
|
|
201
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
|
|
206
|
+
* seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
|
|
207
|
+
* against it, so a stale take-up is reported rather than silently continued.
|
|
208
|
+
*
|
|
209
|
+
* Per run, like everything else here — a snapshot from yesterday's run is not
|
|
210
|
+
* a take-up this run made. Merged by id, so a second item does not erase the
|
|
211
|
+
* first; re-recording an id moves its baseline forward. A marker that is not a
|
|
212
|
+
* string is not recorded at all: `plan-md` has none, and writing `null` would
|
|
213
|
+
* later compare equal to `null` and read as "unchanged".
|
|
214
|
+
*
|
|
215
|
+
* No run directory → nothing written, `null` back, no throw: an attended
|
|
216
|
+
* selection has no run to snapshot into. (Like {@link recordEscalation} it
|
|
217
|
+
* refuses to throw there; unlike it, there is no count to hand back, so `null`.)
|
|
218
|
+
*/
|
|
219
|
+
export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
220
|
+
if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
|
|
221
|
+
const state = readState(runDir);
|
|
222
|
+
const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
|
|
223
|
+
return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The take-up an EARLIER run recorded for this item, or null (AR-138).
|
|
228
|
+
*
|
|
229
|
+
* {@link recordTakeUp} is per run on purpose, and that left a hole RX1
|
|
230
|
+
* measured: an item taken up yesterday and re-offered today compared against
|
|
231
|
+
* nothing and reported a first sight, however far its marker had moved. So
|
|
232
|
+
* when this run has no take-up for the item, SELECT asks the sibling run
|
|
233
|
+
* directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
|
|
234
|
+
* `loop` skill declares — and takes the first that recorded one. The answer
|
|
235
|
+
* names the run it came from, so the revalidation event can say whose
|
|
236
|
+
* baseline it compared against.
|
|
237
|
+
*
|
|
238
|
+
* 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
|
|
239
|
+
* skill declares — and so is the run asking. The first version took every
|
|
240
|
+
* sibling directory, and two measurements showed why that is wrong. On CI,
|
|
241
|
+
* with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
|
|
242
|
+
* directory carrying a `state.json` was read as yesterday's run, and a first
|
|
243
|
+
* sight came back as a hold against a marker nobody had taken up. Locally,
|
|
244
|
+
* the same temp root held 454 000 entries, and every SELECT paid a 540 ms
|
|
245
|
+
* directory read for a question it could not answer. So a run declared under
|
|
246
|
+
* another naming looks at no siblings at all, and the limit is the mirror
|
|
247
|
+
* image: an earlier run declared under another naming is not seen here.
|
|
248
|
+
*
|
|
249
|
+
* Bounded and fail-soft: the runs root is walked through one directory handle
|
|
250
|
+
* and at most 10 000 entries are looked at, whatever is in there; at most 200
|
|
251
|
+
* candidate runs are read, an unreadable state is skipped rather than trusted,
|
|
252
|
+
* and the answer is `null` for no run directory, an unnamed one, or no runs
|
|
253
|
+
* root. Never this run's own state — that is {@link readState}'s answer, and
|
|
254
|
+
* the caller asks it first.
|
|
255
|
+
*/
|
|
256
|
+
const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
|
|
257
|
+
const RUNS_ROOT_ENTRY_BUDGET = 10_000;
|
|
258
|
+
const RUNS_READ_CAP = 200;
|
|
259
|
+
|
|
260
|
+
export const previousTakeUp = (runDir, id) => {
|
|
261
|
+
if (!runDir || id === undefined || id === null) return null;
|
|
262
|
+
const root = dirname(runDir);
|
|
263
|
+
const self = basename(runDir);
|
|
264
|
+
if (!RUN_DIR_NAME.test(self)) return null;
|
|
265
|
+
const names = [];
|
|
266
|
+
let dir;
|
|
267
|
+
try {
|
|
268
|
+
dir = opendirSync(root);
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
|
|
274
|
+
const entry = dir.readSync();
|
|
275
|
+
if (entry === null) break;
|
|
276
|
+
if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
|
|
277
|
+
names.push(entry.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
} finally {
|
|
283
|
+
dir.closeSync();
|
|
284
|
+
}
|
|
285
|
+
names.sort().reverse();
|
|
286
|
+
for (const name of names.slice(0, RUNS_READ_CAP)) {
|
|
287
|
+
const candidate = join(root, name);
|
|
288
|
+
let state;
|
|
289
|
+
try {
|
|
290
|
+
state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
|
|
291
|
+
} catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const updatedAt = state?.takeUps?.[String(id)];
|
|
295
|
+
if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
|
|
202
300
|
/**
|
|
203
301
|
* The stop inputs, read out of a state object that anything may have written.
|
|
204
302
|
*
|
|
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
|
|
|
21
21
|
import { homedir, userInfo } from 'node:os';
|
|
22
22
|
import { delimiter, join } from 'node:path';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
/**
|
|
25
|
+
* BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
|
|
26
|
+
* pointing it at an empty directory once disarmed the brake) and the password
|
|
27
|
+
* database's, which ignores the environment, so the operator's real flag is
|
|
28
|
+
* always among the paths checked. Shared with the unattended flag (AR-51): one
|
|
29
|
+
* lookup, one place to be wrong.
|
|
30
|
+
*/
|
|
31
|
+
export const homesOf = (env = process.env) => {
|
|
32
|
+
const homes = new Set([env.HOME || homedir()]);
|
|
31
33
|
try {
|
|
32
34
|
homes.add(userInfo().homedir);
|
|
33
35
|
} catch {
|
|
34
36
|
// no password entry — the env-derived home is all there is
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
return [...homes];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Every path that arms the brake. The machine-level default is always first. */
|
|
42
|
+
export const stopFlags = (env = process.env) => {
|
|
43
|
+
const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
|
|
37
44
|
const extra = env.AGENT_LOOP_STOP;
|
|
38
45
|
if (extra) {
|
|
39
46
|
// Filtered and CAPPED before the spread, never after. Spreading an
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The unattended flag — how a hook learns that a loop is running, and what the
|
|
3
|
+
// current item is allowed to touch (AR-51).
|
|
4
|
+
//
|
|
5
|
+
// node .claude/scripts/unattended-flag.mjs on --item AR-51 --run-dir <dir> --allow <prefix> [<prefix>…]
|
|
6
|
+
// node .claude/scripts/unattended-flag.mjs off
|
|
7
|
+
//
|
|
8
|
+
// It is a FILE, not an environment variable: a `PreToolUse` hook is spawned by
|
|
9
|
+
// the harness with the harness's own environment, never with a variable the
|
|
10
|
+
// session exported — the generator's `test/template/guard-rulebook.test.ts` ›
|
|
11
|
+
// "only a flag arms it — an exported RIG_UNATTENDED=1 with no flag changes
|
|
12
|
+
// nothing" pins that side of it — and in some harnesses an `export` does not
|
|
13
|
+
// even survive to the next Bash call. The kill switch (`stop-flag.mjs`) is a
|
|
14
|
+
// file for the same reason,
|
|
15
|
+
// and this module copies its shape: machine-level, under BOTH homes, so a
|
|
16
|
+
// worktree sees it and a `$HOME` set from `.claude/settings.json` cannot hide it.
|
|
17
|
+
//
|
|
18
|
+
// The flag is JSON, `{ item, runDir, allow }`. `allow` is the list of
|
|
19
|
+
// repo-relative prefixes the current item may write under even though they are
|
|
20
|
+
// part of the rulebook — the loop writes it at claim time from the paths the
|
|
21
|
+
// item names, and clears the flag when the run ends. A blanket "no rulebook
|
|
22
|
+
// edits while unattended" would stall on the first queue item that touches
|
|
23
|
+
// `queue/*.mjs`, which is most of this queue; the allow-list is what makes the
|
|
24
|
+
// guard livable, and the guard is what makes the allow-list a decision rather
|
|
25
|
+
// than a default.
|
|
26
|
+
//
|
|
27
|
+
// 🔴 Three answers, and the third is not the first: `{ on: false }` when no flag
|
|
28
|
+
// exists — an attended session, the guard does nothing; `{ on: true, item,
|
|
29
|
+
// runDir, allow }` when it reads; `{ on: true, unreadable: true, why }` when a
|
|
30
|
+
// file is THERE and this module cannot read it as the shape above. A guard that
|
|
31
|
+
// treated the third as the first would be disarmed by a corrupt flag, which is
|
|
32
|
+
// the fail-open bypass `.claude/rules/invariants.md` names.
|
|
33
|
+
//
|
|
34
|
+
// 🔴 An `allow` entry may not WIDEN the rulebook: one that is a prefix of a
|
|
35
|
+
// rulebook prefix — `.`, `.claude/`, `.claude/scripts/`, `CLAUDE` — would let
|
|
36
|
+
// the flag disarm the guard for a whole tree while it reports itself as on, so
|
|
37
|
+
// the writer refuses it and a flag carrying one is unreadable. An entry outside
|
|
38
|
+
// the rulebook (`src/`, `.claude/skills/loop/`) is harmless — such a path is
|
|
39
|
+
// never judged — and items name those all the time, so it is kept, not
|
|
40
|
+
// refused — › "an allow entry that widens the rulebook — a prefix of a rulebook prefix such as `.` — makes the flag unreadable".
|
|
41
|
+
//
|
|
42
|
+
// Bounded: the file is read up to 64 KiB, `allow` is capped at 64 entries, and
|
|
43
|
+
// both limits are refusals, never silent truncation.
|
|
44
|
+
import { closeSync, existsSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { dirname, join } from 'node:path';
|
|
46
|
+
import { realpathSync } from 'node:fs';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { homesOf } from './stop-flag.mjs';
|
|
49
|
+
|
|
50
|
+
export const FLAG_BASENAME = '__PROJECT_NAME__-loop-UNATTENDED';
|
|
51
|
+
export const MAX_FLAG_BYTES = 64 * 1024;
|
|
52
|
+
export const MAX_ALLOW_ENTRIES = 64;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The files that decide what a session may do — repo-relative prefixes. Owned
|
|
56
|
+
* here because two things read them: the guard, to judge an edit, and the
|
|
57
|
+
* writer above, to refuse an allow-list that reaches outside them.
|
|
58
|
+
*/
|
|
59
|
+
export const RULEBOOK_PREFIXES = Object.freeze([
|
|
60
|
+
'.claude/hooks/',
|
|
61
|
+
'.claude/settings.json',
|
|
62
|
+
'.claude/queue.json',
|
|
63
|
+
'.claude/scripts/queue/',
|
|
64
|
+
'.claude/scripts/decision-router.mjs',
|
|
65
|
+
'.claude/scripts/detect-missed-gate.mjs',
|
|
66
|
+
'.claude/rules/',
|
|
67
|
+
'CLAUDE.md',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Is this repo-relative path part of the rulebook? */
|
|
71
|
+
export const isRulebookPath = (rel) =>
|
|
72
|
+
RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does this allow entry widen the rulebook — is it a proper prefix of a rulebook
|
|
76
|
+
* prefix, so that it would admit the whole prefix and more? `.claude/scripts/`
|
|
77
|
+
* widens (it covers `.claude/scripts/queue/` and its siblings); `src/` does not
|
|
78
|
+
* (it covers nothing the guard judges); `.claude/scripts/queue/` does not (it is
|
|
79
|
+
* exactly a rulebook prefix, the ordinary allow entry).
|
|
80
|
+
*/
|
|
81
|
+
export const isWidening = (entry) =>
|
|
82
|
+
typeof entry !== 'string' ||
|
|
83
|
+
entry === '' ||
|
|
84
|
+
RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
|
|
85
|
+
|
|
86
|
+
/** Every path that arms unattended mode. The env-derived home is first. */
|
|
87
|
+
export const unattendedFlags = (env = process.env) =>
|
|
88
|
+
homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
|
|
89
|
+
|
|
90
|
+
const readCapped = (path) => {
|
|
91
|
+
const fd = openSync(path, 'r');
|
|
92
|
+
try {
|
|
93
|
+
const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
|
|
94
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, 0);
|
|
95
|
+
return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
|
|
96
|
+
} finally {
|
|
97
|
+
closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
|
|
102
|
+
|
|
103
|
+
/** The mode the flag declares — see the header for the three answers. */
|
|
104
|
+
export const readUnattended = (env = process.env) => {
|
|
105
|
+
const path = unattendedFlags(env).find((candidate) => {
|
|
106
|
+
try {
|
|
107
|
+
return existsSync(candidate);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (!path) return { on: false };
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = readCapped(path);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return unreadable(path, `cannot be read: ${error?.code ?? 'read failed'}`);
|
|
118
|
+
}
|
|
119
|
+
if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw.text);
|
|
123
|
+
} catch {
|
|
124
|
+
return unreadable(path, 'not valid JSON');
|
|
125
|
+
}
|
|
126
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
127
|
+
return unreadable(path, 'not a JSON object');
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(parsed.allow) || parsed.allow.some((entry) => typeof entry !== 'string')) {
|
|
130
|
+
return unreadable(path, '`allow` is not an array of strings');
|
|
131
|
+
}
|
|
132
|
+
if (parsed.allow.length > MAX_ALLOW_ENTRIES) {
|
|
133
|
+
return unreadable(path, `\`allow\` carries more than ${MAX_ALLOW_ENTRIES} entries`);
|
|
134
|
+
}
|
|
135
|
+
const allow = parsed.allow.map((entry) => entry.trim()).filter(Boolean);
|
|
136
|
+
const wide = allow.find(isWidening);
|
|
137
|
+
if (wide !== undefined) {
|
|
138
|
+
return unreadable(
|
|
139
|
+
path,
|
|
140
|
+
`\`allow\` entry ${JSON.stringify(wide)} widens the rulebook (it is a prefix of a rulebook prefix) — an allow-list narrows the rulebook, never widens it`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
on: true,
|
|
145
|
+
item: typeof parsed.item === 'string' ? parsed.item : null,
|
|
146
|
+
runDir: typeof parsed.runDir === 'string' ? parsed.runDir : null,
|
|
147
|
+
allow,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Write the flag under the env-derived home. Returns the paths written. */
|
|
152
|
+
export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
|
|
153
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
154
|
+
throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
|
|
155
|
+
}
|
|
156
|
+
const list = (Array.isArray(allow) ? allow : []).map((entry) => String(entry).trim()).filter(Boolean);
|
|
157
|
+
if (list.length > MAX_ALLOW_ENTRIES) {
|
|
158
|
+
throw new Error(`the allow-list is capped at ${MAX_ALLOW_ENTRIES} entries`);
|
|
159
|
+
}
|
|
160
|
+
const wide = list.find(isWidening);
|
|
161
|
+
if (wide !== undefined) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`allow entry ${JSON.stringify(wide)} widens the rulebook — it is a prefix of one of ${RULEBOOK_PREFIXES.join(', ')}; ` +
|
|
164
|
+
'an allow-list narrows the rulebook, never widens it. A directory entry needs its trailing slash ' +
|
|
165
|
+
'(`.claude/hooks/`, not `.claude/hooks`).',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
const [path] = unattendedFlags(env);
|
|
169
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
170
|
+
writeFileSync(path, `${JSON.stringify({ item: item.trim(), runDir, allow: list }, null, 2)}\n`);
|
|
171
|
+
return [path];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Remove every flag that exists. Returns the paths removed. */
|
|
175
|
+
export const clearUnattended = (env = process.env) => {
|
|
176
|
+
const removed = [];
|
|
177
|
+
for (const path of unattendedFlags(env)) {
|
|
178
|
+
try {
|
|
179
|
+
if (existsSync(path)) {
|
|
180
|
+
rmSync(path);
|
|
181
|
+
removed.push(path);
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// a home this process cannot write is not this run's flag to remove
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return removed;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const invokedDirectly = () => {
|
|
191
|
+
if (!process.argv[1]) return false;
|
|
192
|
+
const real = (p) => {
|
|
193
|
+
try {
|
|
194
|
+
return realpathSync(p);
|
|
195
|
+
} catch {
|
|
196
|
+
return p;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (invokedDirectly()) {
|
|
203
|
+
const [word, ...rest] = process.argv.slice(2);
|
|
204
|
+
const valueOf = (flag) => {
|
|
205
|
+
const index = rest.indexOf(flag);
|
|
206
|
+
return index === -1 ? null : (rest[index + 1] ?? null);
|
|
207
|
+
};
|
|
208
|
+
if (word === 'on') {
|
|
209
|
+
const item = valueOf('--item');
|
|
210
|
+
if (!item || item.startsWith('--')) {
|
|
211
|
+
process.stderr.write('unattended-flag on: --item <id> is required — the flag names the item whose paths are allowed\n');
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
const allowIndex = rest.indexOf('--allow');
|
|
215
|
+
const allow =
|
|
216
|
+
allowIndex === -1
|
|
217
|
+
? []
|
|
218
|
+
: rest.slice(allowIndex + 1).filter((entry, i, all) => {
|
|
219
|
+
const stop = all.findIndex((e) => e.startsWith('--'));
|
|
220
|
+
return stop === -1 || i < stop;
|
|
221
|
+
});
|
|
222
|
+
let path;
|
|
223
|
+
try {
|
|
224
|
+
[path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
process.stdout.write(`${path}\n`);
|
|
230
|
+
process.exit(0);
|
|
231
|
+
}
|
|
232
|
+
if (word === 'off') {
|
|
233
|
+
const removed = clearUnattended();
|
|
234
|
+
process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
process.stderr.write(`unknown word: ${word ?? '(none)'}. This CLI has two: on, off.\n`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
* The verdict CLI — what a gate runs before it believes a reviewer.
|
|
4
4
|
*
|
|
5
5
|
* node .claude/scripts/verdict.mjs check <file> [gate] # `-` reads stdin
|
|
6
|
+
* node .claude/scripts/verdict.mjs coverage <commit> # reads the run journal
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* parsed verdict on stdout (exit 0) or refuses with a diagnosis on stderr
|
|
8
|
+
* `check` reads a gate's report, hands it to `lib/verdict.mjs`, and either prints
|
|
9
|
+
* the parsed verdict on stdout (exit 0) or refuses with a diagnosis on stderr
|
|
9
10
|
* (exit 1). All the deciding lives in the module; this file is the call site.
|
|
10
11
|
*
|
|
12
|
+
* `coverage` answers the question one report cannot: did every reviewer this
|
|
13
|
+
* round asked for actually answer, for the commit being merged? It compares the
|
|
14
|
+
* three sets the run journal already holds — routed, launched, answered — through
|
|
15
|
+
* `lib/gate-coverage.mjs`, and it is a READ. It never launches a reviewer and it
|
|
16
|
+
* never writes.
|
|
17
|
+
*
|
|
11
18
|
* 🔴 **Name the gate you launched.** The module reads the report's LAST block
|
|
12
19
|
* (its limit 3), so a capture holding two reviewers' answers end to end says
|
|
13
20
|
* only what the second one said — a stop that vanishes behind a later pass.
|
|
@@ -27,11 +34,15 @@
|
|
|
27
34
|
|
|
28
35
|
import { readFileSync } from 'node:fs';
|
|
29
36
|
|
|
30
|
-
import {
|
|
37
|
+
import { coverageOf } from './lib/gate-coverage.mjs';
|
|
38
|
+
import { isCommitId, parseVerdict, safeForDiagnosis } from './lib/verdict.mjs';
|
|
39
|
+
import { readRun } from './run-journal.mjs';
|
|
31
40
|
|
|
32
41
|
const USAGE =
|
|
33
42
|
'usage: node .claude/scripts/verdict.mjs check <file> [gate] ' +
|
|
34
|
-
'(`-` reads the report from stdin)\n'
|
|
43
|
+
'(`-` reads the report from stdin)\n' +
|
|
44
|
+
' node .claude/scripts/verdict.mjs coverage <commit> ' +
|
|
45
|
+
'(reads the run journal in $RIG_RUN_DIR)\n';
|
|
35
46
|
|
|
36
47
|
const refuse = (message) => {
|
|
37
48
|
process.stderr.write(message);
|
|
@@ -58,6 +69,92 @@ const [subcommand, source, expectedGate] = process.argv.slice(2);
|
|
|
58
69
|
// either: an operator told the subcommand is unknown goes looking for a typo
|
|
59
70
|
// that is not there.
|
|
60
71
|
if (subcommand === undefined) refuse(USAGE);
|
|
72
|
+
|
|
73
|
+
if (subcommand === 'coverage') {
|
|
74
|
+
// The four cases and the fix each one needs, said in the line that names the
|
|
75
|
+
// reviewer — a single "missing" list makes the reader guess between
|
|
76
|
+
// relaunching a reviewer and going to read why one stayed silent.
|
|
77
|
+
const CASES = [
|
|
78
|
+
['neverLaunched', 'the route asked for it and the fan-out never launched it — launch it'],
|
|
79
|
+
['unanswered', 'launched, and it did not answer — no verdict of its own parsed'],
|
|
80
|
+
['unattributed', 'it answered, and its verdict named no commit — so it cannot say it answered for this one'],
|
|
81
|
+
['stale', 'it answered for another commit — the head moved after the verdict'],
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const commit = source;
|
|
85
|
+
// The same two arms `check` keeps apart: the subcommand was right and the
|
|
86
|
+
// argument was not supplied. Reporting the opposite sends the operator
|
|
87
|
+
// looking for a typo that is not there.
|
|
88
|
+
if (commit === undefined) {
|
|
89
|
+
refuse(
|
|
90
|
+
'verdict: `coverage` needs the commit the round is about; no commit was given. ' +
|
|
91
|
+
'It is the head the reviewers were launched against — `git rev-parse HEAD` in the ' +
|
|
92
|
+
`reviewed checkout.\n${USAGE}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The one commit field this command owns, and the only one shaped before it is
|
|
97
|
+
// compared: the journal is the other way in and `recordDecision` takes any
|
|
98
|
+
// non-blank string, which is why `sameCommit` enforces its own floor and
|
|
99
|
+
// ceiling rather than trusting an upstream check. Without this arm
|
|
100
|
+
// `coverage <a-full-sha>garbage` prefix-matched its way to "covered" — the
|
|
101
|
+
// answer that ends in a merge.
|
|
102
|
+
if (!isCommitId(commit)) {
|
|
103
|
+
refuse(
|
|
104
|
+
`verdict: \`${safeForDiagnosis(commit)}\` is not a commit to ask about. ` +
|
|
105
|
+
'It is 7 to 64 hex characters (0-9a-f), the same shape a verdict may name — ' +
|
|
106
|
+
'`git rev-parse HEAD` in the reviewed checkout.\n',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
111
|
+
if (!runDir) {
|
|
112
|
+
// 🔴 Exit 0 with nothing printed is indistinguishable from a clean round,
|
|
113
|
+
// and an unattended session reads it as one. The skip is the honest answer —
|
|
114
|
+
// this run kept no trace — and it has to be said out loud.
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
'verdict: coverage skipped — no run directory is declared (RIG_RUN_DIR is unset), so ' +
|
|
117
|
+
'this run journalled no fan-out and no verdicts. Nothing was checked, which is not ' +
|
|
118
|
+
'the same as nothing being outstanding.\n',
|
|
119
|
+
);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let decisions;
|
|
124
|
+
try {
|
|
125
|
+
({ decisions } = readRun({ runDir }));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
refuse(
|
|
128
|
+
`verdict: the run journal in ${runDir} could not be read, so coverage was not ` +
|
|
129
|
+
`checked (${error?.message ?? 'unknown error'}).\n`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const coverage = coverageOf({ records: decisions, headSha: commit });
|
|
134
|
+
if (coverage.ok) {
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
`verdict: coverage complete for ${safeForDiagnosis(commit)} — ` +
|
|
137
|
+
`${coverage.launched.length} reviewer(s) launched, every one of them answered for ` +
|
|
138
|
+
'that commit.\n',
|
|
139
|
+
);
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lines = [];
|
|
144
|
+
if (coverage.reason !== undefined) lines.push(` ${coverage.reason}`);
|
|
145
|
+
for (const [key, why] of CASES) {
|
|
146
|
+
// Through the sanitiser like every other quoted value here: the names come
|
|
147
|
+
// from the fan-out record, which `recordDecision` checks as strings and
|
|
148
|
+
// nothing more, and a name carrying a cursor sequence repaints this refusal
|
|
149
|
+
// as a pass for whoever is watching the scrollback.
|
|
150
|
+
for (const reviewer of coverage[key]) lines.push(` ${safeForDiagnosis(reviewer)} — ${why}`);
|
|
151
|
+
}
|
|
152
|
+
refuse(
|
|
153
|
+
`verdict: the fan-out for ${safeForDiagnosis(commit)} is not covered.\n` +
|
|
154
|
+
`${lines.join('\n')}\n`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
61
158
|
if (subcommand !== 'check') {
|
|
62
159
|
refuse(`verdict: \`${subcommand}\` is not a subcommand of this tool.\n${USAGE}`);
|
|
63
160
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"hooks": {
|
|
3
3
|
"PreToolUse": [
|
|
4
4
|
{
|
|
5
|
-
"matcher": "Write|Edit|apply_patch",
|
|
5
|
+
"matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
{
|
|
16
16
|
"type": "command",
|
|
17
17
|
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-secret-file.mjs\""
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-rulebook.mjs\""
|
|
18
22
|
}
|
|
19
23
|
]
|
|
20
24
|
},
|
|
@@ -173,7 +173,8 @@ inside the block; both forms are the contract.
|
|
|
173
173
|
}
|
|
174
174
|
],
|
|
175
175
|
"advisories": [],
|
|
176
|
-
"evidence": ["read the validator and both call sites"]
|
|
176
|
+
"evidence": ["read the validator and both call sites"],
|
|
177
|
+
"headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
|
|
177
178
|
}
|
|
178
179
|
```
|
|
179
180
|
|
|
@@ -187,6 +188,8 @@ inside the block; both forms are the contract.
|
|
|
187
188
|
and the caller is what checks: the `loop` skill runs
|
|
188
189
|
`node .claude/scripts/verdict.mjs check <report> check-premises` on what you
|
|
189
190
|
return. Nothing in this skill runs it — this skill writes nothing at all.
|
|
191
|
+
- **`headSha` is the commit you read** — `git rev-parse HEAD` in the checkout
|
|
192
|
+
you examined. A premise check answers about a tree, and the tree moves.
|
|
190
193
|
|
|
191
194
|
## Examples — the three shapes this actually catches
|
|
192
195
|
|