@sabaiway/agent-workflow-kit 1.28.0 → 1.29.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 +36 -0
- package/README.md +2 -1
- package/SKILL.md +31 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -0
- package/references/hooks/gate-approve.mjs +300 -0
- package/tools/commands.mjs +9 -2
- package/tools/family-registry.mjs +25 -0
- package/tools/gate-hook.mjs +387 -0
- package/tools/known-footprint.mjs +1 -0
- package/tools/presentation.mjs +1 -0
- package/tools/renderers.mjs +6 -0
- package/tools/uninstall.mjs +175 -26
- package/tools/velocity-profile.mjs +21 -3
- package/tools/view-model.mjs +18 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gate-hook.mjs — the onboarding writer behind `/agent-workflow-kit hook`: places the bundled
|
|
3
|
+
// PreToolUse gate-approval hook runtime (references/hooks/gate-approve.mjs) to the project's
|
|
4
|
+
// .claude/hooks/agent-workflow-gates.mjs and wires ONE `PreToolUse` "Bash" entry into
|
|
5
|
+
// .claude/settings.json. The hook then auto-approves byte-exact invocations of the gates
|
|
6
|
+
// declared in docs/ai/gates.json (run from the project root) and asks on seeded-read-only
|
|
7
|
+
// commands carrying the documented runtime residual — see the runtime's own header.
|
|
8
|
+
//
|
|
9
|
+
// A separate SIBLING of velocity, deliberately NOT a velocity flag: velocity's test-pinned
|
|
10
|
+
// invariant is "writes ONLY .claude/settings.json", while this writer also places a file. It
|
|
11
|
+
// REUSES the exported velocity machinery (readSettingsFile / resolveEffectiveMode) rather than
|
|
12
|
+
// re-deriving it, and follows the family writer discipline verbatim:
|
|
13
|
+
// • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
|
|
14
|
+
// • deployment-gated — `--apply` STOPs unless docs/ai/.workflow-version equals the lineage
|
|
15
|
+
// head (a dry-run stays usable on any project);
|
|
16
|
+
// • symlink-safe — a symlinked `.claude` / `.claude/hooks` / target file / settings.json is
|
|
17
|
+
// a STOP on BOTH dry-run and apply (a dry-run never promises a write the apply refuses);
|
|
18
|
+
// • refuses `bypassPermissions` / unsafe modes in EITHER settings file;
|
|
19
|
+
// • merge-don't-clobber — foreign hooks/matchers/keys and existing permissions are preserved
|
|
20
|
+
// semantically; our entry is added idempotently (re-apply never duplicates). The reused
|
|
21
|
+
// readSettingsFile validates `permissions.*` ONLY, so this writer adds its OWN strict shape
|
|
22
|
+
// precondition on any existing `hooks` key — a malformed `hooks`/`hooks.PreToolUse[]` shape
|
|
23
|
+
// is a STOP with ZERO writes, never a merge-through-clobber;
|
|
24
|
+
// • place-then-wire ORDER — the hook file is placed BEFORE settings are wired (a
|
|
25
|
+
// wired-but-missing hook would error on every Bash call);
|
|
26
|
+
// • a target hook file with DIFFERENT content while our entry is NOT wired is a precondition
|
|
27
|
+
// STOP (no file write, no settings mutation — wiring an unknown script as a PreToolUse hook
|
|
28
|
+
// is exactly what consent must not slide past); the recovery is named: delete the file to
|
|
29
|
+
// reseed from the bundle. An identical file is `already current`; an already-wired entry
|
|
30
|
+
// pointing at a diverged file is REPORTED, never unwired or clobbered (the cheap-agents
|
|
31
|
+
// preserved-customization discipline — no refresh-in-place, no marker/checksum machinery);
|
|
32
|
+
// • never writes settings.local.json; never commits.
|
|
33
|
+
//
|
|
34
|
+
// Exit codes: 0 done / dry-run (incl. the report-only diverged-but-wired state); 1 precondition
|
|
35
|
+
// STOP; 2 usage. Dependency-free beyond the kit's own velocity exports, Node >= 18. No side
|
|
36
|
+
// effects on import.
|
|
37
|
+
|
|
38
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
39
|
+
import { dirname, join, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
import {
|
|
42
|
+
CLAUDE_DIR,
|
|
43
|
+
EXPECTED_WORKFLOW_VERSION,
|
|
44
|
+
SETTINGS_FILE,
|
|
45
|
+
SETTINGS_LOCAL_FILE,
|
|
46
|
+
UNSAFE_BYPASS_MODE,
|
|
47
|
+
SAFE_DEFAULT_MODES,
|
|
48
|
+
WORKFLOW_STAMP,
|
|
49
|
+
readSettingsFile,
|
|
50
|
+
resolveEffectiveMode,
|
|
51
|
+
} from './velocity-profile.mjs';
|
|
52
|
+
|
|
53
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
|
|
55
|
+
export const BUNDLED_HOOK_PATH = resolve(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
|
|
56
|
+
export const HOOKS_DIR = '.claude/hooks';
|
|
57
|
+
export const HOOK_FILE_REL = `${HOOKS_DIR}/agent-workflow-gates.mjs`;
|
|
58
|
+
export const PRE_TOOL_USE_EVENT = 'PreToolUse';
|
|
59
|
+
export const HOOK_MATCHER = 'Bash';
|
|
60
|
+
// The literal settings fragment (the plan/test fixture): $CLAUDE_PROJECT_DIR is resolved by
|
|
61
|
+
// Claude Code when it runs the hook command, so the entry survives project relocation.
|
|
62
|
+
export const HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/agent-workflow-gates.mjs"';
|
|
63
|
+
export const HOOK_TIMEOUT_SECONDS = 30;
|
|
64
|
+
|
|
65
|
+
export const GATE_HOOK_STAMP = 'GATE_HOOK_STAMP';
|
|
66
|
+
export const GATE_HOOK_SYMLINK = 'GATE_HOOK_SYMLINK';
|
|
67
|
+
export const GATE_HOOK_UNSAFE_MODE = 'GATE_HOOK_UNSAFE_MODE';
|
|
68
|
+
export const GATE_HOOK_MALFORMED = 'GATE_HOOK_MALFORMED';
|
|
69
|
+
export const GATE_HOOK_DIVERGED = 'GATE_HOOK_DIVERGED';
|
|
70
|
+
export const GATE_HOOK_BUNDLE = 'GATE_HOOK_BUNDLE';
|
|
71
|
+
|
|
72
|
+
const EXIT_OK = 0;
|
|
73
|
+
const EXIT_PRECONDITION = 1;
|
|
74
|
+
const EXIT_USAGE = 2;
|
|
75
|
+
const UTF8 = 'utf8';
|
|
76
|
+
const LF = '\n';
|
|
77
|
+
const CRLF = '\r\n';
|
|
78
|
+
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
79
|
+
const SETTINGS_JSON_INDENT = 2;
|
|
80
|
+
const JSON_NEWLINE_PATTERN = /\n/gu;
|
|
81
|
+
|
|
82
|
+
const TARGET_PLACE = 'place';
|
|
83
|
+
const TARGET_CURRENT = 'already-current';
|
|
84
|
+
const TARGET_DIVERGED = 'diverged';
|
|
85
|
+
|
|
86
|
+
const USAGE = `usage: gate-hook [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
87
|
+
|
|
88
|
+
Places the bundled PreToolUse gate-approval hook to ${HOOK_FILE_REL} and wires ONE
|
|
89
|
+
PreToolUse "Bash" entry into ${SETTINGS_FILE}. Default is --dry-run (a preview; writes
|
|
90
|
+
nothing). --apply writes. The hook auto-approves byte-exact declared gate cmds
|
|
91
|
+
(docs/ai/gates.json, project root) and asks on seeded-read-only commands carrying the
|
|
92
|
+
documented runtime residual. Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
|
|
93
|
+
|
|
94
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
95
|
+
|
|
96
|
+
export const makeGateHookError = (code, message) =>
|
|
97
|
+
Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'GateHookError', code, exitCode: EXIT_PRECONDITION });
|
|
98
|
+
|
|
99
|
+
const fsDeps = (deps = {}) => ({
|
|
100
|
+
exists: deps.exists ?? deps.existsSync ?? existsSync,
|
|
101
|
+
lstat: deps.lstat ?? deps.lstatSync ?? lstatSync,
|
|
102
|
+
mkdir: deps.mkdir ?? deps.mkdirSync ?? mkdirSync,
|
|
103
|
+
readFile: deps.readFile ?? deps.readFileSync ?? readFileSync,
|
|
104
|
+
writeFile: deps.writeFile ?? deps.writeFileSync ?? writeFileSync,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const lstatNoFollow = (absPath, fs) => {
|
|
108
|
+
try {
|
|
109
|
+
return fs.lstat(absPath);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err && err.code === 'ENOENT') return null;
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const isJsonObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
117
|
+
|
|
118
|
+
// ── the bundle ────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export const readBundledHook = (deps = {}) => {
|
|
121
|
+
const fs = fsDeps(deps);
|
|
122
|
+
const bundlePath = deps.bundlePath ?? BUNDLED_HOOK_PATH;
|
|
123
|
+
try {
|
|
124
|
+
return fs.readFile(bundlePath, UTF8);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw makeGateHookError(GATE_HOOK_BUNDLE, `bundled hook runtime unreadable (${err.code ?? err.message}): ${bundlePath} — the kit install is incomplete`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ── preflight (read-only; velocity discipline) ────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const readStamp = (absPath, fs) => {
|
|
133
|
+
try {
|
|
134
|
+
if (!fs.exists(absPath)) return null;
|
|
135
|
+
const stamp = String(fs.readFile(absPath, UTF8)).trim();
|
|
136
|
+
return stamp.length ? stamp : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const assertDirSafe = (absPath, relPath, fs) => {
|
|
143
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
144
|
+
if (stat === null) return;
|
|
145
|
+
if (stat.isSymbolicLink()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
|
|
146
|
+
if (!stat.isDirectory()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const assertSettingsWritable = (absPath, fs) => {
|
|
150
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
151
|
+
if (stat !== null && !stat.isFile()) {
|
|
152
|
+
throw makeGateHookError(GATE_HOOK_SYMLINK, `${SETTINGS_FILE} exists but is not a regular file — refusing to clobber it`);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Target plan: place | already-current | diverged. A symlinked / non-regular target is a STOP —
|
|
157
|
+
// never a write-through, never a comparison pretending it is a file.
|
|
158
|
+
const planTarget = (absPath, bundleContent, fs) => {
|
|
159
|
+
const stat = lstatNoFollow(absPath, fs);
|
|
160
|
+
if (stat === null) return { action: TARGET_PLACE };
|
|
161
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
162
|
+
throw makeGateHookError(GATE_HOOK_SYMLINK, `${HOOK_FILE_REL} exists but is not a regular file — refusing to touch it`);
|
|
163
|
+
}
|
|
164
|
+
return fs.readFile(absPath, UTF8) === bundleContent ? { action: TARGET_CURRENT } : { action: TARGET_DIVERGED };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// The reused readSettingsFile validates permissions.* ONLY — this writer scans and appends
|
|
168
|
+
// inside `hooks`, so any existing `hooks` key must be shape-valid BEFORE a write is planned:
|
|
169
|
+
// a malformed shape is a STOP with zero writes, never a merge-through-clobber.
|
|
170
|
+
const assertHooksShape = (data, relPath) => {
|
|
171
|
+
if (data === undefined || data.hooks === undefined) return;
|
|
172
|
+
if (!isJsonObject(data.hooks)) {
|
|
173
|
+
throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks" must be a JSON object`);
|
|
174
|
+
}
|
|
175
|
+
const entries = data.hooks[PRE_TOOL_USE_EVENT];
|
|
176
|
+
if (entries === undefined) return;
|
|
177
|
+
if (!Array.isArray(entries)) {
|
|
178
|
+
throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}" must be an array`);
|
|
179
|
+
}
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (!isJsonObject(entry)) {
|
|
182
|
+
throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: every "hooks.${PRE_TOOL_USE_EVENT}[]" entry must be a JSON object`);
|
|
183
|
+
}
|
|
184
|
+
if (entry.hooks !== undefined && (!Array.isArray(entry.hooks) || entry.hooks.some((hook) => !isJsonObject(hook)))) {
|
|
185
|
+
throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}[].hooks" must be an array of objects`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Defensive scan (never throws): used for wired-detection in BOTH settings files — the local file
|
|
191
|
+
// is read-only for this writer, so its shape is never a STOP, just never a false match. An entry
|
|
192
|
+
// counts as OUR wiring only when it targets Bash (matcher "Bash") AND carries a command-type hook
|
|
193
|
+
// running HOOK_COMMAND — a same-command entry under a different matcher (e.g. "Write") is NOT our
|
|
194
|
+
// Bash approval hook, so it must not suppress the merge (else approvals/guard stay inactive while
|
|
195
|
+
// status reports wired).
|
|
196
|
+
export const isHookWired = (data) => {
|
|
197
|
+
const entries = isJsonObject(data) && isJsonObject(data.hooks) ? data.hooks[PRE_TOOL_USE_EVENT] : undefined;
|
|
198
|
+
if (!Array.isArray(entries)) return false;
|
|
199
|
+
return entries.some(
|
|
200
|
+
(entry) =>
|
|
201
|
+
isJsonObject(entry) &&
|
|
202
|
+
entry.matcher === HOOK_MATCHER &&
|
|
203
|
+
Array.isArray(entry.hooks) &&
|
|
204
|
+
entry.hooks.some((hook) => isJsonObject(hook) && hook.type === 'command' && hook.command === HOOK_COMMAND),
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const buildHookSettingsEntry = () => ({
|
|
209
|
+
matcher: HOOK_MATCHER,
|
|
210
|
+
hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: HOOK_TIMEOUT_SECONDS }],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Append our ONE entry; every foreign hook/matcher/key is carried over untouched. Only called
|
|
214
|
+
// when the entry is absent (idempotence lives in the caller's plan, not in dedup-on-write).
|
|
215
|
+
export const mergeHookEntry = (base) => {
|
|
216
|
+
const root = isJsonObject(base) ? base : {};
|
|
217
|
+
const hooks = isJsonObject(root.hooks) ? root.hooks : {};
|
|
218
|
+
const entries = Array.isArray(hooks[PRE_TOOL_USE_EVENT]) ? hooks[PRE_TOOL_USE_EVENT] : [];
|
|
219
|
+
return { ...root, hooks: { ...hooks, [PRE_TOOL_USE_EVENT]: [...entries, buildHookSettingsEntry()] } };
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const preflightGateHook = ({ cwd }, deps = {}) => {
|
|
223
|
+
const fs = fsDeps(deps);
|
|
224
|
+
const projectDir = cwd ?? deps.cwd ?? process.cwd();
|
|
225
|
+
const bundleContent = readBundledHook(deps);
|
|
226
|
+
const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
|
|
227
|
+
const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
|
|
228
|
+
assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
|
|
229
|
+
assertDirSafe(join(projectDir, HOOKS_DIR), HOOKS_DIR, fs);
|
|
230
|
+
const target = planTarget(join(projectDir, HOOK_FILE_REL), bundleContent, fs);
|
|
231
|
+
assertSettingsWritable(join(projectDir, SETTINGS_FILE), fs);
|
|
232
|
+
const projectSettings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
|
|
233
|
+
const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
|
|
234
|
+
const { bypassPermissionsPresent, unsafeModePresent } = resolveEffectiveMode(projectSettings.data, localSettings.data);
|
|
235
|
+
|
|
236
|
+
if (bypassPermissionsPresent) {
|
|
237
|
+
throw makeGateHookError(GATE_HOOK_UNSAFE_MODE, `${UNSAFE_BYPASS_MODE} appears in Claude settings - refusing because it auto-approves Bash`);
|
|
238
|
+
}
|
|
239
|
+
if (unsafeModePresent) {
|
|
240
|
+
throw makeGateHookError(
|
|
241
|
+
GATE_HOOK_UNSAFE_MODE,
|
|
242
|
+
`an unsafe or unknown permissions.defaultMode is present in Claude settings - accepted modes: ${SAFE_DEFAULT_MODES.join(', ')}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
assertHooksShape(projectSettings.data, SETTINGS_FILE);
|
|
246
|
+
|
|
247
|
+
const wiredInProject = isHookWired(projectSettings.data);
|
|
248
|
+
const wiredInLocal = isHookWired(localSettings.data);
|
|
249
|
+
// Diverged target + our entry NOT wired anywhere = STOP on BOTH dry-run and apply: applying
|
|
250
|
+
// would wire an unknown script as a PreToolUse hook — exactly what consent must not slide
|
|
251
|
+
// past. (Diverged + already wired is the user's own standing state: report-only, below.)
|
|
252
|
+
if (target.action === TARGET_DIVERGED && !wiredInProject && !wiredInLocal) {
|
|
253
|
+
throw makeGateHookError(
|
|
254
|
+
GATE_HOOK_DIVERGED,
|
|
255
|
+
`${HOOK_FILE_REL} exists with DIFFERENT content and is not wired — refusing to wire an unknown script as a PreToolUse hook; delete the file to reseed from the bundle, then re-run`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { projectDir, bundleContent, stamp, stampOk, target, projectSettings, localSettings, wiredInProject, wiredInLocal };
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ── the writer ────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
const formatJson = (data, eol) =>
|
|
265
|
+
`${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
|
|
266
|
+
|
|
267
|
+
export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
268
|
+
const fs = fsDeps(deps);
|
|
269
|
+
const preflight = preflightGateHook({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
|
|
270
|
+
const placePlanned = preflight.target.action === TARGET_PLACE;
|
|
271
|
+
const wirePlanned = !preflight.wiredInProject && !preflight.wiredInLocal;
|
|
272
|
+
const base = { placePlanned, wirePlanned, ...preflight };
|
|
273
|
+
if (dryRun) return { wrote: false, dryRun: true, ...base };
|
|
274
|
+
|
|
275
|
+
if (!preflight.stampOk) {
|
|
276
|
+
throw makeGateHookError(
|
|
277
|
+
GATE_HOOK_STAMP,
|
|
278
|
+
`not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) — run init/upgrade first`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Place FIRST, then wire — a wired-but-missing hook would error on every Bash call.
|
|
283
|
+
const hookAbs = join(preflight.projectDir, HOOK_FILE_REL);
|
|
284
|
+
if (placePlanned) {
|
|
285
|
+
fs.mkdir(join(preflight.projectDir, HOOKS_DIR), { recursive: true });
|
|
286
|
+
fs.writeFile(hookAbs, preflight.bundleContent, UTF8);
|
|
287
|
+
}
|
|
288
|
+
if (wirePlanned) {
|
|
289
|
+
// Re-verify the target no-follow immediately BEFORE wiring: preflight read it earlier, and an
|
|
290
|
+
// external process could have swapped it (or slipped a symlink in) between preflight and now.
|
|
291
|
+
// Wiring settings to point at an unknown script is exactly what consent must not slide past, so
|
|
292
|
+
// confirm the on-disk bytes are still the bundle — abort with zero settings mutation otherwise.
|
|
293
|
+
const onDisk = lstatNoFollow(hookAbs, fs);
|
|
294
|
+
if (onDisk === null || onDisk.isSymbolicLink() || !onDisk.isFile() || fs.readFile(hookAbs, UTF8) !== preflight.bundleContent) {
|
|
295
|
+
throw makeGateHookError(
|
|
296
|
+
GATE_HOOK_DIVERGED,
|
|
297
|
+
`${HOOK_FILE_REL} changed after preflight — refusing to wire an unknown script as a PreToolUse hook (no settings change made); re-run`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const merged = mergeHookEntry(preflight.projectSettings.data ?? {});
|
|
301
|
+
fs.writeFile(
|
|
302
|
+
join(preflight.projectDir, SETTINGS_FILE),
|
|
303
|
+
formatJson(merged, preflight.projectSettings.eol ?? LF),
|
|
304
|
+
UTF8,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return { wrote: placePlanned || wirePlanned, dryRun: false, ...base };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// ── report ────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
const TRUST_POSTURE_LINE =
|
|
313
|
+
'trust posture: the hook auto-approves ONLY byte-exact matches of the gate cmds declared in docs/ai/gates.json, invoked from the project root — gates.json is thereby a privileged file (whoever can edit it can get its commands auto-approved); an invalid declaration approves nothing.';
|
|
314
|
+
|
|
315
|
+
const formatTargetLine = (result) => {
|
|
316
|
+
if (result.target.action === TARGET_PLACE) return ` - ${HOOK_FILE_REL}: ${result.dryRun ? 'would place' : 'placed'}`;
|
|
317
|
+
if (result.target.action === TARGET_CURRENT) return ` - ${HOOK_FILE_REL}: already current`;
|
|
318
|
+
return ` - ${HOOK_FILE_REL}: diverged from the bundle — preserved, never clobbered (already wired; delete the file to reseed from the bundle)`;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const formatSettingsLine = (result) => {
|
|
322
|
+
if (result.wiredInProject) return ` - ${SETTINGS_FILE}: already wired`;
|
|
323
|
+
if (result.wiredInLocal) return ` - ${SETTINGS_FILE}: already wired via ${SETTINGS_LOCAL_FILE} — not duplicated`;
|
|
324
|
+
return ` - ${SETTINGS_FILE}: ${result.dryRun ? 'would wire' : 'wired'} the ${PRE_TOOL_USE_EVENT} "${HOOK_MATCHER}" entry`;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const formatResult = (result) => {
|
|
328
|
+
const lines = [
|
|
329
|
+
result.dryRun
|
|
330
|
+
? 'agent-workflow gate hook — DRY RUN (no changes; re-run with --apply)'
|
|
331
|
+
: 'agent-workflow gate hook — APPLY',
|
|
332
|
+
formatTargetLine(result),
|
|
333
|
+
formatSettingsLine(result),
|
|
334
|
+
TRUST_POSTURE_LINE,
|
|
335
|
+
];
|
|
336
|
+
if (!result.stampOk) {
|
|
337
|
+
lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
|
|
338
|
+
}
|
|
339
|
+
if (!result.dryRun && result.wrote) {
|
|
340
|
+
lines.push('settings hot-reload: the hook is active for new Bash calls — no session restart needed.');
|
|
341
|
+
lines.push('hidden-mode note: if this deployment is hidden, run the hide-footprint reconcile so the placed hook stays out of `git status` (the registry carries /.claude/hooks/).');
|
|
342
|
+
}
|
|
343
|
+
return lines.join(LF);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
export const parseArgs = (argv) => {
|
|
349
|
+
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
350
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
351
|
+
const arg = argv[i];
|
|
352
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
353
|
+
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
354
|
+
else if (arg === '--apply') opts.apply = true;
|
|
355
|
+
else if (arg === '--cwd') {
|
|
356
|
+
i += 1;
|
|
357
|
+
if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
|
|
358
|
+
opts.cwd = argv[i];
|
|
359
|
+
} else {
|
|
360
|
+
throw fail(EXIT_USAGE, `unknown argument: ${arg}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
364
|
+
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
368
|
+
const log = deps.log ?? console.log;
|
|
369
|
+
const errlog = deps.errlog ?? console.error;
|
|
370
|
+
try {
|
|
371
|
+
const args = parseArgs(argv);
|
|
372
|
+
if (args.help) {
|
|
373
|
+
log(USAGE);
|
|
374
|
+
return EXIT_OK;
|
|
375
|
+
}
|
|
376
|
+
const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
|
|
377
|
+
log(formatResult(result));
|
|
378
|
+
return EXIT_OK;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
errlog(err?.message ?? String(err));
|
|
381
|
+
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
382
|
+
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
387
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|
|
@@ -62,6 +62,7 @@ export const KIT_OWN_PATHS = [
|
|
|
62
62
|
export const KNOWN_FOOTPRINT = [
|
|
63
63
|
{ pattern: '/.claude/skills/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'local-dev skills; absorbs the AD-013 one-off' },
|
|
64
64
|
{ pattern: '/.claude/agents/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project subagent definitions (incl. the kit-placed cheap-lane vehicles)' },
|
|
65
|
+
{ pattern: '/.claude/hooks/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project hooks (incl. the kit-placed gate-approval hook)' },
|
|
65
66
|
{ pattern: '/.cursor/rules/', owner: 'Cursor', type: 'dir', falsePositiveRisk: false, note: 'project rule files' },
|
|
66
67
|
{ pattern: '/.cursorrules', owner: 'Cursor (legacy)', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
|
|
67
68
|
{ pattern: '/.codeium/', owner: 'Codeium/Windsurf', type: 'dir', falsePositiveRisk: false, note: 'home-scoped launchers live under ~/, out of scope' },
|
package/tools/presentation.mjs
CHANGED
package/tools/renderers.mjs
CHANGED
|
@@ -101,6 +101,12 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
101
101
|
else if (s.velocity) {
|
|
102
102
|
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
103
103
|
}
|
|
104
|
+
// gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present.
|
|
105
|
+
if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
|
|
106
|
+
else if (s.hook) {
|
|
107
|
+
const yn = (b) => (b ? 'yes' : 'no');
|
|
108
|
+
lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)}`);
|
|
109
|
+
}
|
|
104
110
|
return lines;
|
|
105
111
|
};
|
|
106
112
|
|