@sabaiway/agent-workflow-kit 1.28.0 → 1.30.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.
@@ -25,7 +25,7 @@ import { readFileSync, lstatSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { pathToFileURL } from 'node:url';
27
27
  import { detectBackends } from './detect-backends.mjs';
28
- import { resolveActivityRecipe } from './recipes.mjs';
28
+ import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
29
29
  import {
30
30
  CONFIG_REL,
31
31
  fail,
@@ -91,7 +91,7 @@ const effectiveLine = (e) =>
91
91
  ? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
92
92
  : `effective here: ${e.effective}`;
93
93
 
94
- const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody }) => {
94
+ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
95
95
  const lines = [];
96
96
  if (wrote) lines.push(`wrote ${CONFIG_REL}`);
97
97
  else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
@@ -102,6 +102,12 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
102
102
  for (const e of unchanged) lines.push(` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
103
103
  for (const w of warnings) lines.push(` ⚠ ${w}`);
104
104
  if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
105
+ // The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
106
+ // active-recipe line verbatim + the one-line handover reminder — the writer is the ONE surface that
107
+ // changes the config, so the change is never announced anywhere else.
108
+ if (wrote && activeLine) {
109
+ lines.push('', activeLine, `refresh the "Active recipes:" slot line in docs/ai/handover.md with the line above.`);
110
+ }
105
111
  if (!wrote) {
106
112
  if (!changed.length) lines.push(' no changes — nothing to write.');
107
113
  else if (willWrite) lines.push('', `would write ${CONFIG_REL} — re-run with --write to apply.`);
@@ -109,12 +115,15 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
109
115
  return lines.join('\n');
110
116
  };
111
117
 
112
- const buildJson = ({ changed, unchanged, warnings, writtenPath, noop }) => ({
118
+ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
113
119
  changed: changed.map((e) => ({ activity: e.activity, slot: e.slot, from: e.from, to: e.to, effective: e.effective, degradedFrom: e.degradedFrom ?? null, reason: e.reason ?? null })),
114
120
  unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
115
121
  writtenPath: writtenPath ?? null,
116
122
  noop,
117
123
  warnings,
124
+ // ADDITIVE: the machine-composed active-recipe line, present only after a successful write (the
125
+ // human render pastes the same line) — the output stays one parseable JSON object either way.
126
+ activeLine: activeLine ?? null,
118
127
  });
119
128
 
120
129
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
@@ -199,9 +208,10 @@ export const main = (argv, ctx = {}) => {
199
208
  validateConfig(after); // defensive re-validate immediately before the write
200
209
  const { writtenPath } = writeConfig(cwd, after, ctx);
201
210
  const fileBody = serializeConfig(after);
211
+ const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection);
202
212
  const stdout = json
203
- ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false }), null, 2)
204
- : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody });
213
+ ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
214
+ : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
205
215
  return { code: 0, stdout, stderr: '' };
206
216
  } catch (err) {
207
217
  return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };
@@ -22,7 +22,7 @@
22
22
  // module is unit-testable without the real filesystem. Dependency-free, Node >= 18. No side effects on
23
23
  // import (the isDirectRun idiom).
24
24
 
25
- import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, realpathSync } from 'node:fs';
25
+ import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
26
26
  import { join, resolve, dirname, basename, isAbsolute } from 'node:path';
27
27
  import { pathToFileURL } from 'node:url';
28
28
  import os from 'node:os';
@@ -30,6 +30,7 @@ import { surveyFamily, surveyProject, FAMILY_MEMBERS, classifyMember, OK } from
30
30
  import { removeTreeManaged, unlinkManaged, MANAGED_LINK_CONFLICT } from './fs-safe.mjs';
31
31
  import { deriveLinks } from './setup-backends.mjs';
32
32
  import { hideFootprint, excludePath } from './hide-footprint.mjs';
33
+ import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, readBundledHook } from './gate-hook.mjs';
33
34
 
34
35
  // ── surface classes ────────────────────────────────────────────────────────────
35
36
  export const SAFE_REMOVE = 'safe-remove';
@@ -57,6 +58,7 @@ const fsOf = (deps = {}) => ({
57
58
  lstat: deps.lstat ?? lstatSync,
58
59
  readlink: deps.readlink ?? readlinkSync,
59
60
  readFile: deps.readFile ?? readFileSync,
61
+ readdir: deps.readdir ?? readdirSync,
60
62
  realpath: deps.realpath ?? realpathSync,
61
63
  readManifest: deps.readManifest ?? ((skillDir) => JSON.parse(readFileSync(join(skillDir, 'capability.json'), 'utf8'))),
62
64
  });
@@ -186,16 +188,17 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
186
188
  }
187
189
  }
188
190
 
189
- const settingsPath = join(dir, '.claude/settings.json');
190
- const settings = (() => {
191
+ const readRaw = (p) => {
191
192
  try {
192
- return fs.exists(settingsPath) ? String(fs.readFile(settingsPath, 'utf8')) : null;
193
+ return fs.exists(p) ? String(fs.readFile(p, 'utf8')) : null;
193
194
  } catch {
194
195
  return null;
195
196
  }
196
- })();
197
+ };
198
+ const settingsPath = join(dir, '.claude/settings.json');
199
+ const settings = readRaw(settingsPath);
197
200
  const settingsSeams = detectSettingsSeams(settings);
198
- if (settingsSeams.attribution || settingsSeams.permissions) {
201
+ if (settingsSeams.attribution || settingsSeams.permissions || settingsSeams.gateHook) {
199
202
  items.push({
200
203
  surface: 'settings',
201
204
  path: settingsPath,
@@ -205,6 +208,66 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
205
208
  });
206
209
  }
207
210
 
211
+ // The gate-approval hook (Mode: hook). Hooks merge from project AND local settings (the Claude
212
+ // Code contract), so the wired probe checks BOTH files; a local entry is user-authored → its own
213
+ // REPORT_ONLY edit item (uninstall never writes settings — either file). The placed FILE is
214
+ // removable ONLY when the entry is absent from BOTH (hand-removed) AND the content is
215
+ // byte-identical to the current bundle — never create the wired-but-missing state this tool
216
+ // itself would warn about; while any entry is present, all surfaces are reported as one bundle
217
+ // (edit settings first, re-run to remove the file).
218
+ const localSettingsPath = join(dir, '.claude/settings.local.json');
219
+ const localSeams = detectSettingsSeams(readRaw(localSettingsPath));
220
+ if (localSeams.gateHook) {
221
+ items.push({
222
+ surface: 'settings',
223
+ path: localSettingsPath,
224
+ class: REPORT_ONLY,
225
+ reason: 'a PreToolUse entry wiring the kit-placed gate-approval hook is present in this file — remove it by hand to unwire (the tool never edits settings)',
226
+ hand: `edit ${shq(localSettingsPath)} → remove the PreToolUse entry whose command runs ${GATE_HOOK_FILE_REL} (keep the rest of your settings)`,
227
+ });
228
+ }
229
+ const gateHookPath = join(dir, GATE_HOOK_FILE_REL);
230
+ const gateHookStat = lstatNoFollow(gateHookPath, fs.lstat);
231
+ // lstat no-follow BEFORE reading: a symlink (or any non-regular file) at the placed path is
232
+ // never a kit-placed hook we remove — reading through it would classify a symlink-to-bundle as
233
+ // SAFE_REMOVE and only removeTreeManaged would catch it at mutate time, AFTER earlier removals.
234
+ // Report it, never remove it.
235
+ if (gateHookStat !== null && (gateHookStat.isSymbolicLink() || !gateHookStat.isFile())) {
236
+ items.push({
237
+ surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
238
+ reason: 'a file exists at the gate-approval hook path but is a symlink or not a regular file — left untouched; remove by hand if you want it gone',
239
+ });
240
+ }
241
+ const gateHookContent = gateHookStat !== null && gateHookStat.isFile() && !gateHookStat.isSymbolicLink() ? readRaw(gateHookPath) : null;
242
+ if (gateHookContent != null) {
243
+ const wired = settingsSeams.gateHook || localSeams.gateHook;
244
+ const bundle = (() => {
245
+ try {
246
+ return readBundledHook(deps);
247
+ } catch {
248
+ return null;
249
+ }
250
+ })();
251
+ if (wired) {
252
+ items.push({
253
+ surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
254
+ reason: 'the gate-approval hook file is still WIRED in Claude settings — edit the settings entry first (see the settings item above), then re-run uninstall to remove the file (never leaves a wired-but-missing hook)',
255
+ });
256
+ } else if (bundle != null && gateHookContent === bundle) {
257
+ items.push({
258
+ surface: 'gate-hook', path: gateHookPath, class: SAFE_REMOVE, expectedContent: bundle,
259
+ reason: 'kit-placed gate-approval hook — byte-identical to the current bundle and not wired in either settings file',
260
+ });
261
+ } else {
262
+ items.push({
263
+ surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
264
+ reason: bundle == null
265
+ ? 'could not read the kit bundle to verify this hook file — left untouched; remove by hand if you want it gone'
266
+ : 'not byte-identical to the current kit bundle (customized, or from another kit version) — left untouched; remove by hand if you want it gone',
267
+ });
268
+ }
269
+ }
270
+
208
271
  for (const rel of REPORT_PATHS) {
209
272
  const p = join(dir, rel);
210
273
  if (fs.exists(p)) {
@@ -235,7 +298,7 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
235
298
  // we leave them untouched and proceed with what IS ours (the per-item posture of setup-backends —
236
299
  // a stray foreign wrapper never blocks removing the rest). They are NEVER mutated.
237
300
  const reported = plan.items.filter((i) => i.class === REPORT_ONLY || i.class === STOP);
238
- const result = { removed: [], unlinked: [], unhidden: false, hookRemoved: false, reported, applied: false, dryRun: !!opts.dryRun };
301
+ const result = { removed: [], unlinked: [], unhidden: false, hookRemoved: false, gateHookRemoved: false, reported, applied: false, dryRun: !!opts.dryRun };
239
302
 
240
303
  // Preview / awaiting consent → show the plan (formatPlan), mutate NOTHING, abort NOTHING.
241
304
  if (opts.dryRun || !opts.yes) return result;
@@ -262,6 +325,20 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
262
325
  const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return ''; } })();
263
326
  if (!content.includes(HOOK_MARKER_SUFFIX)) conflicts.push(`${item.path} no longer carries our marker`);
264
327
  }
328
+ } else if (item.surface === 'gate-hook') {
329
+ // Same AD-011 recheck as the marker hook: the file must STILL be a regular (non-symlink)
330
+ // file, byte-identical to the bundle, AND still unwired — a symlink swapped in, a divergence,
331
+ // or a new settings entry since the plan ⇒ zero mutations. lstat no-follow FIRST: a symlink
332
+ // read through by fs.readFile would masquerade as the bundle and slip past this guard.
333
+ const st = lstatNoFollow(item.path, fs.lstat);
334
+ if (st !== null) {
335
+ if (st.isSymbolicLink() || !st.isFile()) conflicts.push(`${item.path} is no longer a regular file (symlink or special file)`);
336
+ else {
337
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
338
+ if (content !== item.expectedContent) conflicts.push(`${item.path} no longer matches the kit bundle`);
339
+ else if (gateHookWiredNow(plan.projectDir, fs)) conflicts.push(`${item.path} became wired in Claude settings since the plan`);
340
+ }
341
+ }
265
342
  } else if (item.surface === 'fence') {
266
343
  // Validate the unhide WITHOUT writing — a malformed managed block throws here, before any mutation,
267
344
  // so the fence can never blow up AFTER wrappers/skills were already removed (codex #2).
@@ -303,10 +380,44 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
303
380
  result.hookRemoved = true;
304
381
  }
305
382
  }
383
+ for (const item of mutable.filter((i) => i.surface === 'gate-hook')) {
384
+ // Bundle-identity + unwired re-verified at mutate time too (the TOCTOU posture above), lstat
385
+ // no-follow FIRST so a symlink swapped in cannot be read-through as the bundle and removed: a
386
+ // file that changed, turned into a symlink, or got wired between preflight and now is left
387
+ // untouched, never removed.
388
+ const st = lstatNoFollow(item.path, fs.lstat);
389
+ const content = st !== null && st.isFile() && !st.isSymbolicLink()
390
+ ? (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })()
391
+ : null;
392
+ if (content != null && content === item.expectedContent && !gateHookWiredNow(plan.projectDir, fs)) {
393
+ rmFile(item.path);
394
+ result.gateHookRemoved = true;
395
+ // A `.claude/hooks/` dir left EMPTY by that removal is removed too (clean footprint); a dir
396
+ // with anything else in it is untouched.
397
+ const hooksDir = dirname(item.path);
398
+ const leftover = (() => { try { return fs.readdir(hooksDir); } catch { return null; } })();
399
+ if (leftover != null && leftover.length === 0) removeTree(hooksDir, dirname(hooksDir), deps);
400
+ }
401
+ }
306
402
  result.applied = true;
307
403
  return result;
308
404
  };
309
405
 
406
+ // Is the gate-approval hook wired NOW (either settings file)? Probed by the placed-path substring
407
+ // (the same broad detectSettingsSeams posture). Fail-CLOSED: an unreadable settings file counts as
408
+ // wired — "cannot prove unwired" must preserve the file, never remove it.
409
+ const gateHookWiredNow = (projectDir, fs) => {
410
+ if (!projectDir) return false;
411
+ const probe = (p) => {
412
+ try {
413
+ return fs.exists(p) ? settingsMentionsGateHook(String(fs.readFile(p, 'utf8'))) : false;
414
+ } catch {
415
+ return true;
416
+ }
417
+ };
418
+ return probe(join(projectDir, '.claude/settings.json')) || probe(join(projectDir, '.claude/settings.local.json'));
419
+ };
420
+
310
421
  // ── report ───────────────────────────────────────────────────────────────────
311
422
  const CLASS_LABEL = { [SAFE_REMOVE]: 'remove', [MANAGED_MARKER]: 'reverse', [REPORT_ONLY]: 'KEEP (do by hand)', [STOP]: 'STOP (left untouched)' };
312
423
 
@@ -314,42 +425,80 @@ const CLASS_LABEL = { [SAFE_REMOVE]: 'remove', [MANAGED_MARKER]: 'reverse', [REP
314
425
  // or shell metacharacters can't misbehave when the user pastes it (codex #4).
315
426
  const shq = (p) => `'${String(p).replace(/'/g, "'\\''")}'`;
316
427
 
317
- // Detect the two settings.json seams the family may have written: the attribution edit
318
- // (`includeCoAuthoredBy`) and the velocity profile (`permissions.defaultMode` / `permissions.allow`).
319
- // Parse when possible (accurate); on malformed JSON fall back to a substring probe for the attribution
320
- // key so it is still surfaced (no silent miss). The velocity writer stores NO ownership marker, so the
321
- // permissions seam is reported NON-COMMITTALLY — never a false ownership claim, never auto-removed.
428
+ // Does any STRING VALUE anywhere in a parsed settings tree contain the placed hook path? Recursive
429
+ // so the probe reads the DECODED command (JSON `\/` and `/` escapes are already resolved by
430
+ // JSON.parse) a raw-text substring scan would miss a validly-escaped `.claude\/hooks\/…` entry
431
+ // and wrongly read the hook as unwired.
432
+ const jsonStringContains = (value, needle) => {
433
+ if (typeof value === 'string') return value.includes(needle);
434
+ if (Array.isArray(value)) return value.some((v) => jsonStringContains(v, needle));
435
+ if (value !== null && typeof value === 'object') return Object.values(value).some((v) => jsonStringContains(v, needle));
436
+ return false;
437
+ };
438
+
439
+ // Is the gate-approval hook wired anywhere in a settings blob? Parse when possible and scan the
440
+ // DECODED string values (catches `\/` / `/` escapes); on malformed JSON fall back to a raw
441
+ // substring probe. Deliberately BROADER than the writer's exact-command check (any string mentioning
442
+ // the placed path counts): over-detection preserves the hook file, under-detection could remove a
443
+ // still-wired one — so the fallback errs toward wired.
444
+ const settingsMentionsGateHook = (settings) => {
445
+ const parsed = (() => { try { return JSON.parse(settings); } catch { return null; } })();
446
+ return parsed !== null && typeof parsed === 'object'
447
+ ? jsonStringContains(parsed, GATE_HOOK_FILE_REL)
448
+ : settings.includes(GATE_HOOK_FILE_REL);
449
+ };
450
+
451
+ // Detect the three settings.json seams the family may have written: the attribution edit
452
+ // (`includeCoAuthoredBy`), the velocity profile (`permissions.defaultMode` / `permissions.allow`),
453
+ // and the gate-approval hook wiring (a PreToolUse entry running the placed hook file — probed by
454
+ // the placed path across DECODED string values, deliberately BROADER than the writer's exact-command
455
+ // check: a hand-edited variant still counts as wired, so the file is preserved rather than removed).
456
+ // Parse when possible (accurate); on malformed JSON fall back to a substring probe for the
457
+ // attribution key so it is still surfaced (no silent miss). The velocity writer stores NO ownership
458
+ // marker, so the permissions seam is reported NON-COMMITTALLY — never a false ownership claim,
459
+ // never auto-removed.
322
460
  const detectSettingsSeams = (settings) => {
323
- if (settings == null) return { attribution: false, permissions: false };
461
+ if (settings == null) return { attribution: false, permissions: false, gateHook: false };
462
+ const gateHook = settingsMentionsGateHook(settings);
324
463
  const parsed = (() => { try { return JSON.parse(settings); } catch { return null; } })();
325
464
  if (parsed == null || typeof parsed !== 'object') {
326
- // Malformed / JSONC settings.json (comments, trailing commas) — probe both seams by substring so
327
- // neither is silently missed (over-reporting REPORT_ONLY is safe; it is never auto-removed).
465
+ // Malformed / JSONC settings.json (comments, trailing commas) — probe the seams by substring so
466
+ // none is silently missed (over-reporting REPORT_ONLY is safe; it is never auto-removed).
328
467
  return {
329
468
  attribution: settings.includes('includeCoAuthoredBy'),
330
469
  permissions: settings.includes('"permissions"') && (settings.includes('"defaultMode"') || settings.includes('"allow"')),
470
+ gateHook,
331
471
  };
332
472
  }
333
473
  const perms = parsed.permissions;
334
474
  const permissions = perms != null && typeof perms === 'object'
335
475
  && (Object.prototype.hasOwnProperty.call(perms, 'defaultMode') || Object.prototype.hasOwnProperty.call(perms, 'allow'));
336
- return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions };
476
+ return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions, gateHook };
337
477
  };
338
478
 
339
479
  const ATTRIBUTION_REASON = 'we set "includeCoAuthoredBy": false here — review/remove that key by hand (the file may hold your own settings)';
340
480
  const PERMISSIONS_REASON = 'a "permissions.defaultMode" and/or "permissions.allow" key is present in this file — if the velocity profile seeded them, review/remove by hand (no ownership marker is stored); otherwise leave them';
481
+ const GATE_HOOK_SEAM_REASON = `a PreToolUse entry wiring the kit-placed gate-approval hook (${GATE_HOOK_FILE_REL}) is present — remove that entry by hand to unwire it (the tool never edits settings)`;
341
482
 
342
483
  const settingsSeamReason = (seams) =>
343
- seams.attribution && seams.permissions ? `${ATTRIBUTION_REASON}. Also: ${PERMISSIONS_REASON}`
344
- : seams.permissions ? PERMISSIONS_REASON
345
- : ATTRIBUTION_REASON;
346
-
347
- const settingsSeamHand = (seams, p) =>
348
- seams.attribution && seams.permissions
349
- ? `edit ${shq(p)} → remove the "includeCoAuthoredBy" entry and review "permissions.defaultMode"/"permissions.allow" (if the velocity profile seeded them) keep the rest of your settings`
350
- : seams.permissions
351
- ? `edit ${shq(p)} if the velocity profile seeded "permissions.defaultMode"/"permissions.allow", review/remove them by hand (keep the rest of your settings)`
352
- : `edit ${shq(p)} → remove the "includeCoAuthoredBy" entry (keep the rest of your settings)`;
484
+ [
485
+ ...(seams.attribution ? [ATTRIBUTION_REASON] : []),
486
+ ...(seams.permissions ? [PERMISSIONS_REASON] : []),
487
+ ...(seams.gateHook ? [GATE_HOOK_SEAM_REASON] : []),
488
+ ].join('. Also: ');
489
+
490
+ const settingsSeamHand = (seams, p) => {
491
+ const clauses = [
492
+ ...(seams.attribution ? ['remove the "includeCoAuthoredBy" entry'] : []),
493
+ ...(seams.permissions
494
+ ? [seams.attribution
495
+ ? 'review "permissions.defaultMode"/"permissions.allow" (if the velocity profile seeded them)'
496
+ : 'if the velocity profile seeded "permissions.defaultMode"/"permissions.allow", review/remove them by hand']
497
+ : []),
498
+ ...(seams.gateHook ? [`remove the PreToolUse entry whose command runs ${GATE_HOOK_FILE_REL}`] : []),
499
+ ];
500
+ return `edit ${shq(p)} → ${clauses.join(' and ')} (keep the rest of your settings)`;
501
+ };
353
502
 
354
503
  // The "do this by hand" line for a report-only surface. A settings.json item carries its own `hand`
355
504
  // guidance (an EDIT, never an `rm` — deleting it would lose the user's own settings, codex #4);
@@ -89,13 +89,31 @@ export const SHELL_READONLY = Object.freeze([
89
89
  // commands; but the RUNTIME residual (redirection, command substitution, a kept command's own
90
90
  // `--output=<file>` write flag) means a seeded read-only entry is a TRUST-POSTURE convenience, NOT
91
91
  // a sandbox. velocity never ADDS commit/push/publish as allow rules and keeps acceptEdits opt-in;
92
- // full runtime closure (rejecting substitution + mutating commands at invocation) is the deferred
93
- // PreToolUse hook (queue.md follow-up), not something settings-level allow rules can enforce.
92
+ // runtime closure is not something settings-level allow rules can enforce the residual guard
93
+ // ships as the opt-in PreToolUse hook (Mode: hook, tools/gate-hook.mjs; probe record in AD-037).
94
94
  export const SHELL_METACHARACTERS = Object.freeze([
95
95
  '&', '|', ';', '<', '>', '$', '`', '(', ')',
96
96
  '\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
97
97
  ]);
98
98
 
99
+ // The RUNTIME residual documented above, as data: the exact write-redirection / command-
100
+ // substitution / bounded write-flag forms a settings-level allow rule cannot see. Consumed by
101
+ // the PreToolUse gate hook's residual guard — the placed hook (references/hooks/gate-approve.mjs)
102
+ // bakes a frozen COPY (a placed file cannot import the kit), drift-guarded by
103
+ // test/gate-hook-core-parity.test.mjs alongside UNIVERSAL_READONLY_ALLOWLIST.
104
+ export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
105
+ writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
106
+ // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
107
+ // the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
108
+ // already do that), so it is deliberately NOT here.
109
+ commandSubstitutions: Object.freeze(['$(', '`', '<(']),
110
+ // The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
111
+ // whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
112
+ // `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
113
+ // `--output-indicator` is the safe direction (never under-allow a real write flag through quotes).
114
+ boundedWriteFlags: Object.freeze(['--output']),
115
+ });
116
+
99
117
  export const VELOCITY_OFFCORE = 'VELOCITY_OFFCORE';
100
118
  export const VELOCITY_NON_READONLY = 'VELOCITY_NON_READONLY';
101
119
  export const VELOCITY_INVALID_ARGUMENT = 'VELOCITY_INVALID_ARGUMENT';
@@ -137,7 +155,7 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
137
155
  // refusal is named, tested, and produces a clear message).
138
156
  const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
139
157
  const RESIDUAL_NOTICE =
140
- 'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the substitution/redirection residual is not closed here); full closure is the deferred PreToolUse hook.';
158
+ 'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook).';
141
159
 
142
160
  const USAGE = `usage: velocity-profile [--dry-run | --apply] [--accept-edits] [--cwd <dir>] [--help]
143
161
 
@@ -65,8 +65,25 @@ const velocityVm = (v) => {
65
65
  return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
66
66
  };
67
67
 
68
+ const hookVm = (h) => {
69
+ if (!h) return null;
70
+ if (h.error) return { error: h.error };
71
+ return {
72
+ wired: Boolean(h.wired),
73
+ filePlaced: Boolean(h.filePlaced),
74
+ declarationPresent: Boolean(h.declarationPresent),
75
+ };
76
+ };
77
+
68
78
  const settingsVm = (s) =>
69
- s ? { recipes: recipesVm(s.recipes), attribution: attributionVm(s.attribution), velocity: velocityVm(s.velocity) } : null;
79
+ s
80
+ ? {
81
+ recipes: recipesVm(s.recipes),
82
+ attribution: attributionVm(s.attribution),
83
+ velocity: velocityVm(s.velocity),
84
+ hook: hookVm(s.hook),
85
+ }
86
+ : null;
70
87
 
71
88
  const projectVm = (p) =>
72
89
  p