@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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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;
|
package/tools/commands.mjs
CHANGED
|
@@ -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:
|
|
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;
|
|
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',
|
package/tools/gate-hook.mjs
CHANGED
|
@@ -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
|
|
28
|
-
// is exactly what consent must not slide past); the recovery is named:
|
|
29
|
-
// reseed
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
|
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
|
-
|
|
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, `${
|
|
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[
|
|
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.${
|
|
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.${
|
|
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.${
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
|
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}:
|
|
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
|
-
?
|
|
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
|
-
|
|
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) {
|