@sabaiway/agent-workflow-kit 14.30.0 → 14.32.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.
@@ -15,6 +15,7 @@ import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-
15
15
  import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
16
16
  import { loadConfig } from './orchestration-config.mjs';
17
17
  import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
18
+ import { STATE_BLOCK_HOOK_FILE_REL, buildStopHookEntry, mergeStopEntry, writeStateBlockHook } from './state-block-hook.mjs';
18
19
  import { writeMcp } from './mcp.mjs';
19
20
  import { ENABLED_KEY, MCP_JSON_REL, SERVERS_KEY, SERVER_NAME, allowRulesFor, buildServerEntry } from './mcp-registration.mjs';
20
21
  import { AGENTS_DIR, writeCheapAgents } from './cheap-agents.mjs';
@@ -22,16 +23,22 @@ import { planFor } from './jev-skill.mjs';
22
23
  import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
23
24
  import { findOnPath } from './detect-backends.mjs';
24
25
  import { consoleArgv } from './write-lanes.mjs';
26
+ import { BUNDLED_SCRIPT_NAMES, INSTALLER_REL, SCRIPTS_REL, formatPlan, planGitHooks } from './git-hooks.mjs';
25
27
  import { isDirectRun } from './direct-run.mjs';
26
28
  import { dirCovers as covers } from './declared-paths.mjs';
27
29
 
28
30
  const TOOLS = dirname(fileURLToPath(import.meta.url));
29
31
  const SKILL_VARIANTS = ['jev-skill', 'jev-skill.earlier'];
30
32
  const VELOCITY_VARIANTS = ['velocity-core', 'kit-tools-tier', 'bridge-tier'];
31
- const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing'];
33
+ const RESEED_VARIANTS = ['gate-hook.marker-stale', 'read-lane.stale'];
34
+ const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing', ...RESEED_VARIANTS];
35
+ const STOP = 'state-block';
32
36
  const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
33
37
  const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
34
- const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, ...MCP_VARIANTS, ...AGENT_VARIANTS, ...SKILL_VARIANTS];
38
+ const GIT_VARIANTS = ['enforcement', 'commit-guard'];
39
+ const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, STOP, ...MCP_VARIANTS, ...AGENT_VARIANTS,
40
+ ...SKILL_VARIANTS, ...GIT_VARIANTS];
41
+ const HOOK_KEYS = ['hooks.PreToolUse', 'hooks.Stop'];
35
42
  const CREDENTIALS = 'sandbox.credentials.envVars';
36
43
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
37
44
  const stable = (value) => object(value)
@@ -56,6 +63,7 @@ export const KEY_TABLE = Object.freeze({
56
63
  'permissions.deny': 3,
57
64
  'sandbox.enabled': 3,
58
65
  'hooks.PreToolUse': 4,
66
+ 'hooks.Stop': 4,
59
67
  [ENABLED_KEY]: 4,
60
68
  [SERVERS_KEY]: 4,
61
69
  [CREDENTIALS]: 5,
@@ -67,7 +75,7 @@ const valid = (key, value, element = false) => {
67
75
  if (SCALARS.includes(key)) return typeof value === 'boolean';
68
76
  if (!element) return Array.isArray(value) && value.every((entry) => valid(key, entry, true));
69
77
  if (key === CREDENTIALS) return object(value) && typeof value.name === 'string' && typeof value.mode === 'string';
70
- return key === 'hooks.PreToolUse' ? object(value) : typeof value === 'string';
78
+ return HOOK_KEYS.includes(key) ? object(value) : typeof value === 'string';
71
79
  };
72
80
  const keyOf = (key) => key.startsWith(`${SERVERS_KEY}.`) ? SERVERS_KEY : key;
73
81
 
@@ -174,6 +182,7 @@ export const scopeOf = (variant, facts, deps = {}) => {
174
182
  set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
175
183
  allowed[CREDENTIALS] = (value) => value.mode === 'deny';
176
184
  } else if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
185
+ else if (variant === STOP) set('hooks.Stop', [buildStopHookEntry()]);
177
186
  else if (MCP_VARIANTS.includes(variant)) {
178
187
  set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
179
188
  set(ENABLED_KEY, [SERVER_NAME]);
@@ -181,20 +190,27 @@ export const scopeOf = (variant, facts, deps = {}) => {
181
190
  }
182
191
  const targets = SKILL_VARIANTS.includes(variant) ? skillTargets({ home: facts.home,
183
192
  claudeDir: facts.claudeDir ?? claudeDirOf(facts.env, facts.home, facts.platform).dir }) : [];
184
- const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)] : [];
185
- const directories = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render'
186
- || HOOK_VARIANTS.includes(variant) || MCP_VARIANTS.includes(variant) || AGENT_VARIANTS.includes(variant)
187
- ? [join(facts.root, CLAUDE_DIR)] : [];
188
- if (HOOK_VARIANTS.includes(variant)) directories.push(join(facts.root, HOOKS_DIR));
193
+ const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)]
194
+ : variant === STOP ? [join(facts.root, STATE_BLOCK_HOOK_FILE_REL)] : [];
195
+ const directories = SKILL_VARIANTS.includes(variant) ? [] : [join(facts.root, CLAUDE_DIR)];
196
+ if (HOOK_VARIANTS.includes(variant) || variant === STOP) directories.push(join(facts.root, HOOKS_DIR));
189
197
  if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
190
198
  for (const target of targets) {
191
199
  for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
192
200
  filePaths.push(target.path);
193
201
  }
202
+ const scripts = join(facts.root, SCRIPTS_REL);
203
+ if (GIT_VARIANTS.includes(variant)) {
204
+ directories.splice(0, directories.length, scripts);
205
+ filePaths.push(...BUNDLED_SCRIPT_NAMES.map((name) => join(scripts, name)));
206
+ }
207
+ const runs = GIT_VARIANTS.includes(variant) ? ['node', INSTALLER_REL,
208
+ ...(variant === 'commit-guard' ? ['--commit-guard', join(facts.toolsDir, 'commit-guard.mjs')] : [])] : null;
194
209
  return {
210
+ admitsRun: (argv) => runs !== null && equal(argv, runs),
195
211
  admits: ({ path, key, change, value }) => {
196
212
  const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
197
- const gains = variant === 'bridge-tier' || key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS
213
+ const gains = variant === 'bridge-tier' || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
198
214
  || (VELOCITY_VARIANTS.includes(variant) && key === 'permissions.ask');
199
215
  return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
200
216
  },
@@ -207,7 +223,7 @@ const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8'
207
223
  const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
208
224
  const maskedFiles = (variant, facts) => {
209
225
  const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
210
- ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
226
+ || variant === STOP ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
211
227
  return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
212
228
  ? [`masked: ${path}`] : []);
213
229
  };
@@ -215,6 +231,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
215
231
  const files = [];
216
232
  const writes = [];
217
233
  const temporaries = [];
234
+ let runs = null;
218
235
  const writerDeps = writerFacts(facts, deps);
219
236
  const addFile = (path, before, after) => {
220
237
  files.push({ path, before: before ?? null, after });
@@ -243,9 +260,17 @@ export const plannedChange = (variant, facts, deps = {}) => {
243
260
  const before = jsonAt(join(facts.root, SETTINGS_FILE));
244
261
  addFile(SETTINGS_FILE, before, mergeAutonomySettings(before, render));
245
262
  } else if (HOOK_VARIANTS.includes(variant)) {
246
- const plan = writeGateHook({ cwd: facts.root, dryRun: true }, writerDeps);
263
+ const plan = writeGateHook({ cwd: facts.root, dryRun: true, reseed: RESEED_VARIANTS.includes(variant) }, writerDeps);
247
264
  if (plan.wirePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeHookEntry(plan.projectSettings.data));
248
- if (plan.placePlanned) writes.push({ path: join(facts.root, HOOK_FILE_REL), bytes: plan.bundleContent });
265
+ const path = join(facts.root, HOOK_FILE_REL);
266
+ if (plan.placePlanned) writes.push({ path, bytes: plan.bundleContent });
267
+ if (plan.reseedPlanned) writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
268
+ } else if (variant === STOP) {
269
+ const plan = writeStateBlockHook({ cwd: facts.root, dryRun: true }, writerDeps);
270
+ if (plan.mergePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeStopEntry(plan.projectSettings.data));
271
+ const path = join(facts.root, STATE_BLOCK_HOOK_FILE_REL);
272
+ if (plan.placement === 'create') writes.push({ path, bytes: plan.bundleContent });
273
+ if (plan.placement === 'replace') writes.push({ path, bytes: plan.bundleContent, replaces: readFileSync(path) });
249
274
  } else if (MCP_VARIANTS.includes(variant)) {
250
275
  const result = writeMcp({ cwd: facts.root, dryRun: true, replace: variant === 'mcp-channel.differing' }, writerDeps);
251
276
  const { plan, registration } = result;
@@ -266,6 +291,10 @@ export const plannedChange = (variant, facts, deps = {}) => {
266
291
  temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-tmp-*`);
267
292
  if (target.action === 'replace') temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-old-*`);
268
293
  }
294
+ } else if (GIT_VARIANTS.includes(variant)) {
295
+ const plan = (deps.planGitHooks ?? planGitHooks)({ root: facts.root, variant, toolsDir: facts.toolsDir });
296
+ for (const copy of plan.copies.filter(({ action }) => action === 'created')) writes.push({ path: copy.path, bytes: copy.bytes });
297
+ runs = { argv: plan.runs.shown, line: formatPlan(plan).at(-1) };
269
298
  } else throw new Error(`unsupported variant: ${variant}`);
270
299
  } catch (error) {
271
300
  throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
@@ -275,7 +304,7 @@ export const plannedChange = (variant, facts, deps = {}) => {
275
304
  for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
276
305
  for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
277
306
  }
278
- return { files, writes, temporaries, directories: [...directories] };
307
+ return { files, writes, temporaries, directories: [...directories], runs };
279
308
  };
280
309
 
281
310
  export const checkApply = (variant, facts, deps = {}) => {
@@ -298,9 +327,16 @@ export const checkApply = (variant, facts, deps = {}) => {
298
327
  if (!scope.admitsPath(path, directory)) refused.push({ path: display(path), key: display(path),
299
328
  reason: `beyond the declared scope of ${variant}` });
300
329
  };
301
- for (const write of plan.writes) pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}`);
330
+ for (const write of plan.writes) {
331
+ const replaces = write.replaces === undefined ? '' : ` — replaces sha256:${sha(write.replaces)}`;
332
+ pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}${replaces}`);
333
+ }
302
334
  for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
303
335
  lines.push(...plan.temporaries);
336
+ if (plan.runs) {
337
+ lines.push(plan.runs.line);
338
+ if (!scope.admitsRun(plan.runs.argv)) refused.push({ path: 'runs:', key: plan.runs.argv.join(' '), reason: `beyond the declared scope of ${variant}` });
339
+ }
304
340
  if (refused.length) {
305
341
  for (const entry of refused) lines.push(`refused: ${entry.path} ${entry.key} — ${entry.reason}`);
306
342
  lines.push('nothing written');
@@ -352,7 +388,7 @@ export const main = (argv, io = {}) => {
352
388
  platform: resolved.platform, claudeDir: args['claude-dir'] };
353
389
  let result;
354
390
  try {
355
- result = checkApply(args.variant, facts, { detect: resolved.detect });
391
+ result = checkApply(args.variant, facts, { detect: resolved.detect, planGitHooks: resolved.planGitHooks });
356
392
  } catch (error) {
357
393
  for (const line of error.lines ?? [error.message]) resolved.log(line);
358
394
  return 3;
@@ -165,8 +165,8 @@ const CATALOG = [
165
165
  key: 'state-block-guard',
166
166
  invocation: invocationOf('state-block-guard'),
167
167
  group: 'Configure',
168
- kind: READ_ONLY,
169
- oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; you paste the wiring).',
168
+ kind: WRITER,
169
+ oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; its writer places and wires it on your chat yes).',
170
170
  },
171
171
  {
172
172
  key: 'bridge-settings',
@@ -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) {