@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.
@@ -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' },
@@ -41,6 +41,7 @@ export const SETTINGS_LABELS = Object.freeze({
41
41
  recipes: 'recipes',
42
42
  attribution: 'attribution',
43
43
  velocity: 'velocity',
44
+ hook: 'gate hook',
44
45
  });
45
46
 
46
47
  // Glyph sets — Unicode for a capable terminal, an ASCII fallback for a narrow / Windows-legacy one.
@@ -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