@sabaiway/agent-workflow-kit 14.29.1 → 14.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +2 -2
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- 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 +6 -6
- package/references/scripts/check-docs-size-cli.test.mjs +45 -0
- package/references/scripts/check-docs-size-ensure.test.mjs +28 -0
- package/references/scripts/check-docs-size.mjs +6 -4
- package/tools/apply-danger-check.mjs +30 -13
- package/tools/commands.mjs +2 -2
- package/tools/ensure-configs.mjs +55 -22
- package/tools/ensure-ops.mjs +99 -8
- package/tools/ensure-specs.mjs +55 -23
- package/tools/ensure-vocabulary.mjs +2 -1
- package/tools/gate-hook.mjs +81 -40
- package/tools/recommendations.mjs +16 -36
- package/tools/script-priors.mjs +3 -2
- package/tools/state-block-hook.mjs +284 -0
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/write-lanes.mjs +12 -7
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) {
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
|
|
48
48
|
import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
|
|
49
49
|
import { laneOf, applySlot } from './write-lanes.mjs';
|
|
50
|
+
import { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME, isStopHookWired } from './state-block-hook.mjs';
|
|
50
51
|
import { detectBackends } from './detect-backends.mjs';
|
|
51
52
|
import { isDirectRun } from './direct-run.mjs';
|
|
52
53
|
import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
|
|
@@ -599,16 +600,15 @@ const probeGates = ({ root, deps, add, skip, shared }) => {
|
|
|
599
600
|
// ABSENT is not this arm either: a hook that is not placed belongs to the place offers above and
|
|
600
601
|
// in the read-lane item, whose recovery actually places one.
|
|
601
602
|
if (readPlacedHookCurrency(root, deps) !== HOOK_CURRENCY.STALE) return;
|
|
602
|
-
// The writer's OWN stale recovery
|
|
603
|
-
//
|
|
604
|
-
// that converges — and it is maintainer territory, hence HAND-APPLY.
|
|
603
|
+
// The writer's OWN stale recovery: `gate-hook --apply --reseed` replaces the placed hook with
|
|
604
|
+
// the bundle in place — the one consented replacement; the slot renders it behind the check.
|
|
605
605
|
// The RESULT is what the read-lane item defers to — not these conditions re-derived there. Both
|
|
606
606
|
// probes read the placed hook independently, so a hook that changes between the two reads would
|
|
607
607
|
// otherwise let each conclude the other owns it and leave a dark lane reported by nobody.
|
|
608
608
|
shared.markerStaleRendered = add(
|
|
609
609
|
'gate-hook',
|
|
610
610
|
fillTemplate(WHATS['gate-hook.marker-stale'], {}),
|
|
611
|
-
`
|
|
611
|
+
`node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
|
|
612
612
|
'gate-hook.marker-stale',
|
|
613
613
|
);
|
|
614
614
|
} catch (err) {
|
|
@@ -992,20 +992,20 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
|
|
|
992
992
|
if (readReadLaneToggle(root, deps)) {
|
|
993
993
|
// The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
|
|
994
994
|
// reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
|
|
995
|
-
//
|
|
996
|
-
// repo's hook.
|
|
995
|
+
// reseed route pins the ABSOLUTE root (council R2-M3), so running it from any cwd can only
|
|
996
|
+
// replace this repo's hook.
|
|
997
997
|
const currency = readPlacedHookCurrency(root, deps);
|
|
998
998
|
if (currency === HOOK_CURRENCY.CURRENT) return; // converged
|
|
999
999
|
if (currency === HOOK_CURRENCY.ABSENT) {
|
|
1000
1000
|
// The survey saw it placed and it is gone by the time we read it. The missing arm is the
|
|
1001
|
-
// honest report of that; a reseed recovery
|
|
1001
|
+
// honest report of that; a reseed recovery, which refuses an absent hook, is not.
|
|
1002
1002
|
placeRecovery();
|
|
1003
1003
|
return;
|
|
1004
1004
|
}
|
|
1005
1005
|
// ONE render per condition — and the item that renders is the one whose CAUSE is true. A hook
|
|
1006
1006
|
// that postdates the read-lane but predates the marker key reads lanes.json perfectly well, so
|
|
1007
1007
|
// this arm's "an old hook never reads lanes.json" would be a false diagnosis over it, while the
|
|
1008
|
-
// marker arm's is exact. Both carry the same
|
|
1008
|
+
// marker arm's is exact. Both carry the same reseed recovery, so deferring costs
|
|
1009
1009
|
// the reader nothing and buys a true sentence. The condition is that the marker arm REALLY
|
|
1010
1010
|
// rendered — never the conditions it would have used, re-derived here: each probe reads the
|
|
1011
1011
|
// placed hook itself, so a hook changing between the two reads could otherwise have both defer
|
|
@@ -1014,7 +1014,7 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
|
|
|
1014
1014
|
add(
|
|
1015
1015
|
'read-lane',
|
|
1016
1016
|
fillTemplate(WHATS['read-lane.stale'], {}),
|
|
1017
|
-
`
|
|
1017
|
+
`node ${q(toolPath('gate-hook.mjs'))} --apply --reseed --cwd ${q(root)}`,
|
|
1018
1018
|
'read-lane.stale',
|
|
1019
1019
|
);
|
|
1020
1020
|
return;
|
|
@@ -1027,18 +1027,17 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
|
|
|
1027
1027
|
|
|
1028
1028
|
// The state-block-guard offer (AD-075 · OPT-IN-SHIPS-INVISIBLE). This item exists because its absence
|
|
1029
1029
|
// fired: 3.14.0 shipped the detector with a mode doc, a catalog row and a README row — and no advisor
|
|
1030
|
-
// entry — so `upgrade` reported «nothing is broken» to a user who did not have it.
|
|
1031
|
-
//
|
|
1032
|
-
// and the three merge cases.
|
|
1030
|
+
// entry — so `upgrade` reported «nothing is broken» to a user who did not have it. Its writer is
|
|
1031
|
+
// state-block-hook.mjs; convergence is that leaf's own isStopHookWired over both settings files.
|
|
1033
1032
|
const probeStateBlockHook = ({ root, deps, add, skip }) => {
|
|
1034
1033
|
try {
|
|
1035
1034
|
const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
|
|
1036
1035
|
const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
|
|
1037
|
-
if (
|
|
1036
|
+
if (isStopHookWired(project.data) || isStopHookWired(local.data)) return; // converged
|
|
1038
1037
|
add(
|
|
1039
1038
|
'state-block',
|
|
1040
1039
|
fillTemplate(WHATS['state-block'], {}),
|
|
1041
|
-
`
|
|
1040
|
+
`node ${q(toolPath('state-block-hook.mjs'))} --apply --cwd ${q(root)}`,
|
|
1042
1041
|
);
|
|
1043
1042
|
} catch (err) {
|
|
1044
1043
|
skip('state-block', err);
|
|
@@ -1215,28 +1214,9 @@ export const READ_LANE_KEY = 'readLane';
|
|
|
1215
1214
|
const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
|
|
1216
1215
|
const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
|
|
1217
1216
|
|
|
1218
|
-
// The state-block-guard wiring the advisor looks for
|
|
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
|
-
};
|
|
1217
|
+
// The state-block-guard wiring the advisor looks for is the writer leaf's own rule (any Stop entry of
|
|
1218
|
+
// either settings file running the runtime); the two constants are re-exported for their readers.
|
|
1219
|
+
export { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME };
|
|
1240
1220
|
|
|
1241
1221
|
// The placed gate hook's currency, as the states a caller must tell APART (D8). Read through the
|
|
1242
1222
|
// writer-class fail-closed no-follow primitive, never a bare readFile: a byte-compare that FOLLOWS a
|
package/tools/script-priors.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
|
|
2
2
|
// into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
|
|
3
3
|
// before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
|
|
4
|
-
// a release shipped, never edited since) · `custom` (anything else — preserved verbatim,
|
|
5
|
-
//
|
|
4
|
+
// a release shipped, never edited since) · `custom` (anything else — preserved verbatim, replaced only by
|
|
5
|
+
// the replace run the user's yes starts). The lens-region idiom (AD-041) over whole script bodies.
|
|
6
6
|
//
|
|
7
7
|
// Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
|
|
8
8
|
// in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
|
|
@@ -36,6 +36,7 @@ export const SCRIPT_PRIORS = Object.freeze([
|
|
|
36
36
|
prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
|
|
37
37
|
prior('spec-schema.mjs', '5.0.0', '7.2.1', '6a7d1e836c158baab5a412a1ad76d34891934dc28c88486c264a4b5d46f5892a'),
|
|
38
38
|
prior('spec-schema.test.mjs', '5.0.0', '7.2.1', '698564d7283281be1826019d214df475bb876687915de086818d8f5ece0e0336'),
|
|
39
|
+
prior('check-docs-size.mjs', '7.0.0', '8.1.5', '0cf90ae2cbfc809847574a8c71a6b1322c783359740b08e0ff179f0d9d04dffc'),
|
|
39
40
|
]);
|
|
40
41
|
|
|
41
42
|
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|