create-agent-rig 0.6.1 → 0.7.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/package.json +3 -1
  3. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
  4. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +122 -81
  5. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
  6. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +24 -8
  7. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +3 -6
  8. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +67 -12
  9. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +5 -9
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +20 -11
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +5 -9
  13. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  14. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +164 -0
  15. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  16. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  17. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +61 -27
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  25. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
  26. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  27. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  28. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +33 -19
  29. package/templates/agent-os/universal/.claude/settings.json +1 -1
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +122 -81
  31. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
  32. package/templates/agent-os/universal/.codex/hooks.json +17 -17
  33. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  34. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  35. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  36. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  37. package/templates/agent-os/universal/layers.json +6 -0
  38. package/templates/hash-history.json +71 -28
  39. package/templates/release-ledger.json +3 -1
@@ -23,9 +23,9 @@
23
23
  * actionChanged — catches whose `revalidation-outcome` says true;
24
24
  * falseHolds — catches whose outcome says false;
25
25
  * unresolved — catches with no outcome at all (the run skipped the re-read).
26
- * An outcome answers the revalidation whose seq its `answers` names, in the
27
- * SAME run — an outcome cannot reach across runs. `noise` counts the sources
28
- * behind the false holds, which is where the mechanism's cost is.
26
+ * A typed outcome answers the stable `detectionId`, including across harness
27
+ * runs; legacy evidence still joins by `answers` within the same run. `noise`
28
+ * counts the sources behind false holds, which is where the mechanism's cost is.
29
29
  *
30
30
  * The primary metric is `actionChanged`, not `opportunities` or `catches`: a
31
31
  * hold that changed nothing is noise, and the report says so by name.
@@ -37,6 +37,7 @@ import { fileURLToPath } from 'node:url';
37
37
  import { mainCheckoutRoot } from './queue/checkout.mjs';
38
38
  import { readRun } from './run-journal.mjs';
39
39
  import { POINTS } from './lib/revalidation-points.mjs';
40
+ import { typedResolutionOf, typedResolutionsOf } from './lib/revalidation-evidence.mjs';
40
41
 
41
42
  export { POINTS };
42
43
 
@@ -56,18 +57,25 @@ export const reportOf = ({ runs, since }) => {
56
57
  const noise = {};
57
58
  const read = [];
58
59
  const skipped = [];
60
+ const resolutions = typedResolutionsOf(
61
+ runs.flatMap(({ events = [], error }) => (error ? [] : events)),
62
+ );
63
+ const legacyOutcomes = new Map();
64
+ for (const { run, events = [], error } of runs) {
65
+ if (error) continue;
66
+ for (const event of events) {
67
+ if (event.kind !== 'revalidation-outcome') continue;
68
+ if (Number.isInteger(event.data?.answers)) {
69
+ legacyOutcomes.set(`${run}:${event.data.answers}`, event.data);
70
+ }
71
+ }
72
+ }
59
73
  for (const { run, events, error } of runs) {
60
74
  if (error) {
61
75
  skipped.push({ run, why: error });
62
76
  continue;
63
77
  }
64
78
  read.push(run);
65
- const outcomes = new Map();
66
- for (const event of events) {
67
- if (event.kind === 'revalidation-outcome' && Number.isInteger(event.data?.answers)) {
68
- outcomes.set(event.data.answers, event.data);
69
- }
70
- }
71
79
  for (const event of events) {
72
80
  if (event.kind !== 'revalidation') continue;
73
81
  // Written as "inside the window", so an `at` that does not parse falls
@@ -76,13 +84,22 @@ export const reportOf = ({ runs, since }) => {
76
84
  const bucket = points[event.data?.point];
77
85
  if (!bucket) continue;
78
86
  bucket.opportunities += 1;
79
- if (event.data.changed === null) bucket.unverifiable += 1;
80
- if (event.data.changed !== true) continue;
87
+ const result = event.data?.result;
88
+ const unverifiable = result === 'UNVERIFIABLE' || event.data.changed === null;
89
+ if (unverifiable) bucket.unverifiable += 1;
90
+ const caught =
91
+ typeof result === 'string'
92
+ ? result !== 'CURRENT' && result !== 'BASELINE_CREATED'
93
+ : event.data.changed === true;
94
+ if (!caught) continue;
81
95
  bucket.catches += 1;
82
- const outcome = outcomes.get(event.seq);
83
- if (!outcome) bucket.unresolved += 1;
84
- else if (outcome.actionChanged === true) bucket.actionChanged += 1;
85
- else {
96
+ const typed = typedResolutionOf(resolutions, event);
97
+ const outcome = typed ?? legacyOutcomes.get(`${run}:${event.seq}`);
98
+ const actionRequired = outcome?.actionRequired ?? outcome?.actionChanged;
99
+ if (typeof actionRequired !== 'boolean') bucket.unresolved += 1;
100
+ else if (actionRequired) {
101
+ bucket.actionChanged += 1;
102
+ } else {
86
103
  bucket.falseHolds += 1;
87
104
  for (const source of Array.isArray(event.data.source) ? event.data.source : []) {
88
105
  noise[source] = (noise[source] ?? 0) + 1;
@@ -1,8 +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, 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:
3
+ * nothing used to answer, plus compatibility evidence in `takeUps` and a
4
+ * checkpoint refusal in `revalidationHold`. The take-up marker is not a drift
5
+ * authority; `.rig/claims/` is. The fourth stop input, `killSwitch`, is deliberately not here:
6
6
  * it is already mechanical in `guard-bash` and scripted in preflight, and a
7
7
  * second answer to "is the brake on" is the disagreement `invariants.md`
8
8
  * forbids.
@@ -49,25 +49,39 @@
49
49
  * per run is the caller's part of the contract, and the `loop` skill states it.
50
50
  */
51
51
 
52
- import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
52
+ import {
53
+ closeSync,
54
+ constants,
55
+ fstatSync,
56
+ lstatSync,
57
+ openSync,
58
+ opendirSync,
59
+ readFileSync,
60
+ realpathSync,
61
+ renameSync,
62
+ unlinkSync,
63
+ writeFileSync,
64
+ } from 'node:fs';
53
65
  import { basename, dirname, join } from 'node:path';
54
66
  import { fileURLToPath } from 'node:url';
55
67
 
56
68
  const STATE = 'state.json';
69
+ const MAX_STATE_BYTES = 256 * 1024;
57
70
 
58
71
  /** The state file's path inside a run directory — one definition, not two. */
59
72
  export const statePathIn = (runDir) => join(runDir, STATE);
60
73
 
61
74
  /**
62
- * What the run has recorded so far; `{}` when it has recorded nothing.
75
+ * What a non-selection caller has recorded so far; `{}` when it has recorded
76
+ * nothing.
63
77
  *
64
78
  * 🔴 **Unreadable is empty, and that is a decision rather than an oversight.**
65
- * A corrupt or half-written state file must not stop the run from selecting
66
- * work: the failure mode of reading it as "no state" is today's behaviour —
67
- * which is exactly what the caller had before this module — while the failure
68
- * mode of throwing is a run that cannot take an item because of a file that
69
- * only ever *adds* stop conditions. Fail towards the behaviour that was already
70
- * trusted.
79
+ * This is the compatibility reader for writers and diagnostic commands whose
80
+ * established failure mode is "no state". Selection does NOT call it: a
81
+ * corrupt file there may be hiding a persisted stop, so `readStateForSelection`
82
+ * below is deliberately fail-closed. Keeping the two entry points named makes
83
+ * that boundary explicit instead of letting one permissive helper silently
84
+ * decide whether work may start.
71
85
  *
72
86
  * Note the asymmetry with `run-journal.mjs`, which refuses a broken sequence
73
87
  * loudly: the journal's whole job is to be trustworthy evidence, so a journal
@@ -84,6 +98,61 @@ export const readState = (runDir) => {
84
98
  }
85
99
  };
86
100
 
101
+ /**
102
+ * Selection's fail-closed view of run state. An absent file is still an empty
103
+ * run, but a present file that cannot be read or interpreted may be hiding a
104
+ * persisted stop such as `revalidationHold` and must not become `{}`.
105
+ */
106
+ export const readStateForSelection = (runDir) => {
107
+ if (!runDir) return {};
108
+ const statePath = statePathIn(runDir);
109
+ let pathStat;
110
+ try {
111
+ pathStat = lstatSync(statePath);
112
+ } catch (error) {
113
+ if (error?.code === 'ENOENT') return {};
114
+ throw new Error('run state is unreadable', { cause: error });
115
+ }
116
+ if (pathStat.isSymbolicLink()) throw new Error('run state is a symlink');
117
+ if (!pathStat.isFile()) throw new Error('run state is invalid: expected a regular file');
118
+ let fd;
119
+ try {
120
+ fd = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
121
+ } catch (error) {
122
+ throw new Error('run state is unreadable', { cause: error });
123
+ }
124
+ let raw;
125
+ try {
126
+ const stat = fstatSync(fd);
127
+ if (!stat.isFile()) throw new Error('run state is invalid: expected a regular file');
128
+ if (stat.size > MAX_STATE_BYTES) {
129
+ throw new Error(`run state exceeds ${MAX_STATE_BYTES} bytes`);
130
+ }
131
+ const current = lstatSync(statePath);
132
+ if (current.isSymbolicLink()) throw new Error('run state is a symlink');
133
+ if (!current.isFile()) throw new Error('run state is invalid: expected a regular file');
134
+ if (current.dev !== stat.dev || current.ino !== stat.ino) {
135
+ throw new Error('run state changed during validation');
136
+ }
137
+ raw = readFileSync(fd, 'utf8');
138
+ } catch (error) {
139
+ if (String(error?.message ?? error).startsWith('run state ')) throw error;
140
+ throw new Error('run state is unreadable', { cause: error });
141
+ } finally {
142
+ closeSync(fd);
143
+ }
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(raw);
147
+ } catch (error) {
148
+ throw new Error('run state is corrupt or invalid JSON', { cause: error });
149
+ }
150
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
151
+ throw new Error('run state is invalid: expected a JSON object');
152
+ }
153
+ return parsed;
154
+ };
155
+
87
156
  /**
88
157
  * Merge `patch` over what is already recorded, and return the result.
89
158
  *
@@ -103,8 +172,8 @@ export const readState = (runDir) => {
103
172
  * there the failure is loud rather than torn, which is the direction to prefer
104
173
  * if it ever has to be handled.
105
174
  */
106
- export const updateState = (runDir, patch) => {
107
- const next = { ...readState(runDir), ...patch };
175
+ const writeState = (runDir, current, patch) => {
176
+ const next = { ...current, ...patch };
108
177
  const file = statePathIn(runDir);
109
178
  // The temp name carries the pid so a second writer cannot clobber the first
110
179
  // one's half-written file — the merge above is still unsafe under two
@@ -140,6 +209,8 @@ export const updateState = (runDir, patch) => {
140
209
  return next;
141
210
  };
142
211
 
212
+ export const updateState = (runDir, patch) => writeState(runDir, readState(runDir), patch);
213
+
143
214
  /**
144
215
  * Count one task that hit a wall, and hand back the new total.
145
216
  *
@@ -203,8 +274,8 @@ export const recordEscalation = (runDir) => {
203
274
 
204
275
  /**
205
276
  * 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.
277
+ * seen at SELECT. It is evidence/compatibility state only; it neither decides
278
+ * drift nor whether a durable claim baseline may be created.
208
279
  *
209
280
  * Per run, like everything else here — a snapshot from yesterday's run is not
210
281
  * a take-up this run made. Merged by id, so a second item does not erase the
@@ -223,6 +294,41 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
223
294
  return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
224
295
  };
225
296
 
297
+ /** Persist a checkpoint refusal so the next selector cannot progress the run. */
298
+ export const recordRevalidationHold = (runDir, detection = {}) => {
299
+ if (!runDir) return null;
300
+ const result = detection.result;
301
+ if (
302
+ typeof detection.ticket !== 'string' ||
303
+ typeof detection.checkpoint !== 'string' ||
304
+ typeof detection.id !== 'string' ||
305
+ !['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(result)
306
+ ) {
307
+ throw new Error('run state: revalidation hold needs a ticket, checkpoint, detection id, and blocking result');
308
+ }
309
+ return writeState(
310
+ runDir,
311
+ readStateForSelection(runDir),
312
+ {
313
+ revalidationHold: {
314
+ kind: 'revalidation-hold',
315
+ ticket: detection.ticket,
316
+ checkpoint: detection.checkpoint,
317
+ result,
318
+ detectionId: detection.id,
319
+ },
320
+ },
321
+ );
322
+ };
323
+
324
+ /** Clear only the hold the recorded resolution actually answers. */
325
+ export const clearRevalidationHold = (runDir, detectionId) => {
326
+ if (!runDir || typeof detectionId !== 'string') return null;
327
+ const state = readStateForSelection(runDir);
328
+ if (state.revalidationHold?.detectionId !== detectionId) return state;
329
+ return writeState(runDir, state, { revalidationHold: undefined });
330
+ };
331
+
226
332
  /**
227
333
  * The take-up an EARLIER run recorded for this item, or null (AR-138).
228
334
  *
@@ -232,8 +338,7 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
232
338
  * when this run has no take-up for the item, SELECT asks the sibling run
233
339
  * directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
234
340
  * `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.
341
+ * names the evidence source in the event; it is never the fingerprint baseline.
237
342
  *
238
343
  * 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
239
344
  * skill declares — and so is the run asking. The first version took every
@@ -246,45 +351,68 @@ export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
246
351
  * another naming looks at no siblings at all, and the limit is the mirror
247
352
  * image: an earlier run declared under another naming is not seen here.
248
353
  *
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.
354
+ * Bounded: the runs root is walked through one directory handle and at most
355
+ * 10 000 entries are looked at, whatever is in there; at most 200 candidate
356
+ * runs are returned. `previousRunEvidence` says when either boundary truncated
357
+ * the search, so authoritative resume detection can fail closed instead of
358
+ * reading "not found in the subset" as "first sight". Compatibility marker
359
+ * lookup remains fail-soft and may discard that completeness signal. Never
360
+ * this run's own state — that is {@link readState}'s answer, and the caller asks
361
+ * it first.
255
362
  */
256
363
  const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
257
364
  const RUNS_ROOT_ENTRY_BUDGET = 10_000;
258
365
  const RUNS_READ_CAP = 200;
259
366
 
260
- export const previousTakeUp = (runDir, id) => {
261
- if (!runDir || id === undefined || id === null) return null;
367
+ /**
368
+ * The bounded, newest-first sibling run directories that can carry evidence.
369
+ *
370
+ * Enumeration is shared by compatibility take-up lookup and durable journal
371
+ * lookup so they cannot disagree about which runs are in scope. Callers choose
372
+ * their own failure direction: take-up evidence is optional and catches an
373
+ * error; resume detection is authoritative for baseline creation and fails
374
+ * closed when this enumeration cannot be completed.
375
+ */
376
+ export const previousRunEvidence = (runDir) => {
377
+ if (!runDir) return { runDirs: [], complete: true };
262
378
  const root = dirname(runDir);
263
379
  const self = basename(runDir);
264
- if (!RUN_DIR_NAME.test(self)) return null;
380
+ if (!RUN_DIR_NAME.test(self)) return { runDirs: [], complete: true };
265
381
  const names = [];
266
- let dir;
267
- try {
268
- dir = opendirSync(root);
269
- } catch {
270
- return null;
271
- }
382
+ let reachedEnd = false;
383
+ const dir = opendirSync(root);
272
384
  try {
273
385
  for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
274
386
  const entry = dir.readSync();
275
- if (entry === null) break;
387
+ if (entry === null) {
388
+ reachedEnd = true;
389
+ break;
390
+ }
276
391
  if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
277
392
  names.push(entry.name);
278
393
  }
279
394
  }
280
- } catch {
281
- return null;
282
395
  } finally {
283
396
  dir.closeSync();
284
397
  }
285
398
  names.sort().reverse();
286
- for (const name of names.slice(0, RUNS_READ_CAP)) {
287
- const candidate = join(root, name);
399
+ return {
400
+ runDirs: names.slice(0, RUNS_READ_CAP).map((name) => join(root, name)),
401
+ complete: reachedEnd && names.length <= RUNS_READ_CAP,
402
+ };
403
+ };
404
+
405
+ export const previousRunDirs = (runDir) => previousRunEvidence(runDir).runDirs;
406
+
407
+ export const previousTakeUp = (runDir, id) => {
408
+ if (!runDir || id === undefined || id === null) return null;
409
+ let candidates;
410
+ try {
411
+ candidates = previousRunDirs(runDir);
412
+ } catch {
413
+ return null;
414
+ }
415
+ for (const candidate of candidates) {
288
416
  let state;
289
417
  try {
290
418
  state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
@@ -378,6 +506,20 @@ export const stopInputsOf = (state = {}) => {
378
506
  refuse('lastDeployVerdict', verdict);
379
507
  }
380
508
 
509
+ const revalidationHold = state.revalidationHold ?? null;
510
+ if (
511
+ revalidationHold !== null &&
512
+ (typeof revalidationHold !== 'object' ||
513
+ Array.isArray(revalidationHold) ||
514
+ revalidationHold.kind !== 'revalidation-hold' ||
515
+ typeof revalidationHold.ticket !== 'string' ||
516
+ typeof revalidationHold.checkpoint !== 'string' ||
517
+ typeof revalidationHold.detectionId !== 'string' ||
518
+ !['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(revalidationHold.result))
519
+ ) {
520
+ refuse('revalidationHold', revalidationHold);
521
+ }
522
+
381
523
  // `triggersFired` is deliberately absent from this, and its absence is a
382
524
  // decision rather than an oversight: `core.mjs` compares `fired !== true`
383
525
  // strictly, so a string, a number or a nonsense shape can only ever leave an
@@ -386,6 +528,7 @@ export const stopInputsOf = (state = {}) => {
386
528
  return {
387
529
  consecutiveEscalations: count,
388
530
  lastDeployVerdict: normalised,
531
+ revalidationHold,
389
532
  // Any truthy value means exhausted: the only writer stores `true`, and a
390
533
  // hand-edit reaching for `"yes"` means yes. A flag has an honest `false`,
391
534
  // unlike a count — so unlike `escalations` above, nothing here needs
@@ -80,6 +80,7 @@ export const MAX_ALLOW_ENTRIES = 64;
80
80
  export const RULEBOOK_PREFIXES = Object.freeze([
81
81
  '.agents/',
82
82
  '.claude/.rig-manifest.json',
83
+ '.claude/doctor-exemptions.json',
83
84
  '.claude/agents/',
84
85
  '.claude/hooks/',
85
86
  '.claude/settings.json',
@@ -116,16 +117,38 @@ export const isWidening = (entry) =>
116
117
  entry === '.codex/' ||
117
118
  RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
118
119
 
119
- const canonicalCheckout = (env) => {
120
- const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' ? env.CLAUDE_PROJECT_DIR.trim() : '';
121
- if (declared === '') return null;
120
+ /**
121
+ * One spelling for one directory — the single canonicaliser this file compares
122
+ * and hashes with (`invariants.md`, "One mechanism, one implementation").
123
+ *
124
+ * RP-54: it is `realpathSync.native`, not `realpathSync`, and on Windows those
125
+ * differ. Both normalise separators; only the native one expands an 8.3 short
126
+ * name, so `C:\Users\RUNNER~1\…` and `C:\Users\runneradmin\…` survive
127
+ * `realpathSync` as two strings for one directory. The flag is named by a hash
128
+ * of this value while the generated Codex Windows hook supplies
129
+ * `git rev-parse --show-toplevel` — a different spelling of the same checkout —
130
+ * so the guard looked for a file nobody wrote and, being fail-open, allowed the
131
+ * rulebook edit it exists to refuse. Proven by
132
+ * unattended-flag.test.ts › "scopes the flag by the checkout, so two spellings
133
+ * of one directory arm one file".
134
+ *
135
+ * A path that does not exist has no real path; `resolve` is the fallback, and
136
+ * it is the same one on both sides of every comparison below.
137
+ */
138
+ const canonicalPath = (p) => {
122
139
  try {
123
- return realpathSync(declared);
140
+ return realpathSync.native(p);
124
141
  } catch {
125
- return resolve(declared);
142
+ return resolve(p);
126
143
  }
127
144
  };
128
145
 
146
+ const canonicalCheckout = (env) => {
147
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' ? env.CLAUDE_PROJECT_DIR.trim() : '';
148
+ if (declared === '') return null;
149
+ return canonicalPath(declared);
150
+ };
151
+
129
152
  const checkoutId = (env) => {
130
153
  const canonical = canonicalCheckout(env);
131
154
  if (canonical === null) return null;
@@ -290,12 +313,10 @@ export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env =
290
313
  };
291
314
 
292
315
  const pathBelongsToCheckout = (candidate, checkout) => {
293
- let resolved;
294
- try {
295
- resolved = realpathSync(candidate);
296
- } catch {
297
- resolved = resolve(candidate);
298
- }
316
+ // `checkout` is `canonicalCheckout`'s output, so the candidate takes the same
317
+ // canonicaliser: two spellings compared here would put an in-checkout runDir
318
+ // outside its own checkout.
319
+ const resolved = canonicalPath(candidate);
299
320
  const rel = relative(checkout, resolved);
300
321
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
301
322
  };
@@ -364,14 +385,7 @@ export const clearLegacyUnattended = (selectedPath) => {
364
385
 
365
386
  const invokedDirectly = () => {
366
387
  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]);
388
+ return canonicalPath(fileURLToPath(import.meta.url)) === canonicalPath(process.argv[1]);
375
389
  };
376
390
 
377
391
  if (invokedDirectly()) {
@@ -23,7 +23,7 @@
23
23
  ]
24
24
  },
25
25
  {
26
- "matcher": "Bash",
26
+ "matcher": "Bash|PowerShell",
27
27
  "hooks": [
28
28
  {
29
29
  "type": "command",