@sabaiway/agent-workflow-kit 14.29.1 → 14.31.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.
@@ -24,11 +24,12 @@
24
24
  // • place-then-wire ORDER — the hook file is placed BEFORE settings are wired (a
25
25
  // wired-but-missing hook would error on every Bash call);
26
26
  // • a target hook file with DIFFERENT content while our entry is NOT wired is a precondition
27
- // STOP (no file write, no settings mutation — wiring an unknown script as a PreToolUse hook
28
- // is exactly what consent must not slide past); the recovery is named: delete the file to
29
- // reseed from the bundle. An identical file is `already current`; an already-wired entry
30
- // pointing at a diverged file is REPORTED, never unwired or clobbered (the cheap-agents
31
- // preserved-customization discipline — no refresh-in-place, no marker/checksum machinery);
27
+ // STOP for `--apply` (no file write, no settings mutation — wiring an unknown script as a
28
+ // PreToolUse hook is exactly what consent must not slide past); the recovery is named: the
29
+ // reseed route. An identical file is `already current`; an already-wired entry pointing at a
30
+ // diverged file is REPORTED, never unwired by `--apply`. A diverged hook is never clobbered
31
+ // except by `--apply --reseed`, the one consented replacement: it writes the bundled bytes in
32
+ // place over a placed hook and wires the entry only while it is wired in neither file;
32
33
  // • never writes settings.local.json; never commits.
33
34
  //
34
35
  // The `--read-lane` flag is a SEPARATE operation (AD-055 Part II): it enables the opt-in read-only
@@ -39,7 +40,7 @@
39
40
  // STOP; 2 usage. Dependency-free beyond the kit's own internal exports, Node >= 22. No side
40
41
  // effects on import.
41
42
 
42
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
43
+ import { constants as fsConstants, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
43
44
  import { dirname, join, resolve } from 'node:path';
44
45
  import { fileURLToPath } from 'node:url';
45
46
  import {
@@ -99,6 +100,7 @@ const TARGET_CURRENT = 'already-current';
99
100
  const TARGET_DIVERGED = 'diverged';
100
101
 
101
102
  const USAGE = `usage: gate-hook [--dry-run | --apply] [--cwd <dir>] [--help]
103
+ gate-hook --reseed [--dry-run | --apply] [--cwd <dir>] (replace a placed hook with the bundle)
102
104
  gate-hook --read-lane [--dry-run | --apply] [--cwd <dir>] (enable the opt-in read-only compound lane)
103
105
 
104
106
  Places the bundled PreToolUse gate-approval hook to ${HOOK_FILE_REL} and wires ONE
@@ -107,11 +109,16 @@ nothing). --apply writes. The hook auto-approves byte-exact declared gate cmds
107
109
  (docs/ai/gates.json, project root) and asks on seeded-read-only commands carrying the
108
110
  documented runtime residual.
109
111
 
112
+ --reseed replaces a PLACED hook whose bytes differ from the bundle with the bundled bytes, in
113
+ place, and wires the entry only while it is wired in neither settings file; a current hook wired
114
+ in either settings file is nothing to change, and a current unwired one is only wired. It refuses
115
+ an absent hook (--apply places it) and a symlinked or non-regular one.
116
+
110
117
  --read-lane enables the opt-in read-only COMPOUND lane in ${LANES_REL} ("${READ_LANE_KEY}": true) — the
111
118
  hook then auto-approves compounds of the seeded read-only core carrying zero shell metaprogramming.
112
119
  --apply --read-lane verifies the PLACED hook is the CURRENT bundle first (a pre-1.48 hook never reads
113
- ${LANES_REL}) and refuses with the delete-to-reseed recovery otherwise. Never touches ${SETTINGS_FILE}
114
- or docs/ai/gates.json.
120
+ ${LANES_REL}) and refuses otherwise, naming the reseed route (gate-hook --apply --reseed --cwd <dir>).
121
+ Never touches ${SETTINGS_FILE} or docs/ai/gates.json.
115
122
 
116
123
  Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
117
124
 
@@ -120,15 +127,33 @@ export const fail = (exitCode, message) => Object.assign(new Error(message), { e
120
127
  export const makeGateHookError = (code, message) =>
121
128
  Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'GateHookError', code, exitCode: EXIT_PRECONDITION });
122
129
 
123
- const fsDeps = (deps = {}) => ({
130
+ export const fsDeps = (deps = {}) => ({
124
131
  exists: deps.exists ?? deps.existsSync ?? existsSync,
125
132
  lstat: deps.lstat ?? deps.lstatSync ?? lstatSync,
126
133
  mkdir: deps.mkdir ?? deps.mkdirSync ?? mkdirSync,
127
134
  readFile: deps.readFile ?? deps.readFileSync ?? readFileSync,
128
135
  writeFile: deps.writeFile ?? deps.writeFileSync ?? writeFileSync,
136
+ noFollow: 'noFollow' in deps ? deps.noFollow : fsConstants.O_NOFOLLOW,
129
137
  });
130
138
 
131
- const lstatNoFollow = (absPath, fs) => {
139
+ // A hook file is written through open flags that refuse a symlink planted at the hook path after the
140
+ // preflight: a create is exclusive (O_EXCL fails on any existing entry), a replace opens no-follow and
141
+ // truncates in place (the inode stays). Without a no-follow flag a replace refuses before truncating.
142
+ const LATE_CHANGE_CODES = new Set(['EEXIST', 'ELOOP', 'EMLINK', 'ENOENT', 'EISDIR']);
143
+ export const writeHookFile = (absPath, rel, bytes, create, fs) => {
144
+ if (!create && !fs.noFollow) {
145
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `this platform exposes no O_NOFOLLOW open flag — refusing to replace ${rel} in place (fail closed)`);
146
+ }
147
+ const flag = create ? 'wx' : fsConstants.O_WRONLY | fs.noFollow | fsConstants.O_TRUNC;
148
+ try {
149
+ fs.writeFile(absPath, bytes, { encoding: UTF8, flag });
150
+ } catch (err) {
151
+ if (!LATE_CHANGE_CODES.has(err?.code)) throw err;
152
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `${rel} changed before the write — refusing to write through it (no settings change made); re-run`);
153
+ }
154
+ };
155
+
156
+ export const lstatNoFollow = (absPath, fs) => {
132
157
  try {
133
158
  return fs.lstat(absPath);
134
159
  } catch (err) {
@@ -153,7 +178,7 @@ export const readBundledHook = (deps = {}) => {
153
178
 
154
179
  // ── preflight (read-only; velocity discipline) ────────────────────────────────────────
155
180
 
156
- const readStamp = (absPath, fs) => {
181
+ export const readStamp = (absPath, fs) => {
157
182
  try {
158
183
  if (!fs.exists(absPath)) return null;
159
184
  const stamp = String(fs.readFile(absPath, UTF8)).trim();
@@ -163,14 +188,14 @@ const readStamp = (absPath, fs) => {
163
188
  }
164
189
  };
165
190
 
166
- const assertDirSafe = (absPath, relPath, fs) => {
191
+ export const assertDirSafe = (absPath, relPath, fs) => {
167
192
  const stat = lstatNoFollow(absPath, fs);
168
193
  if (stat === null) return;
169
194
  if (stat.isSymbolicLink()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
170
195
  if (!stat.isDirectory()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
171
196
  };
172
197
 
173
- const assertSettingsWritable = (absPath, fs) => {
198
+ export const assertSettingsWritable = (absPath, fs) => {
174
199
  const stat = lstatNoFollow(absPath, fs);
175
200
  if (stat !== null && !stat.isFile()) {
176
201
  throw makeGateHookError(GATE_HOOK_SYMLINK, `${SETTINGS_FILE} exists but is not a regular file — refusing to clobber it`);
@@ -178,12 +203,12 @@ const assertSettingsWritable = (absPath, fs) => {
178
203
  };
179
204
 
180
205
  // Target plan: place | already-current | diverged. A symlinked / non-regular target is a STOP —
181
- // never a write-through, never a comparison pretending it is a file.
182
- const planTarget = (absPath, bundleContent, fs) => {
206
+ // never a write-through, never a comparison pretending it is a file. `rel` names the judged file.
207
+ export const planTarget = (absPath, rel, bundleContent, fs) => {
183
208
  const stat = lstatNoFollow(absPath, fs);
184
209
  if (stat === null) return { action: TARGET_PLACE };
185
210
  if (stat.isSymbolicLink() || !stat.isFile()) {
186
- throw makeGateHookError(GATE_HOOK_SYMLINK, `${HOOK_FILE_REL} exists but is not a regular file — refusing to touch it`);
211
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `${rel} exists but is not a regular file — refusing to touch it`);
187
212
  }
188
213
  return fs.readFile(absPath, UTF8) === bundleContent ? { action: TARGET_CURRENT } : { action: TARGET_DIVERGED };
189
214
  };
@@ -191,22 +216,22 @@ const planTarget = (absPath, bundleContent, fs) => {
191
216
  // The reused readSettingsFile validates permissions.* ONLY — this writer scans and appends
192
217
  // inside `hooks`, so any existing `hooks` key must be shape-valid BEFORE a write is planned:
193
218
  // a malformed shape is a STOP with zero writes, never a merge-through-clobber.
194
- const assertHooksShape = (data, relPath) => {
219
+ export const assertHooksShape = (data, relPath, event = PRE_TOOL_USE_EVENT) => {
195
220
  if (data === undefined || data.hooks === undefined) return;
196
221
  if (!isJsonObject(data.hooks)) {
197
222
  throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks" must be a JSON object`);
198
223
  }
199
- const entries = data.hooks[PRE_TOOL_USE_EVENT];
224
+ const entries = data.hooks[event];
200
225
  if (entries === undefined) return;
201
226
  if (!Array.isArray(entries)) {
202
- throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}" must be an array`);
227
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${event}" must be an array`);
203
228
  }
204
229
  for (const entry of entries) {
205
230
  if (!isJsonObject(entry)) {
206
- throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: every "hooks.${PRE_TOOL_USE_EVENT}[]" entry must be a JSON object`);
231
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: every "hooks.${event}[]" entry must be a JSON object`);
207
232
  }
208
233
  if (entry.hooks !== undefined && (!Array.isArray(entry.hooks) || entry.hooks.some((hook) => !isJsonObject(hook)))) {
209
- throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}[].hooks" must be an array of objects`);
234
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${event}[].hooks" must be an array of objects`);
210
235
  }
211
236
  }
212
237
  };
@@ -243,15 +268,21 @@ export const mergeHookEntry = (base) => {
243
268
  return { ...root, hooks: { ...hooks, [PRE_TOOL_USE_EVENT]: [...entries, buildHookSettingsEntry()] } };
244
269
  };
245
270
 
246
- export const preflightGateHook = ({ cwd }, deps = {}) => {
271
+ export const preflightGateHook = ({ cwd, reseed = false }, deps = {}) => {
247
272
  const fs = fsDeps(deps);
248
- const projectDir = cwd ?? deps.cwd ?? process.cwd();
273
+ const projectDir = resolve(cwd ?? deps.cwd ?? process.cwd());
249
274
  const bundleContent = readBundledHook(deps);
250
275
  const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
251
276
  const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
252
277
  assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
253
278
  assertDirSafe(join(projectDir, HOOKS_DIR), HOOKS_DIR, fs);
254
- const target = planTarget(join(projectDir, HOOK_FILE_REL), bundleContent, fs);
279
+ const target = planTarget(join(projectDir, HOOK_FILE_REL), HOOK_FILE_REL, bundleContent, fs);
280
+ if (reseed && target.action === TARGET_PLACE) {
281
+ throw makeGateHookError(
282
+ GATE_HOOK_STALE,
283
+ `${HOOK_FILE_REL} is not placed — --reseed replaces a placed hook; place it with: ${applyGateHookCommand(projectDir)}`,
284
+ );
285
+ }
255
286
  assertSettingsWritable(join(projectDir, SETTINGS_FILE), fs);
256
287
  const projectSettings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
257
288
  const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
@@ -272,11 +303,12 @@ export const preflightGateHook = ({ cwd }, deps = {}) => {
272
303
  const wiredInLocal = isHookWired(localSettings.data);
273
304
  // Diverged target + our entry NOT wired anywhere = STOP on BOTH dry-run and apply: applying
274
305
  // would wire an unknown script as a PreToolUse hook — exactly what consent must not slide
275
- // past. (Diverged + already wired is the user's own standing state: report-only, below.)
276
- if (target.action === TARGET_DIVERGED && !wiredInProject && !wiredInLocal) {
306
+ // past. (Diverged + already wired is the user's own standing state: report-only, below.) Under
307
+ // --reseed this is exactly the state the reseed replaces before it wires.
308
+ if (!reseed && target.action === TARGET_DIVERGED && !wiredInProject && !wiredInLocal) {
277
309
  throw makeGateHookError(
278
310
  GATE_HOOK_DIVERGED,
279
- `${HOOK_FILE_REL} exists with DIFFERENT content and is not wired — refusing to wire an unknown script as a PreToolUse hook; delete the file to reseed from the bundle, then re-run`,
311
+ `${HOOK_FILE_REL} exists with DIFFERENT content and is not wired — refusing to wire an unknown script as a PreToolUse hook; reseed it from the bundle: ${applyReseedCommand(projectDir)}`,
280
312
  );
281
313
  }
282
314
 
@@ -285,15 +317,16 @@ export const preflightGateHook = ({ cwd }, deps = {}) => {
285
317
 
286
318
  // ── the writer ────────────────────────────────────────────────────────────────────────
287
319
 
288
- const formatJson = (data, eol) =>
320
+ export const formatJson = (data, eol) =>
289
321
  `${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
290
322
 
291
- export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
323
+ export const writeGateHook = ({ cwd, dryRun = true, reseed = false } = {}, deps = {}) => {
292
324
  const fs = fsDeps(deps);
293
- const preflight = preflightGateHook({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
325
+ const preflight = preflightGateHook({ cwd: cwd ?? deps.cwd ?? process.cwd(), reseed }, deps);
294
326
  const placePlanned = preflight.target.action === TARGET_PLACE;
327
+ const reseedPlanned = reseed && preflight.target.action === TARGET_DIVERGED;
295
328
  const wirePlanned = !preflight.wiredInProject && !preflight.wiredInLocal;
296
- const base = { placePlanned, wirePlanned, ...preflight };
329
+ const base = { placePlanned, reseedPlanned, wirePlanned, reseed, ...preflight };
297
330
  if (dryRun) return { wrote: false, dryRun: true, ...base };
298
331
 
299
332
  if (!preflight.stampOk) {
@@ -307,8 +340,9 @@ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
307
340
  const hookAbs = join(preflight.projectDir, HOOK_FILE_REL);
308
341
  if (placePlanned) {
309
342
  fs.mkdir(join(preflight.projectDir, HOOKS_DIR), { recursive: true });
310
- fs.writeFile(hookAbs, preflight.bundleContent, UTF8);
343
+ writeHookFile(hookAbs, HOOK_FILE_REL, preflight.bundleContent, true, fs);
311
344
  }
345
+ if (reseedPlanned) writeHookFile(hookAbs, HOOK_FILE_REL, preflight.bundleContent, false, fs); // in place: the inode stays
312
346
  if (wirePlanned) {
313
347
  // Re-verify the target no-follow immediately BEFORE wiring: preflight read it earlier, and an
314
348
  // external process could have swapped it (or slipped a symlink in) between preflight and now.
@@ -328,7 +362,7 @@ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
328
362
  UTF8,
329
363
  );
330
364
  }
331
- return { wrote: placePlanned || wirePlanned, dryRun: false, ...base };
365
+ return { wrote: placePlanned || reseedPlanned || wirePlanned, dryRun: false, ...base };
332
366
  };
333
367
 
334
368
  // ── the read-lane writer (--read-lane; AD-055 Part II, Decisions 5/9) ──────────────────
@@ -338,7 +372,7 @@ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
338
372
  // merge-preserve, symlink/non-regular refusal, malformed-JSON fail-closed, atomic), PLUS a CURRENCY
339
373
  // CHECK (Decisions 9): --apply refuses unless the PLACED hook is byte-identical to the current
340
374
  // bundle — enabling a lane a pre-1.48 hook can never read would be a silent no-op the user paid
341
- // consent for. The refusal names the delete-to-reseed recovery.
375
+ // consent for. The refusal names the reseed route.
342
376
 
343
377
  const laneStop = (message) => makeGateHookError(GATE_HOOK_LANE, message);
344
378
 
@@ -377,6 +411,7 @@ const preflightLaneTarget = (absPath, deps) => {
377
411
  };
378
412
 
379
413
  export const applyGateHookCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --apply --cwd ${q(root)}`;
414
+ export const applyReseedCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --apply --reseed --cwd ${q(root)}`;
380
415
  export const applyReadLaneCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --read-lane --cwd ${q(root)} --apply`;
381
416
 
382
417
  // The currency check (Decisions 9): the PLACED hook must be byte-identical to the current bundle,
@@ -399,7 +434,7 @@ const checkHookCurrency = (root, bundleContent, fs) => {
399
434
  return {
400
435
  current: false,
401
436
  reason: `${HOOK_FILE_REL} is not the current bundle`,
402
- refusal: `${HOOK_FILE_REL} is NOT the current bundle — enabling the read-lane now would be a silent no-op (a pre-1.48 hook never reads ${LANES_REL}). Reseed it, then re-run with --read-lane:\n rm ${q(join(root, HOOK_FILE_REL))}\n ${applyGateHookCommand(root)}`,
437
+ refusal: `${HOOK_FILE_REL} is NOT the current bundle — enabling the read-lane now would be a silent no-op (a pre-1.48 hook never reads ${LANES_REL}). Reseed it, then re-run with --read-lane:\n ${applyReseedCommand(root)}`,
403
438
  };
404
439
  }
405
440
  return { current: true, reason: 'the placed hook is the current bundle' };
@@ -499,7 +534,8 @@ const TRUST_POSTURE_LINE =
499
534
  const formatTargetLine = (result) => {
500
535
  if (result.target.action === TARGET_PLACE) return ` - ${HOOK_FILE_REL}: ${result.dryRun ? 'would place' : 'placed'}`;
501
536
  if (result.target.action === TARGET_CURRENT) return ` - ${HOOK_FILE_REL}: already current`;
502
- return ` - ${HOOK_FILE_REL}: diverged from the bundle — preserved, never clobbered (already wired; delete the file to reseed from the bundle)`;
537
+ if (result.reseed) return ` - ${HOOK_FILE_REL}: ${result.dryRun ? 'would replace' : 'replaced'} with the bundle (it differed from the bundled runtime)`;
538
+ return ` - ${HOOK_FILE_REL}: diverged from the bundle — preserved, never clobbered (already wired; reseed it from the bundle: ${applyReseedCommand(result.projectDir)})`;
503
539
  };
504
540
 
505
541
  const formatSettingsLine = (result) => {
@@ -508,10 +544,13 @@ const formatSettingsLine = (result) => {
508
544
  return ` - ${SETTINGS_FILE}: ${result.dryRun ? 'would wire' : 'wired'} the ${PRE_TOOL_USE_EVENT} "${HOOK_MATCHER}" entry`;
509
545
  };
510
546
 
547
+ const RESEED_CURRENT_LINE = 'nothing to change: the placed hook is current';
548
+
511
549
  export const formatResult = (result) => {
550
+ if (result.reseed && !result.reseedPlanned && !result.wirePlanned) return RESEED_CURRENT_LINE;
512
551
  const lines = [
513
552
  result.dryRun
514
- ? 'agent-workflow gate hook — DRY RUN (no changes; re-run with --apply)'
553
+ ? `agent-workflow gate hook — DRY RUN (no changes; re-run with --apply${result.reseed ? ' --reseed' : ''})`
515
554
  : 'agent-workflow gate hook — APPLY',
516
555
  formatTargetLine(result),
517
556
  formatSettingsLine(result),
@@ -530,13 +569,14 @@ export const formatResult = (result) => {
530
569
  // ── CLI ───────────────────────────────────────────────────────────────────────────────
531
570
 
532
571
  export const parseArgs = (argv) => {
533
- const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false, readLane: false };
572
+ const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false, readLane: false, reseed: false };
534
573
  for (let i = 0; i < argv.length; i += 1) {
535
574
  const arg = argv[i];
536
575
  if (arg === '--help' || arg === '-h') opts.help = true;
537
576
  else if (arg === '--dry-run') opts.dryRunFlag = true;
538
577
  else if (arg === '--apply') opts.apply = true;
539
578
  else if (arg === '--read-lane') opts.readLane = true;
579
+ else if (arg === '--reseed') opts.reseed = true;
540
580
  else if (arg === '--cwd') {
541
581
  i += 1;
542
582
  if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
@@ -546,7 +586,8 @@ export const parseArgs = (argv) => {
546
586
  }
547
587
  }
548
588
  if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
549
- return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd, readLane: opts.readLane };
589
+ if (opts.reseed && opts.readLane) throw fail(EXIT_USAGE, '--reseed and --read-lane cannot be used together');
590
+ return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd, readLane: opts.readLane, reseed: opts.reseed };
550
591
  };
551
592
 
552
593
  export const main = (argv = process.argv.slice(2), deps = {}) => {
@@ -563,7 +604,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
563
604
  log(formatReadLaneResult(laneResult));
564
605
  return EXIT_OK;
565
606
  }
566
- const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
607
+ const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun, reseed: args.reseed }, deps);
567
608
  log(formatResult(result));
568
609
  return EXIT_OK;
569
610
  } catch (err) {
@@ -47,6 +47,7 @@ import {
47
47
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
48
48
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
49
49
  import { laneOf, applySlot } from './write-lanes.mjs';
50
+ import { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME, isStopHookWired } from './state-block-hook.mjs';
50
51
  import { detectBackends } from './detect-backends.mjs';
51
52
  import { isDirectRun } from './direct-run.mjs';
52
53
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
@@ -599,16 +600,15 @@ const probeGates = ({ root, deps, add, skip, shared }) => {
599
600
  // ABSENT is not this arm either: a hook that is not placed belongs to the place offers above and
600
601
  // in the read-lane item, whose recovery actually places one.
601
602
  if (readPlacedHookCurrency(root, deps) !== HOOK_CURRENCY.STALE) return;
602
- // The writer's OWN stale recovery, in its exact shape: `gate-hook --apply` never overwrites a
603
- // placed diverged hook (it places only an ABSENT target), so remove-then-reseed is the only lane
604
- // that converges — and it is maintainer territory, hence HAND-APPLY.
603
+ // The writer's OWN stale recovery: `gate-hook --apply --reseed` replaces the placed hook with
604
+ // the bundle in place — the one consented replacement; the slot renders it behind the check.
605
605
  // The RESULT is what the read-lane item defers to — not these conditions re-derived there. Both
606
606
  // probes read the placed hook independently, so a hook that changes between the two reads would
607
607
  // otherwise let each conclude the other owns it and leave a dark lane reported by nobody.
608
608
  shared.markerStaleRendered = add(
609
609
  'gate-hook',
610
610
  fillTemplate(WHATS['gate-hook.marker-stale'], {}),
611
- `HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
611
+ `node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
612
612
  'gate-hook.marker-stale',
613
613
  );
614
614
  } catch (err) {
@@ -992,20 +992,20 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
992
992
  if (readReadLaneToggle(root, deps)) {
993
993
  // The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
994
994
  // reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
995
- // rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
996
- // repo's hook.
995
+ // reseed route pins the ABSOLUTE root (council R2-M3), so running it from any cwd can only
996
+ // replace this repo's hook.
997
997
  const currency = readPlacedHookCurrency(root, deps);
998
998
  if (currency === HOOK_CURRENCY.CURRENT) return; // converged
999
999
  if (currency === HOOK_CURRENCY.ABSENT) {
1000
1000
  // The survey saw it placed and it is gone by the time we read it. The missing arm is the
1001
- // honest report of that; a reseed recovery whose `rm` targets nothing is not.
1001
+ // honest report of that; a reseed recovery, which refuses an absent hook, is not.
1002
1002
  placeRecovery();
1003
1003
  return;
1004
1004
  }
1005
1005
  // ONE render per condition — and the item that renders is the one whose CAUSE is true. A hook
1006
1006
  // that postdates the read-lane but predates the marker key reads lanes.json perfectly well, so
1007
1007
  // this arm's "an old hook never reads lanes.json" would be a false diagnosis over it, while the
1008
- // marker arm's is exact. Both carry the same remove-then-reseed recovery, so deferring costs
1008
+ // marker arm's is exact. Both carry the same reseed recovery, so deferring costs
1009
1009
  // the reader nothing and buys a true sentence. The condition is that the marker arm REALLY
1010
1010
  // rendered — never the conditions it would have used, re-derived here: each probe reads the
1011
1011
  // placed hook itself, so a hook changing between the two reads could otherwise have both defer
@@ -1014,7 +1014,7 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
1014
1014
  add(
1015
1015
  'read-lane',
1016
1016
  fillTemplate(WHATS['read-lane.stale'], {}),
1017
- `HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
1017
+ `node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
1018
1018
  'read-lane.stale',
1019
1019
  );
1020
1020
  return;
@@ -1027,18 +1027,17 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
1027
1027
 
1028
1028
  // The state-block-guard offer (AD-075 · OPT-IN-SHIPS-INVISIBLE). This item exists because its absence
1029
1029
  // fired: 3.14.0 shipped the detector with a mode doc, a catalog row and a README row — and no advisor
1030
- // entry — so `upgrade` reported «nothing is broken» to a user who did not have it. There is no writer
1031
- // for this hook, so the apply is a HAND-APPLY pointer at the mode doc, which carries the exact block
1032
- // and the three merge cases.
1030
+ // entry — so `upgrade` reported «nothing is broken» to a user who did not have it. Its writer is
1031
+ // state-block-hook.mjs; convergence is that leaf's own isStopHookWired over both settings files.
1033
1032
  const probeStateBlockHook = ({ root, deps, add, skip }) => {
1034
1033
  try {
1035
1034
  const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
1036
1035
  const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
1037
- if (isStateBlockGuardWired(project.data) || isStateBlockGuardWired(local.data)) return; // converged
1036
+ if (isStopHookWired(project.data) || isStopHookWired(local.data)) return; // converged
1038
1037
  add(
1039
1038
  'state-block',
1040
1039
  fillTemplate(WHATS['state-block'], {}),
1041
- `HAND-APPLY: add a Stop hook running ${STATE_BLOCK_HOOK_COMMAND} to ${SETTINGS_FILE} — the exact block and the three merge cases are in references/modes/state-block-guard.md`,
1040
+ `node ${q(toolPath('state-block-hook.mjs'))} --apply --cwd ${q(root)}`,
1042
1041
  );
1043
1042
  } catch (err) {
1044
1043
  skip('state-block', err);
@@ -1215,28 +1214,9 @@ export const READ_LANE_KEY = 'readLane';
1215
1214
  const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
1216
1215
  const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
1217
1216
 
1218
- // The state-block-guard wiring the advisor looks for. Matched on the RUNTIME FILE NAME inside the
1219
- // command, not on an exact string: the hook has no writer, so every user pastes their own path —
1220
- // a copy under `.claude/hooks/`, a kit-source path, `--require-block` or not. Any Stop entry that
1221
- // runs this runtime counts as wired, which is the honest question ("is it watching?").
1222
- export const STATE_BLOCK_HOOK_RUNTIME = 'state-block-guard.mjs';
1223
- // NO `--require-block` in the offered command. That flag turns on the absent-block report, and this
1224
- // kit does not mandate the three-part closing block — recommending it to every project would hand
1225
- // them a hook that warns after nearly every turn. The mode doc explains when to add it. (Offering the
1226
- // strict flag by default would also contradict the very reason the report was made opt-in.)
1227
- export const STATE_BLOCK_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${STATE_BLOCK_HOOK_RUNTIME}"`;
1228
- const runsStateBlockGuard = (hook) => typeof hook?.command === 'string' && hook.command.includes(STATE_BLOCK_HOOK_RUNTIME);
1229
- // Both entry shapes count. The matcher-group form (an object carrying its own `hooks` array) is what
1230
- // this family ships and what was observed firing; a flat entry is accepted too, because the question
1231
- // this probe answers is "is anything watching?" — and guessing NO for a wiring that works would nag
1232
- // someone who already did the work.
1233
- const isStateBlockGuardWired = (data) => {
1234
- const entries = data?.hooks?.Stop;
1235
- if (!Array.isArray(entries)) return false;
1236
- return entries.some((entry) => (Array.isArray(entry?.hooks)
1237
- ? entry.hooks.some(runsStateBlockGuard)
1238
- : runsStateBlockGuard(entry)));
1239
- };
1217
+ // The state-block-guard wiring the advisor looks for is the writer leaf's own rule (any Stop entry of
1218
+ // either settings file running the runtime); the two constants are re-exported for their readers.
1219
+ export { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME };
1240
1220
 
1241
1221
  // The placed gate hook's currency, as the states a caller must tell APART (D8). Read through the
1242
1222
  // writer-class fail-closed no-follow primitive, never a bare readFile: a byte-compare that FOLLOWS a
@@ -1,8 +1,8 @@
1
1
  // script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
2
2
  // into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
3
3
  // before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
4
- // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, never
5
- // overwritten). The lens-region idiom (AD-041) over whole script bodies.
4
+ // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, replaced only by
5
+ // the replace run the user's yes starts). The lens-region idiom (AD-041) over whole script bodies.
6
6
  //
7
7
  // Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
8
8
  // in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
@@ -36,6 +36,7 @@ export const SCRIPT_PRIORS = Object.freeze([
36
36
  prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
37
37
  prior('spec-schema.mjs', '5.0.0', '7.2.1', '6a7d1e836c158baab5a412a1ad76d34891934dc28c88486c264a4b5d46f5892a'),
38
38
  prior('spec-schema.test.mjs', '5.0.0', '7.2.1', '698564d7283281be1826019d214df475bb876687915de086818d8f5ece0e0336'),
39
+ prior('check-docs-size.mjs', '7.0.0', '8.1.5', '0cf90ae2cbfc809847574a8c71a6b1322c783359740b08e0ff179f0d9d04dffc'),
39
40
  ]);
40
41
 
41
42
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');