@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.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
3
+ // the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
4
+ // decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
5
+ // copies and the installer bytes this dry run answers, never git's hooks dir.
6
+ import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { writeProjectFileCreateOnly } from './atomic-write.mjs';
12
+ import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
13
+ import { shellQuoteArg } from './repo-lex.mjs';
14
+ import { isDirectRun } from './direct-run.mjs';
15
+
16
+ const TOOLS = dirname(fileURLToPath(import.meta.url));
17
+ export const SCRIPTS_REL = 'scripts';
18
+ export const INSTALLER_REL = 'scripts/install-git-hooks.mjs';
19
+ const bundleDir = (toolsDir) => join(toolsDir, '..', 'references', 'scripts');
20
+ export const BUNDLED_SCRIPT_NAMES = Object.freeze(readdirSync(bundleDir(TOOLS)).filter((name) => name.endsWith('.mjs')).sort());
21
+
22
+ const EXIT_OK = 0;
23
+ const EXIT_INCOMPLETE = 1;
24
+ const EXIT_USAGE = 2;
25
+ const EXIT_REFUSED = 3;
26
+ const USAGE = 'usage: git-hooks (--enforcement | --commit-guard) [--cwd <root>] [--apply]';
27
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
28
+ const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
29
+ const refusal = (message) => Object.assign(new Error(message), { exitCode: EXIT_REFUSED });
30
+
31
+ const nodeAt = (path, lstat) => {
32
+ try {
33
+ return lstat(path);
34
+ } catch (error) {
35
+ if (error?.code === 'ENOENT') return null;
36
+ throw error;
37
+ }
38
+ };
39
+ const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
40
+ const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
41
+
42
+ // The dry run: what the apply copies and the installer it runs, with the bytes of each.
43
+ export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
44
+ const scripts = join(root, SCRIPTS_REL);
45
+ const scriptsNode = nodeAt(scripts, lstat);
46
+ if (scriptsNode !== null && !isPlainDir(scriptsNode)) throw refusal(`${scripts} is not a plain directory`);
47
+ const kinds = new Map();
48
+ const names = variant === 'enforcement' ? BUNDLED_SCRIPT_NAMES : [INSTALLER_REL.slice(SCRIPTS_REL.length + 1)];
49
+ for (const name of scriptsNode === null ? [] : names) {
50
+ const path = join(scripts, name);
51
+ const node = nodeAt(path, lstat);
52
+ if (node !== null && !isFile(node)) throw refusal(`${path} is not a regular file`);
53
+ kinds.set(name, node === null ? 'absent' : 'file');
54
+ }
55
+ const installer = join(root, INSTALLER_REL);
56
+ const deployed = NODE_EVIDENCE_SCRIPTS.some((name) => kinds.get(name) === 'file');
57
+ const copies = variant === 'enforcement' && !deployed ? BUNDLED_SCRIPT_NAMES.map((name) => {
58
+ if (kinds.get(name) === 'file') return { name, path: join(scripts, name), action: 'kept' };
59
+ return { name, path: join(scripts, name), action: 'created', bytes: readFile(join(bundleDir(toolsDir), name)) };
60
+ }) : [];
61
+ const placed = copies.find(({ path, action }) => path === installer && action === 'created');
62
+ if (!placed && !isFile(nodeAt(installer, lstat))) throw refusal(`${installer} is absent — nothing here places the hook installer`);
63
+ const guard = variant === 'commit-guard' ? ['--commit-guard', join(toolsDir, 'commit-guard.mjs')] : [];
64
+ return {
65
+ root,
66
+ variant,
67
+ scriptsCreated: scriptsNode === null,
68
+ copies,
69
+ runs: { argv: ['node', installer, ...guard], shown: ['node', INSTALLER_REL, ...guard], bytes: placed ? placed.bytes : readFile(installer) },
70
+ };
71
+ };
72
+
73
+ export const formatPlan = (plan) => [
74
+ ...plan.copies.map(({ name, action, bytes }) => `copy: ${SCRIPTS_REL}/${name} ${action}${action === 'created' ? ` sha256:${sha(bytes)}` : ''}`),
75
+ `runs: ${plan.runs.shown.map(shellQuoteArg).join(' ')} sha256:${sha(plan.runs.bytes)}`,
76
+ ];
77
+
78
+ // Removes exactly the files this run created, then scripts/ when this run created it and it is empty;
79
+ // every path it cannot remove is answered for the caller to name.
80
+ const rollBack = (plan, created, io) => {
81
+ const scripts = join(plan.root, SCRIPTS_REL);
82
+ const left = [];
83
+ const dirNow = () => nodeAt(scripts, io.lstat);
84
+ for (const path of created) {
85
+ const dir = dirNow();
86
+ const node = nodeAt(path, io.lstat);
87
+ if (!isPlainDir(dir) || dir.ino !== created.dirIno || !isFile(node)) {
88
+ left.push(path);
89
+ continue;
90
+ }
91
+ try {
92
+ io.unlink(path);
93
+ } catch {
94
+ left.push(path);
95
+ }
96
+ }
97
+ if (plan.scriptsCreated && created.dirIno !== undefined) {
98
+ const dir = dirNow();
99
+ let empty = false;
100
+ try {
101
+ empty = isPlainDir(dir) && dir.ino === created.dirIno && io.readdir(scripts).length === 0;
102
+ } catch {
103
+ empty = false;
104
+ }
105
+ if (empty) {
106
+ try {
107
+ io.rmdir(scripts);
108
+ } catch {
109
+ left.push(scripts);
110
+ }
111
+ } else if (!isPlainDir(dir) || dir.ino !== created.dirIno) left.push(scripts);
112
+ }
113
+ return left;
114
+ };
115
+
116
+ export const applyGitHooks = (plan, io = {}) => {
117
+ const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
118
+ log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
119
+ spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
120
+ const created = [];
121
+ const leftovers = [];
122
+ const fail = (lines) => {
123
+ for (const line of lines) resolved.error(line);
124
+ for (const path of [...rollBack(plan, created, resolved), ...leftovers]) resolved.error(`not removed: ${path}`);
125
+ return EXIT_INCOMPLETE;
126
+ };
127
+ const scripts = join(plan.root, SCRIPTS_REL);
128
+ const noteDir = () => {
129
+ const dir = nodeAt(scripts, resolved.lstat);
130
+ if (created.dirIno === undefined && isPlainDir(dir)) created.dirIno = dir.ino;
131
+ };
132
+ for (const copy of plan.copies.filter(({ action }) => action === 'created')) {
133
+ let result;
134
+ try {
135
+ result = resolved.writeFile(plan.root, `${SCRIPTS_REL}/${copy.name}`, copy.bytes);
136
+ } catch (error) {
137
+ noteDir();
138
+ return fail([error?.message ?? String(error)]);
139
+ }
140
+ noteDir();
141
+ if (result.tmpLeftBehind) leftovers.push(result.tmpLeftBehind);
142
+ if (!result.created) return fail([`${copy.path} appeared after the check — the file found there stands`]);
143
+ created.push(copy.path);
144
+ }
145
+ const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
146
+ if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
147
+ for (const path of leftovers) resolved.error(`not removed: ${path}`);
148
+ return EXIT_OK;
149
+ };
150
+
151
+ export const parseArgs = (argv) => {
152
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
153
+ const args = { apply: false, cwd: undefined, variants: [] };
154
+ for (let index = 0; index < argv.length; index += 1) {
155
+ const flag = argv[index];
156
+ if (flag === '--apply') args.apply = true;
157
+ else if (flag === '--enforcement' || flag === '--commit-guard') args.variants.push(flag.slice(2));
158
+ else if (flag === '--cwd') {
159
+ const value = argv[index + 1];
160
+ if (value === undefined || value.startsWith('-')) throw usageError('--cwd takes a value');
161
+ args.cwd = value;
162
+ index += 1;
163
+ } else throw usageError(`unknown argument: ${flag}`);
164
+ }
165
+ if (args.variants.length !== 1) throw usageError('pass exactly one of --enforcement and --commit-guard');
166
+ return { help: false, apply: args.apply, cwd: args.cwd, variant: args.variants[0] };
167
+ };
168
+
169
+ export const main = (argv, io = {}) => {
170
+ const resolved = { log: console.log, error: console.error, cwd: process.cwd(), toolsDir: TOOLS, ...io };
171
+ let args;
172
+ try {
173
+ args = parseArgs(argv);
174
+ if (args.help) {
175
+ resolved.log(USAGE);
176
+ return EXIT_OK;
177
+ }
178
+ args.root = resolve(resolved.cwd, args.cwd ?? '.');
179
+ if (!statSync(args.root, { throwIfNoEntry: false })?.isDirectory()) throw usageError(`--cwd is not a directory: ${args.root}`);
180
+ } catch (error) {
181
+ resolved.error(error.message);
182
+ resolved.error(USAGE);
183
+ return EXIT_USAGE;
184
+ }
185
+ let plan;
186
+ try {
187
+ plan = planGitHooks({ root: args.root, variant: args.variant, toolsDir: resolved.toolsDir });
188
+ } catch (error) {
189
+ resolved.error(error.message);
190
+ return EXIT_REFUSED;
191
+ }
192
+ if (!args.apply) {
193
+ for (const line of formatPlan(plan)) resolved.log(line);
194
+ return EXIT_OK;
195
+ }
196
+ return applyGitHooks(plan, resolved);
197
+ };
198
+
199
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -46,7 +46,8 @@ import {
46
46
  } from './velocity-profile.mjs';
47
47
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
48
48
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
49
- import { laneOf, applySlot } from './write-lanes.mjs';
49
+ import { laneOf, applySlot, consoleArgv } 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';
@@ -119,8 +120,10 @@ const ABSENT_CODE = 'ENOENT';
119
120
  const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
120
121
  const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
121
122
  const ENFORCEMENT_WHAT = 'the enforcement scripts are not deployed beside docs/ai (no Node evidence) \u2014 the offer copies them and installs the pre-commit hook';
122
- const ENFORCEMENT_APPLY_TEMPLATE = 'HAND-APPLY: mkdir -p {root}/scripts && cp {kit}/references/scripts/*.mjs {root}/scripts/ && node {root}/scripts/install-git-hooks.mjs';
123
- const COMMIT_GUARD_INSTALLER_REMEDY = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 deploy the enforcement scripts (bootstrap steps 8\u20139) and install the hook, then re-run';
123
+ const ENFORCEMENT_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it, so the kit places no script here; with that hook moved aside (your own step) the enforcement item is offered';
124
+ const COMMIT_GUARD_INSTALLER_VIA_ITEM = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 apply the enforcement item first, then re-run';
125
+ const COMMIT_GUARD_INSTALLER_OWN_STEP = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed here \u2014 deploying the enforcement scripts into this project is your own step, then re-run';
126
+ const COMMIT_GUARD_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it; adding the guard line to it is your own step';
124
127
  // ── the section contract tokens (doc-parity-bound in upgrade.md + the mode doc) ────────────────
125
128
  export const RECOMMENDATIONS_SECTION_HEADER = '## Recommendations (agent-workflow)';
126
129
  export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.';
@@ -599,16 +602,15 @@ const probeGates = ({ root, deps, add, skip, shared }) => {
599
602
  // ABSENT is not this arm either: a hook that is not placed belongs to the place offers above and
600
603
  // in the read-lane item, whose recovery actually places one.
601
604
  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.
605
+ // The writer's OWN stale recovery: `gate-hook --apply --reseed` replaces the placed hook with
606
+ // the bundle in place — the one consented replacement; the slot renders it behind the check.
605
607
  // The RESULT is what the read-lane item defers to — not these conditions re-derived there. Both
606
608
  // probes read the placed hook independently, so a hook that changes between the two reads would
607
609
  // otherwise let each conclude the other owns it and leave a dark lane reported by nobody.
608
610
  shared.markerStaleRendered = add(
609
611
  'gate-hook',
610
612
  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)}`,
613
+ `node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
612
614
  'gate-hook.marker-stale',
613
615
  );
614
616
  } catch (err) {
@@ -883,7 +885,7 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
883
885
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
884
886
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
885
887
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
886
- // alike; an UNMANAGED hook is a loud skip (manual merge, never an overwrite offer); a
888
+ // alike; an UNMANAGED hook is a loud skip naming the user's own step (never an overwrite offer); a
887
889
  // non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
888
890
  // receipt would block every commit).
889
891
  const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
@@ -892,6 +894,23 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
892
894
  // the kit's OWN resolved guard path. A commented-out line or a lookalike path (fake-commit-guard)
893
895
  // must never read as armed (that would suppress the offer and leave commits unguarded).
894
896
  const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
897
+ // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
898
+ export const readPreCommitState = (root, deps = {}) => {
899
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
900
+ if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
901
+ const path = join(hooksPath, 'pre-commit');
902
+ const hook = (() => {
903
+ try {
904
+ if (readsAbsent(deps, path)) return null;
905
+ return String((deps.readFile ?? readFileSync)(path, 'utf8'));
906
+ } catch {
907
+ return null;
908
+ }
909
+ })();
910
+ if (hook === null) return { state: 'absent', armed: false };
911
+ return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe().test(hook) };
912
+ };
913
+ const formsApply = (variant, root) => consoleArgv(variant, { toolsDir: HERE, root }).apply.map(q).join(' ');
895
914
  const probeCommitGuard = ({ root, deps, add, skip }) => {
896
915
  try {
897
916
  const read = deps.readFile ?? readFileSync;
@@ -907,25 +926,16 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
907
926
  if (!isFinalCapableDeclaration(gates, root)) return; // no receipt to guard yet — no offer
908
927
  const installer = join(root, 'scripts', 'install-git-hooks.mjs');
909
928
  if (!(deps.exists ?? existsSync)(installer)) {
910
- skip('commit-guard', new Error(COMMIT_GUARD_INSTALLER_REMEDY));
929
+ skip('commit-guard', new Error(enforcementAdmits(root, deps) ? COMMIT_GUARD_INSTALLER_VIA_ITEM : COMMIT_GUARD_INSTALLER_OWN_STEP));
911
930
  return;
912
931
  }
913
- const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
914
- if (hooksPath === null) return; // not a git tree — nothing to hook
915
- const hook = (() => {
916
- try {
917
- if (readsAbsent(deps, join(hooksPath, 'pre-commit'))) return null;
918
- return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
919
- } catch {
920
- return null;
921
- }
922
- })();
923
- if (hook !== null && canonicalGuardLineRe().test(hook)) return; // armed — converged (managed or not)
924
- if (hook !== null && !INSTALLER_MARKER_RE.test(hook)) {
925
- skip('commit-guard', new Error('an UNMANAGED pre-commit hook exists — merge the guard line by hand (the installer refuses to overwrite it)'));
932
+ const { state, armed } = readPreCommitState(root, deps);
933
+ if (armed || state === 'no-hooks-dir') return; // armed (managed or not), or not a git tree — converged
934
+ if (state === 'unmanaged') {
935
+ skip('commit-guard', new Error(COMMIT_GUARD_UNMANAGED_HOOK));
926
936
  return;
927
937
  }
928
- add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), `node ${q(installer)} --commit-guard ${q(toolPath('commit-guard.mjs'))}`);
938
+ add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), formsApply('commit-guard', root));
929
939
  } catch (err) {
930
940
  skip('commit-guard', err);
931
941
  }
@@ -940,31 +950,46 @@ const readEnforcementNode = (path, lstat) => {
940
950
  }
941
951
  };
942
952
 
953
+ // The enforcement offer's one admission: true when the item renders, false when nothing renders, and
954
+ // a throw names the skip.
955
+ const enforcementOffer = (root, deps) => {
956
+ const lstat = deps.lstat ?? lstatSync;
957
+ const docsAi = join(root, 'docs', 'ai');
958
+ const docsAiNode = readEnforcementNode(docsAi, lstat);
959
+ if (docsAiNode === null) return false;
960
+ if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
961
+ const evidence = probeNodeEvidence(root, lstat);
962
+ if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return false;
963
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
964
+ if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
965
+ if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
966
+ const readDir = deps.readdir ?? readdirSync;
967
+ const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
968
+ const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
969
+ if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
970
+ const scripts = join(root, 'scripts');
971
+ const scriptsNode = readEnforcementNode(scripts, lstat);
972
+ if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
973
+ for (const basename of scriptsNode === null ? [] : bundleBasenames) {
974
+ const destination = join(scripts, basename);
975
+ if (readEnforcementNode(destination, lstat) === null) continue;
976
+ throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
977
+ }
978
+ if (readPreCommitState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
979
+ return true;
980
+ };
981
+
982
+ export const enforcementAdmits = (root, deps = {}) => {
983
+ try {
984
+ return enforcementOffer(root, deps);
985
+ } catch {
986
+ return false;
987
+ }
988
+ };
989
+
943
990
  export const probeEnforcement = ({ root, deps, add, skip }) => {
944
991
  try {
945
- const lstat = deps.lstat ?? lstatSync;
946
- const docsAi = join(root, 'docs', 'ai');
947
- const docsAiNode = readEnforcementNode(docsAi, lstat);
948
- if (docsAiNode === null) return;
949
- if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
950
- const evidence = probeNodeEvidence(root, lstat);
951
- if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return;
952
- if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
953
- if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
954
- if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
955
- const readDir = deps.readdir ?? readdirSync;
956
- const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
957
- const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
958
- if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
959
- const scripts = join(root, 'scripts');
960
- const scriptsNode = readEnforcementNode(scripts, lstat);
961
- if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
962
- for (const basename of scriptsNode === null ? [] : bundleBasenames) {
963
- const destination = join(scripts, basename);
964
- if (readEnforcementNode(destination, lstat) === null) continue;
965
- throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
966
- }
967
- add('enforcement', ENFORCEMENT_WHAT, fillTemplate(ENFORCEMENT_APPLY_TEMPLATE, { root: q(root), kit: q(ENFORCEMENT_KIT_ROOT) }));
992
+ if (enforcementOffer(root, deps)) add('enforcement', ENFORCEMENT_WHAT, formsApply('enforcement', root));
968
993
  } catch (err) {
969
994
  skip('enforcement', err);
970
995
  }
@@ -992,20 +1017,20 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
992
1017
  if (readReadLaneToggle(root, deps)) {
993
1018
  // The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
994
1019
  // 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.
1020
+ // reseed route pins the ABSOLUTE root (council R2-M3), so running it from any cwd can only
1021
+ // replace this repo's hook.
997
1022
  const currency = readPlacedHookCurrency(root, deps);
998
1023
  if (currency === HOOK_CURRENCY.CURRENT) return; // converged
999
1024
  if (currency === HOOK_CURRENCY.ABSENT) {
1000
1025
  // 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.
1026
+ // honest report of that; a reseed recovery, which refuses an absent hook, is not.
1002
1027
  placeRecovery();
1003
1028
  return;
1004
1029
  }
1005
1030
  // ONE render per condition — and the item that renders is the one whose CAUSE is true. A hook
1006
1031
  // that postdates the read-lane but predates the marker key reads lanes.json perfectly well, so
1007
1032
  // 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
1033
+ // marker arm's is exact. Both carry the same reseed recovery, so deferring costs
1009
1034
  // the reader nothing and buys a true sentence. The condition is that the marker arm REALLY
1010
1035
  // rendered — never the conditions it would have used, re-derived here: each probe reads the
1011
1036
  // placed hook itself, so a hook changing between the two reads could otherwise have both defer
@@ -1014,7 +1039,7 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
1014
1039
  add(
1015
1040
  'read-lane',
1016
1041
  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)}`,
1042
+ `node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
1018
1043
  'read-lane.stale',
1019
1044
  );
1020
1045
  return;
@@ -1027,18 +1052,17 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
1027
1052
 
1028
1053
  // The state-block-guard offer (AD-075 · OPT-IN-SHIPS-INVISIBLE). This item exists because its absence
1029
1054
  // 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.
1055
+ // entry — so `upgrade` reported «nothing is broken» to a user who did not have it. Its writer is
1056
+ // state-block-hook.mjs; convergence is that leaf's own isStopHookWired over both settings files.
1033
1057
  const probeStateBlockHook = ({ root, deps, add, skip }) => {
1034
1058
  try {
1035
1059
  const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
1036
1060
  const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
1037
- if (isStateBlockGuardWired(project.data) || isStateBlockGuardWired(local.data)) return; // converged
1061
+ if (isStopHookWired(project.data) || isStopHookWired(local.data)) return; // converged
1038
1062
  add(
1039
1063
  'state-block',
1040
1064
  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`,
1065
+ `node ${q(toolPath('state-block-hook.mjs'))} --apply --cwd ${q(root)}`,
1042
1066
  );
1043
1067
  } catch (err) {
1044
1068
  skip('state-block', err);
@@ -1215,28 +1239,9 @@ export const READ_LANE_KEY = 'readLane';
1215
1239
  const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
1216
1240
  const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
1217
1241
 
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
- };
1242
+ // The state-block-guard wiring the advisor looks for is the writer leaf's own rule (any Stop entry of
1243
+ // either settings file running the runtime); the two constants are re-exported for their readers.
1244
+ export { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME };
1240
1245
 
1241
1246
  // The placed gate hook's currency, as the states a caller must tell APART (D8). Read through the
1242
1247
  // writer-class fail-closed no-follow primitive, never a bare readFile: a byte-compare that FOLLOWS a