@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.
- package/CHANGELOG.md +76 -0
- package/README.md +2 -2
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/gates.md +1 -1
- package/references/modes/hook.md +6 -4
- package/references/modes/recommendations.md +6 -6
- package/references/modes/state-block-guard.md +51 -70
- package/references/modes/upgrade.md +1 -1
- package/references/shared/deploy-tail.md +1 -1
- package/tools/apply-danger-check.mjs +51 -15
- package/tools/commands.mjs +2 -2
- package/tools/gate-hook.mjs +81 -40
- package/tools/git-hooks.mjs +199 -0
- package/tools/recommendations.mjs +83 -78
- package/tools/state-block-hook.mjs +284 -0
- package/tools/write-lanes.mjs +23 -11
|
@@ -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
|
|
123
|
-
const
|
|
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
|
|
603
|
-
//
|
|
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
|
-
`
|
|
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
|
|
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(
|
|
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
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
|
|
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'], {}),
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
`
|
|
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.
|
|
1031
|
-
//
|
|
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 (
|
|
1061
|
+
if (isStopHookWired(project.data) || isStopHookWired(local.data)) return; // converged
|
|
1038
1062
|
add(
|
|
1039
1063
|
'state-block',
|
|
1040
1064
|
fillTemplate(WHATS['state-block'], {}),
|
|
1041
|
-
`
|
|
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
|
|
1219
|
-
//
|
|
1220
|
-
|
|
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
|