create-agent-rig 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +170 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +15 -5
  10. package/templates/agent-os/init/CLAUDE.md +15 -5
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
  23. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  24. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
  25. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  26. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
  27. package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
  28. package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
  29. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
  30. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
  31. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  32. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  33. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
  34. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  35. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  37. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  40. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  41. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
  42. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
  43. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  44. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
  45. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
  46. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  47. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  48. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
  49. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  50. package/templates/agent-os/universal/.claude/settings.json +5 -1
  51. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  52. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
  53. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  54. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  55. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  56. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  57. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  58. package/templates/agent-os/universal/AGENTS.md +3 -1
  59. package/templates/agent-os/universal/CLAUDE.md +3 -1
  60. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  61. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  62. package/templates/agent-os/universal/layers.json +9 -0
  63. package/templates/hash-history.json +404 -51
  64. package/templates/release-ledger.json +10 -0
  65. package/templates/skeleton/aws-serverless/gitignore +2 -0
  66. package/templates/skeleton/node-service/gitignore +2 -0
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * The run's own state — three of the four values `stopConditionOf` asks for and
3
- * nothing used to answer. The fourth, `killSwitch`, is deliberately not here:
3
+ * nothing used to answer, plus one value it does not ask for: the take-up
4
+ * snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
5
+ * the other three. The fourth stop input, `killSwitch`, is deliberately not here:
4
6
  * it is already mechanical in `guard-bash` and scripted in preflight, and a
5
7
  * second answer to "is the brake on" is the disagreement `invariants.md`
6
8
  * forbids.
@@ -47,8 +49,8 @@
47
49
  * per run is the caller's part of the contract, and the `loop` skill states it.
48
50
  */
49
51
 
50
- import { readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
51
- import { join } from 'node:path';
52
+ import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
53
+ import { basename, dirname, join } from 'node:path';
52
54
  import { fileURLToPath } from 'node:url';
53
55
 
54
56
  const STATE = 'state.json';
@@ -199,6 +201,102 @@ export const recordEscalation = (runDir) => {
199
201
  }
200
202
  };
201
203
 
204
+ /**
205
+ * The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
206
+ * seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
207
+ * against it, so a stale take-up is reported rather than silently continued.
208
+ *
209
+ * Per run, like everything else here — a snapshot from yesterday's run is not
210
+ * a take-up this run made. Merged by id, so a second item does not erase the
211
+ * first; re-recording an id moves its baseline forward. A marker that is not a
212
+ * string is not recorded at all: `plan-md` has none, and writing `null` would
213
+ * later compare equal to `null` and read as "unchanged".
214
+ *
215
+ * No run directory → nothing written, `null` back, no throw: an attended
216
+ * selection has no run to snapshot into. (Like {@link recordEscalation} it
217
+ * refuses to throw there; unlike it, there is no count to hand back, so `null`.)
218
+ */
219
+ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
220
+ if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
221
+ const state = readState(runDir);
222
+ const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
223
+ return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
224
+ };
225
+
226
+ /**
227
+ * The take-up an EARLIER run recorded for this item, or null (AR-138).
228
+ *
229
+ * {@link recordTakeUp} is per run on purpose, and that left a hole RX1
230
+ * measured: an item taken up yesterday and re-offered today compared against
231
+ * nothing and reported a first sight, however far its marker had moved. So
232
+ * when this run has no take-up for the item, SELECT asks the sibling run
233
+ * directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
234
+ * `loop` skill declares — and takes the first that recorded one. The answer
235
+ * names the run it came from, so the revalidation event can say whose
236
+ * baseline it compared against.
237
+ *
238
+ * 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
239
+ * skill declares — and so is the run asking. The first version took every
240
+ * sibling directory, and two measurements showed why that is wrong. On CI,
241
+ * with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
242
+ * directory carrying a `state.json` was read as yesterday's run, and a first
243
+ * sight came back as a hold against a marker nobody had taken up. Locally,
244
+ * the same temp root held 454 000 entries, and every SELECT paid a 540 ms
245
+ * directory read for a question it could not answer. So a run declared under
246
+ * another naming looks at no siblings at all, and the limit is the mirror
247
+ * image: an earlier run declared under another naming is not seen here.
248
+ *
249
+ * Bounded and fail-soft: the runs root is walked through one directory handle
250
+ * and at most 10 000 entries are looked at, whatever is in there; at most 200
251
+ * candidate runs are read, an unreadable state is skipped rather than trusted,
252
+ * and the answer is `null` for no run directory, an unnamed one, or no runs
253
+ * root. Never this run's own state — that is {@link readState}'s answer, and
254
+ * the caller asks it first.
255
+ */
256
+ const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
257
+ const RUNS_ROOT_ENTRY_BUDGET = 10_000;
258
+ const RUNS_READ_CAP = 200;
259
+
260
+ export const previousTakeUp = (runDir, id) => {
261
+ if (!runDir || id === undefined || id === null) return null;
262
+ const root = dirname(runDir);
263
+ const self = basename(runDir);
264
+ if (!RUN_DIR_NAME.test(self)) return null;
265
+ const names = [];
266
+ let dir;
267
+ try {
268
+ dir = opendirSync(root);
269
+ } catch {
270
+ return null;
271
+ }
272
+ try {
273
+ for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
274
+ const entry = dir.readSync();
275
+ if (entry === null) break;
276
+ if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
277
+ names.push(entry.name);
278
+ }
279
+ }
280
+ } catch {
281
+ return null;
282
+ } finally {
283
+ dir.closeSync();
284
+ }
285
+ names.sort().reverse();
286
+ for (const name of names.slice(0, RUNS_READ_CAP)) {
287
+ const candidate = join(root, name);
288
+ let state;
289
+ try {
290
+ state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
291
+ } catch {
292
+ continue;
293
+ }
294
+ const updatedAt = state?.takeUps?.[String(id)];
295
+ if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
296
+ }
297
+ return null;
298
+ };
299
+
202
300
  /**
203
301
  * The stop inputs, read out of a state object that anything may have written.
204
302
  *
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
21
21
  import { homedir, userInfo } from 'node:os';
22
22
  import { delimiter, join } from 'node:path';
23
23
 
24
- /** Every path that arms the brake. The machine-level default is always first. */
25
- export const stopFlags = (env = process.env) => {
26
- // BOTH homes: `homedir()` honours $HOME, which `.claude/settings.json` can set —
27
- // pointing it at an empty directory disarmed the brake. `userInfo()` reads the
28
- // password database and ignores the environment, so the operator's real flag is
29
- // always among the paths checked.
30
- const homes = new Set([homedir()]);
24
+ /**
25
+ * BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
26
+ * pointing it at an empty directory once disarmed the brake) and the password
27
+ * database's, which ignores the environment, so the operator's real flag is
28
+ * always among the paths checked. Shared with the unattended flag (AR-51): one
29
+ * lookup, one place to be wrong.
30
+ */
31
+ export const homesOf = (env = process.env) => {
32
+ const homes = new Set([env.HOME || homedir()]);
31
33
  try {
32
34
  homes.add(userInfo().homedir);
33
35
  } catch {
34
36
  // no password entry — the env-derived home is all there is
35
37
  }
36
- const paths = [...homes].map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
38
+ return [...homes];
39
+ };
40
+
41
+ /** Every path that arms the brake. The machine-level default is always first. */
42
+ export const stopFlags = (env = process.env) => {
43
+ const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
37
44
  const extra = env.AGENT_LOOP_STOP;
38
45
  if (extra) {
39
46
  // Filtered and CAPPED before the spread, never after. Spreading an
@@ -0,0 +1,436 @@
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
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
5
+ //
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>
9
+ //
10
+ // It is a FILE, not an environment variable: a `PreToolUse` hook is spawned by
11
+ // the harness with the harness's own environment, never with a variable the
12
+ // session exported — the generator's `test/template/guard-rulebook.test.ts` (absent in a generated rig) ›
13
+ // "only a flag arms it — an exported RIG_UNATTENDED=1 with no flag changes
14
+ // nothing" pins that side of it — and in some harnesses an `export` does not
15
+ // even survive to the next Bash call. The kill switch (`stop-flag.mjs`) is a
16
+ // file for the same reason,
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.
20
+ //
21
+ // The flag is JSON, `{ item, runDir, allow }`. `allow` is the list of
22
+ // repo-relative prefixes the current item may write under even though they are
23
+ // part of the rulebook — the loop writes it at claim time from the paths the
24
+ // item names, and clears the flag when the run ends. A blanket "no rulebook
25
+ // edits while unattended" would stall on the first queue item that touches
26
+ // `queue/*.mjs`, which is most of this queue; the allow-list is what makes the
27
+ // guard livable, and the guard is what makes the allow-list a decision rather
28
+ // than a default.
29
+ //
30
+ // 🔴 Three answers, and the third is not the first: `{ on: false }` when no flag
31
+ // exists — an attended session, the guard does nothing; `{ on: true, item,
32
+ // runDir, allow }` when it reads; `{ on: true, unreadable: true, why }` when a
33
+ // file is THERE and this module cannot read it as the shape above. A guard that
34
+ // treated the third as the first would be disarmed by a corrupt flag, which is
35
+ // the fail-open bypass `.claude/rules/invariants.md` names.
36
+ //
37
+ // 🔴 An `allow` entry may not WIDEN the rulebook: one that is a prefix of a
38
+ // rulebook prefix — `.`, `.claude/`, `.claude/scripts/`, `CLAUDE` — would let
39
+ // the flag disarm the guard for a whole tree while it reports itself as on, so
40
+ // the writer refuses it and a flag carrying one is unreadable. An entry outside
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".
44
+ //
45
+ // Bounded: the file is read up to 64 KiB, `allow` is capped at 64 entries, and
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';
68
+ import { fileURLToPath } from 'node:url';
69
+ import { homesOf } from './stop-flag.mjs';
70
+
71
+ export const FLAG_BASENAME = '__PROJECT_NAME__-loop-UNATTENDED';
72
+ export const MAX_FLAG_BYTES = 64 * 1024;
73
+ export const MAX_ALLOW_ENTRIES = 64;
74
+
75
+ /**
76
+ * The files that decide what a session may do — repo-relative prefixes. Owned
77
+ * here because two things read them: the guard, to judge an edit, and the
78
+ * writer above, to refuse an allow-list that reaches outside them.
79
+ */
80
+ export const RULEBOOK_PREFIXES = Object.freeze([
81
+ '.agents/',
82
+ '.claude/.rig-manifest.json',
83
+ '.claude/agents/',
84
+ '.claude/hooks/',
85
+ '.claude/settings.json',
86
+ '.claude/queue.json',
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/',
91
+ '.claude/rules/',
92
+ '.claude/skills/',
93
+ '.codex/',
94
+ 'AGENTS.md',
95
+ 'CLAUDE.md',
96
+ ]);
97
+
98
+ /** Is this repo-relative path part of the rulebook? */
99
+ export const isRulebookPath = (rel) =>
100
+ RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
101
+
102
+ /**
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.
110
+ */
111
+ export const isWidening = (entry) =>
112
+ typeof entry !== 'string' ||
113
+ entry === '' ||
114
+ entry === '.agents/' ||
115
+ entry === '.claude/scripts/' ||
116
+ entry === '.codex/' ||
117
+ RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
118
+
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. */
141
+ export const unattendedFlags = (env = process.env) =>
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';
148
+
149
+ const readCapped = (path) => {
150
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
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
+ }
157
+ const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
158
+ const bytes = readSync(fd, buffer, 0, buffer.length, 0);
159
+ return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
160
+ } finally {
161
+ closeSync(fd);
162
+ }
163
+ };
164
+
165
+ const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
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
+
183
+ /** The mode the flag declares — see the header for the three answers. */
184
+ export const readUnattended = (env = process.env) => {
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
+ );
212
+ }
213
+ }
214
+ if (!path) return { on: false };
215
+ if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(raw.text);
219
+ } catch {
220
+ return unreadable(path, 'not valid JSON');
221
+ }
222
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
223
+ return unreadable(path, 'not a JSON object');
224
+ }
225
+ if (!Array.isArray(parsed.allow) || parsed.allow.some((entry) => typeof entry !== 'string')) {
226
+ return unreadable(path, '`allow` is not an array of strings');
227
+ }
228
+ if (parsed.allow.length > MAX_ALLOW_ENTRIES) {
229
+ return unreadable(path, `\`allow\` carries more than ${MAX_ALLOW_ENTRIES} entries`);
230
+ }
231
+ const allow = parsed.allow.map((entry) => entry.trim()).filter(Boolean);
232
+ const wide = allow.find(isWidening);
233
+ if (wide !== undefined) {
234
+ return unreadable(
235
+ path,
236
+ `\`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`,
237
+ );
238
+ }
239
+ return {
240
+ on: true,
241
+ item: typeof parsed.item === 'string' ? parsed.item : null,
242
+ runDir: typeof parsed.runDir === 'string' ? parsed.runDir : null,
243
+ allow,
244
+ };
245
+ };
246
+
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
+ */
253
+ export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
254
+ if (typeof item !== 'string' || item.trim() === '') {
255
+ throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
256
+ }
257
+ const list = (Array.isArray(allow) ? allow : []).map((entry) => String(entry).trim()).filter(Boolean);
258
+ if (list.length > MAX_ALLOW_ENTRIES) {
259
+ throw new Error(`the allow-list is capped at ${MAX_ALLOW_ENTRIES} entries`);
260
+ }
261
+ const wide = list.find(isWidening);
262
+ if (wide !== undefined) {
263
+ throw new Error(
264
+ `allow entry ${JSON.stringify(wide)} widens the rulebook — it is a prefix of one of ${RULEBOOK_PREFIXES.join(', ')}; ` +
265
+ 'an allow-list narrows the rulebook, never widens it. A directory entry needs its trailing slash ' +
266
+ '(`.claude/hooks/`, not `.claude/hooks`).',
267
+ );
268
+ }
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));
301
+ };
302
+
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. */
323
+ export const clearUnattended = (env = process.env) => {
324
+ const removed = [];
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)]) {
333
+ try {
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'}`);
339
+ }
340
+ }
341
+ if (failures.length > 0) {
342
+ throw new Error(`failed to remove unattended flag(s): ${failures.join('; ')}`);
343
+ }
344
+ return removed;
345
+ };
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
+
365
+ const invokedDirectly = () => {
366
+ if (!process.argv[1]) return false;
367
+ const real = (p) => {
368
+ try {
369
+ return realpathSync(p);
370
+ } catch {
371
+ return p;
372
+ }
373
+ };
374
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
375
+ };
376
+
377
+ if (invokedDirectly()) {
378
+ const [word, ...rest] = process.argv.slice(2);
379
+ const valueOf = (flag) => {
380
+ const index = rest.indexOf(flag);
381
+ return index === -1 ? null : (rest[index + 1] ?? null);
382
+ };
383
+ const root = valueOf('--root');
384
+ const cliEnv = root && !root.startsWith('--')
385
+ ? { ...process.env, CLAUDE_PROJECT_DIR: root }
386
+ : process.env;
387
+ if (word === 'on') {
388
+ const item = valueOf('--item');
389
+ if (!item || item.startsWith('--')) {
390
+ process.stderr.write('unattended-flag on: --item <id> is required — the flag names the item whose paths are allowed\n');
391
+ process.exit(1);
392
+ }
393
+ const allowIndex = rest.indexOf('--allow');
394
+ const allow =
395
+ allowIndex === -1
396
+ ? []
397
+ : rest.slice(allowIndex + 1).filter((entry, i, all) => {
398
+ const stop = all.findIndex((e) => e.startsWith('--'));
399
+ return stop === -1 || i < stop;
400
+ });
401
+ let path;
402
+ try {
403
+ [path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow }, cliEnv);
404
+ } catch (error) {
405
+ process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
406
+ process.exit(1);
407
+ }
408
+ process.stdout.write(`${path}\n`);
409
+ process.exit(0);
410
+ }
411
+ if (word === 'off') {
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
+ }
431
+ process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
432
+ process.exit(0);
433
+ }
434
+ process.stderr.write(`unknown word: ${word ?? '(none)'}. This CLI has two: on, off.\n`);
435
+ process.exit(1);
436
+ }