@sabaiway/agent-workflow-kit 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join, relative } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  // Velocity-profile core + writer: a fixed, audited read-only allowlist that an onboarding step seeds
6
6
  // into `.claude/settings.json` so routine read-only commands stop idling on approval prompts.
@@ -40,6 +40,13 @@ export const UNIVERSAL_READONLY_ALLOWLIST = Object.freeze([
40
40
  'Bash(git ls-files:*)',
41
41
  'Bash(git check-ignore:*)',
42
42
  'Bash(git branch --list:*)',
43
+ 'Bash(git rev-parse:*)',
44
+ 'Bash(git blame:*)',
45
+ 'Bash(git shortlog:*)',
46
+ 'Bash(git describe:*)',
47
+ 'Bash(git tag --list:*)',
48
+ 'Bash(git stash list:*)',
49
+ 'Bash(git worktree list:*)',
43
50
  'Bash(npm view:*)',
44
51
  'Bash(npm ls:*)',
45
52
  'Bash(npm outdated:*)',
@@ -51,6 +58,12 @@ export const UNIVERSAL_READONLY_ALLOWLIST = Object.freeze([
51
58
  'Bash(readlink:*)',
52
59
  'Bash(which:*)',
53
60
  'Bash(grep:*)',
61
+ 'Bash(diff:*)',
62
+ 'Bash(stat:*)',
63
+ 'Bash(du:*)',
64
+ 'Bash(basename:*)',
65
+ 'Bash(dirname:*)',
66
+ 'Bash(realpath:*)',
54
67
  ]);
55
68
 
56
69
  // Per-tool POSITIVE allowlists, as frozen arrays. NOTE: `Object.freeze` on a Set does NOT prevent
@@ -64,6 +77,18 @@ export const GIT_READONLY_SUBCOMMANDS = Object.freeze([
64
77
  'ls-files',
65
78
  'check-ignore',
66
79
  'branch --list',
80
+ 'rev-parse',
81
+ 'blame',
82
+ 'shortlog',
83
+ 'describe',
84
+ // `git cat-file` is deliberately ABSENT (diff-council fold, AD-040): `--textconv`/`--filters`
85
+ // activate CONFIGURED external filters under an auto-approved command, and its read utility is
86
+ // marginal next to the kept `git show`.
87
+ // Fixed read-only forms ONLY — the bare `git tag` / `git stash` / `git worktree` forms mutate
88
+ // (probe-proven, AD-040), the same multi-token precedent as 'branch --list'.
89
+ 'tag --list',
90
+ 'stash list',
91
+ 'worktree list',
67
92
  ]);
68
93
  export const NPM_READONLY_SUBCOMMANDS = Object.freeze(['view', 'ls', 'outdated']);
69
94
  export const SHELL_READONLY = Object.freeze([
@@ -75,7 +100,56 @@ export const SHELL_READONLY = Object.freeze([
75
100
  'readlink',
76
101
  'which',
77
102
  'grep',
103
+ // AD-040 audit survivors. `file` deliberately FAILED the audit (`-C -m <magic>` compiles a magic
104
+ // FILE WRITE — probe-proven) and stays a hand-add candidate only. `diff -l/--paginate` execs `pr`
105
+ // by a build-time FIXED absolute path (probed NOT PATH-resolved) — not arbitrary exec, kept.
106
+ 'diff',
107
+ 'stat',
108
+ 'du',
109
+ 'basename',
110
+ 'dirname',
111
+ 'realpath',
112
+ ]);
113
+
114
+ // ── the opt-in --kit-tools tier (AD-040) ────────────────────────────────────────────────
115
+ // A SEPARATE frozen tier, never an extension of UNIVERSAL_READONLY_ALLOWLIST: the core stays the
116
+ // hook-parity surface (test/gate-hook-core-parity.test.mjs), while tier entries are derived at
117
+ // seed time from the RUNNING tool's own location — resolved-absolute, so a moved or reinstalled
118
+ // skill leaves a stale rule that FAIL-SAFE prompts again (never a silent widening).
119
+ //
120
+ // Membership (9, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
121
+ // project-exec (it runs the project's OWN declared gates.json commands), so it seeds as ONE exact
122
+ // byte-string pinned to this project root (`--cwd <resolved root>`): a wildcard would be BROADER
123
+ // than the AD-037 hook boundary (`--cwd <dir>` executes an arbitrary OTHER project's gates.json)
124
+ // and a cwd-defaulting entry would follow the shell's current directory into subdirs. NEVER the
125
+ // writers. review-state.mjs's read-only git spawns are in-scope read-only.
126
+ export const KIT_RUN_GATES_TOOL = 'tools/run-gates.mjs';
127
+ export const KIT_READONLY_TOOLS = Object.freeze([
128
+ 'tools/recipes.mjs',
129
+ 'tools/procedures.mjs',
130
+ 'tools/family-registry.mjs',
131
+ 'tools/detect-backends.mjs',
132
+ 'tools/commands.mjs',
133
+ 'tools/review-state.mjs',
134
+ KIT_RUN_GATES_TOOL,
135
+ 'tools/manifest/validate.mjs',
136
+ 'tools/release-scan.mjs',
137
+ ]);
138
+ // Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
139
+ // --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
140
+ // its prompt. set-recipe is excluded (its preview takes variable --set ops); setup-backends and
141
+ // hide-footprint are excluded (their arg-free forms APPLY); uninstall is excluded (a guarded
142
+ // teardown documents --dry-run explicitly — its bare form is not its preview contract).
143
+ export const KIT_WRITER_PREVIEW_TOOLS = Object.freeze([
144
+ 'tools/velocity-profile.mjs',
145
+ 'tools/cheap-agents.mjs',
146
+ 'tools/gate-hook.mjs',
78
147
  ]);
148
+ const KIT_WILDCARD_TOOLS = Object.freeze(KIT_READONLY_TOOLS.filter((rel) => rel !== KIT_RUN_GATES_TOOL));
149
+ // The kit root this tool runs from (tools/..) — the tier's seed-time path anchor.
150
+ const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
151
+ const KIT_TOOL_INVOKER = 'node';
152
+ const RUN_GATES_CWD_FLAG = '--cwd';
79
153
 
80
154
  // Characters that make a pattern NOT a single read-only command, so the screen rejects them.
81
155
  // Per current Claude Code permission semantics: the recognized command SEPARATORS are
@@ -125,6 +199,9 @@ export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
125
199
  const VELOCITY_ERROR_NAME = 'VelocityProfileError';
126
200
  const ERROR_PREFIX = '[agent-workflow-kit]';
127
201
  const BASH_ALLOW_PATTERN = /^Bash\((.+):\*\)$/u;
202
+ // The EXACT (non-wildcard) allow form `Bash(cmd)` — tier-only (run-gates + writer previews); the
203
+ // git/npm/shell core classes stay wildcard-only so the pre-existing advisory behavior never shifts.
204
+ const BASH_EXACT_ALLOW_PATTERN = /^Bash\((.+)\)$/u;
128
205
  const BASH_PERMISSION_PATTERN = /^Bash\(.+\)$/u;
129
206
  const WHITESPACE_PATTERN = /\s+/u;
130
207
  const GIT_COMMAND = 'git';
@@ -143,6 +220,7 @@ const JSON_NEWLINE_PATTERN = /\n/gu;
143
220
  const FLAG_DRY_RUN = '--dry-run';
144
221
  const FLAG_APPLY = '--apply';
145
222
  const FLAG_ACCEPT_EDITS = '--accept-edits';
223
+ const FLAG_KIT_TOOLS = '--kit-tools';
146
224
  const FLAG_CWD = '--cwd';
147
225
  const FLAG_HELP = '--help';
148
226
  const SHORT_FLAG_HELP = '-h';
@@ -155,12 +233,16 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
155
233
  // refusal is named, tested, and produces a clear message).
156
234
  const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
157
235
  const RESIDUAL_NOTICE =
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).';
236
+ '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). floor (never auto-approved, with or without the tier): every writer --apply/--write/--yes still prompts; clobber-protection STOPs still stop; the three release asks (commit/push/publish) stay maintainer-owned.';
159
237
 
160
- const USAGE = `usage: velocity-profile [--dry-run | --apply] [--accept-edits] [--cwd <dir>] [--help]
238
+ const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--accept-edits] [--cwd <dir>] [--help]
161
239
 
162
240
  Seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
163
- Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.`;
241
+ Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
242
+ --kit-tools additionally seeds the audited kit-tool tier: 8 read-only kit tools by resolved
243
+ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned byte-string
244
+ (project-exec - it runs YOUR declared gates.json), and the writers' exact arg-free dry-run
245
+ preview byte-strings. Never touches settings.local.json.`;
164
246
 
165
247
  const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
166
248
 
@@ -245,6 +327,36 @@ const getBashAllowCommand = (pattern) => {
245
327
  return match ? match[1] : undefined;
246
328
  };
247
329
 
330
+ // Parse the exact (non-wildcard) form ONLY when the wildcard form does not match — `Bash(cmd:*)`
331
+ // would otherwise parse here as `cmd:*`.
332
+ const getBashExactCommand = (pattern) => {
333
+ if (typeof pattern !== 'string' || BASH_ALLOW_PATTERN.test(pattern)) return undefined;
334
+ const match = pattern.match(BASH_EXACT_ALLOW_PATTERN);
335
+ return match ? match[1] : undefined;
336
+ };
337
+
338
+ // Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
339
+ // shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
340
+ // redirections/expansions — `*`/`?` globs included — but not these four).
341
+ const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
342
+
343
+ // A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
344
+ // whitespace, no shell metacharacter, no quoting/glob syntax.
345
+ const isSeedablePathToken = (token) =>
346
+ typeof token === 'string' &&
347
+ token.startsWith('/') &&
348
+ !/\s/u.test(token) &&
349
+ !hasShellMetacharacter(token) &&
350
+ !PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
351
+
352
+ // A tier path token must be SEEDABLE (a relative or shell-syntax-carrying spelling is a dead rule
353
+ // the screen refuses to bless) and end on a known tier-tool tail. Any seedable absolute prefix is
354
+ // accepted — the user's own kit copy elsewhere is legitimate shape-wise; entries OUTSIDE the
355
+ // derived tier are still flagged by the pre-existing advisory, and membership in the SEEDED set
356
+ // stays enforced separately by validateProfile's audited-set check.
357
+ const isKitToolPathToken = (token, relPaths) =>
358
+ isSeedablePathToken(token) && relPaths.some((rel) => token.endsWith(`/${rel}`));
359
+
248
360
  const isSingleShellToken = (cmd, tokens) => tokens.length === 1 && cmd === tokens[0];
249
361
 
250
362
  const isScriptMap = (scripts) => Boolean(scripts) && typeof scripts === 'object' && !Array.isArray(scripts);
@@ -309,11 +421,23 @@ const assertClaudeDirSafe = (cwd, deps = {}) => {
309
421
  return { claudeDirAbsent: false };
310
422
  };
311
423
 
312
- const collectPreExistingNonReadonly = (sources) =>
424
+ // A kit-tool-SHAPED entry (any `node …` allow entry, wildcard or exact) passes the shape screen on
425
+ // any absolute path — but only the entries the tier itself derives for THIS kit + THIS root are
426
+ // audited. Everything else `node`-shaped is arbitrary local JS / foreign project-exec and must
427
+ // stay flagged for hand review (diff-council fold, AD-040).
428
+ const isKitToolShapedCommand = (entry) => {
429
+ const cmd = getBashAllowCommand(entry) ?? getBashExactCommand(entry);
430
+ return cmd !== undefined && tokenizeCommand(cmd)[0] === KIT_TOOL_INVOKER;
431
+ };
432
+
433
+ const collectPreExistingNonReadonly = (sources, derivedTier = []) =>
313
434
  sources.flatMap(({ source, data }) =>
314
435
  getAllowEntries(data)
315
436
  .filter((entry) => typeof entry === 'string' && BASH_PERMISSION_PATTERN.test(entry))
316
- .filter((entry) => !screenAllowlistEntry(entry))
437
+ .filter(
438
+ (entry) =>
439
+ !screenAllowlistEntry(entry) || (isKitToolShapedCommand(entry) && !derivedTier.includes(entry)),
440
+ )
317
441
  .map((entry) => ({ source, entry })),
318
442
  );
319
443
 
@@ -351,6 +475,21 @@ const formatAllowlist = (result) => [
351
475
  `already present: ${result.alreadyPresent.length}`,
352
476
  ];
353
477
 
478
+ // The tier's honest posture, printed on every --kit-tools run: run-gates is project-exec (never
479
+ // "read-only"), previews stay dry-run-only, and the tier gets none of the hook's residual ask-net.
480
+ const KIT_TIER_NOTICE =
481
+ 'kit-tools tier: paths are resolved absolute at seed time (fail-safe - a moved skill or stale path simply prompts again); run-gates.mjs is seeded as ONE exact byte-string pinned to this project root and is project-exec - it runs YOUR declared gates.json commands, never "read-only"; writer previews are exact dry-run byte-strings - every --apply/--write/--yes still prompts; tier entries get NO PreToolUse-hook residual coverage (settings-level posture only - see the velocity mode notes).';
482
+
483
+ const formatKitTier = (result) =>
484
+ result.kitTools
485
+ ? [
486
+ `${result.wrote ? 'added' : 'would add'} kit-tools tier entries: ${result.tierToAdd.length}`,
487
+ ...formatEntryList(result.tierToAdd),
488
+ `already present (tier): ${result.tierAlreadyPresent.length}`,
489
+ KIT_TIER_NOTICE,
490
+ ]
491
+ : [];
492
+
354
493
  const formatGateAdvisory = (gateCandidates) => [
355
494
  'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
356
495
  ...(gateCandidates.length
@@ -375,6 +514,7 @@ const formatVelocityProfileResult = (result) =>
375
514
  [
376
515
  result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
377
516
  ...formatAllowlist(result),
517
+ ...formatKitTier(result),
378
518
  formatDefaultMode(result),
379
519
  ...formatGateAdvisory(result.gateCandidates),
380
520
  ...formatPreExistingAdvisory(result.preExistingNonReadonly),
@@ -386,16 +526,69 @@ const formatVelocityProfileResult = (result) =>
386
526
  const exitCodeFor = (err) => err?.exitCode ?? EXIT_PRECONDITION;
387
527
 
388
528
  // `:*` is the trailing word-boundary wildcard: equivalent to a trailing ` *`, recognized only at the
389
- // end of a pattern per Claude Code semantics.
529
+ // end of a pattern per Claude Code semantics. Two forms are recognized: the wildcard form (the
530
+ // git/npm/shell core + the kit-tool wildcard class) and the EXACT form (tier-only: the root-pinned
531
+ // run-gates byte-string + the writer-preview dry-run byte-strings).
390
532
  export const screenAllowlistEntry = (pattern) => {
391
- const cmd = getBashAllowCommand(pattern);
392
- if (!cmd) return false;
393
- if (hasShellMetacharacter(cmd)) return false;
394
-
395
- const tokens = tokenizeCommand(cmd);
396
- if (tokens[0] === GIT_COMMAND) return GIT_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
397
- if (tokens[0] === NPM_COMMAND) return NPM_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
398
- return isSingleShellToken(cmd, tokens) && SHELL_READONLY.includes(cmd);
533
+ const wildcardCmd = getBashAllowCommand(pattern);
534
+ if (wildcardCmd !== undefined) {
535
+ if (hasShellMetacharacter(wildcardCmd)) return false;
536
+ const tokens = tokenizeCommand(wildcardCmd);
537
+ if (tokens[0] === GIT_COMMAND) return GIT_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
538
+ if (tokens[0] === NPM_COMMAND) return NPM_READONLY_SUBCOMMANDS.includes(getSubcommand(tokens));
539
+ // Kit-tool wildcard class: `node <abs kit tool>` + args wildcard. run-gates is deliberately NOT
540
+ // here (Decision 3: only its exact root-pinned form below — a wildcard would cover `--cwd <any>`).
541
+ if (tokens[0] === KIT_TOOL_INVOKER) return tokens.length === 2 && isKitToolPathToken(tokens[1], KIT_WILDCARD_TOOLS);
542
+ return isSingleShellToken(wildcardCmd, tokens) && SHELL_READONLY.includes(wildcardCmd);
543
+ }
544
+ const exactCmd = getBashExactCommand(pattern);
545
+ if (exactCmd === undefined || hasShellMetacharacter(exactCmd)) return false;
546
+ const tokens = tokenizeCommand(exactCmd);
547
+ if (tokens[0] !== KIT_TOOL_INVOKER) return false;
548
+ // Writer preview: the arg-free dry-run byte-string of a preview-class writer.
549
+ if (tokens.length === 2) return isKitToolPathToken(tokens[1], KIT_WRITER_PREVIEW_TOOLS);
550
+ // run-gates: EXACTLY `node <abs run-gates> --cwd <abs root>` — any other form keeps prompting.
551
+ return (
552
+ tokens.length === 4 &&
553
+ isKitToolPathToken(tokens[1], [KIT_RUN_GATES_TOOL]) &&
554
+ tokens[2] === RUN_GATES_CWD_FLAG &&
555
+ isSeedablePathToken(tokens[3])
556
+ );
557
+ };
558
+
559
+ /**
560
+ * Derive the opt-in kit-tools tier for a project: 8 wildcard entries (resolved-absolute script
561
+ * path + args wildcard), ONE exact run-gates entry pinned to the resolved project root, and the
562
+ * writer-preview exact dry-run byte-strings. Pure derivation — a stale path simply prompts again.
563
+ */
564
+ // A seedable path must survive UNQUOTED inside a byte-exact allow rule: POSIX-absolute, no
565
+ // whitespace, no shell metacharacter, no quoting/glob syntax (isSeedablePathToken). Anything else
566
+ // is refused UP FRONT with a clear error (a Windows-shaped, space- or quote-carrying path would
567
+ // otherwise die downstream as a confusing VELOCITY_NON_READONLY, or seed dead/shell-reinterpreted
568
+ // byte strings) — the hand-add advisory is the fallback.
569
+ const assertSeedablePath = (label, absPath) => {
570
+ if (isSeedablePathToken(absPath)) return;
571
+ throw makeVelocityProfileError(
572
+ VELOCITY_INVALID_ARGUMENT,
573
+ `kit-tools tier: the ${label} "${absPath}" is not a POSIX absolute path free of spaces/metacharacters/quoting - byte-exact allow rules cannot be seeded for it; add hand-picked entries to your settings instead (hand-add)`,
574
+ { entry: absPath },
575
+ );
576
+ };
577
+
578
+ export const deriveKitToolsAllowlist = ({ projectDir } = {}) => {
579
+ if (typeof projectDir !== 'string' || projectDir === '') {
580
+ throw makeVelocityProfileError(VELOCITY_INVALID_ARGUMENT, 'kit-tools derivation needs a project directory', {
581
+ entry: projectDir,
582
+ });
583
+ }
584
+ const projectRoot = resolve(projectDir);
585
+ assertSeedablePath('resolved project root', projectRoot);
586
+ assertSeedablePath('resolved skill dir', join(KIT_ROOT, '.'));
587
+ return Object.freeze([
588
+ ...KIT_WILDCARD_TOOLS.map((rel) => `Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, rel)}:*)`),
589
+ `Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, KIT_RUN_GATES_TOOL)} ${RUN_GATES_CWD_FLAG} ${projectRoot})`,
590
+ ...KIT_WRITER_PREVIEW_TOOLS.map((rel) => `Bash(${KIT_TOOL_INVOKER} ${join(KIT_ROOT, rel)})`),
591
+ ]);
399
592
  };
400
593
 
401
594
  export const discoverGateCandidates = (packageJson) => {
@@ -405,12 +598,14 @@ export const discoverGateCandidates = (packageJson) => {
405
598
  };
406
599
 
407
600
  /**
408
- * Validate that what we are about to write is a subset of the audited read-only core: every entry
601
+ * Validate that what we are about to write is a subset of the SELECTED audited set: every entry
409
602
  * must (a) NOT be a commit/push/publish allow entry, (b) pass the read-only screen, and (c) be a
410
- * member of UNIVERSAL_READONLY_ALLOWLIST (a guard against the constant drifting). Pure; owns no exit
411
- * codes a CLI maps the typed `.code` to a process exit.
603
+ * member of the audited set (a guard against the constants drifting). The argument-less call keeps
604
+ * its exact historical semantics the full core validated against itself; a --kit-tools run
605
+ * additionally validates the derived tier against core + tier. Pure; owns no exit codes — a CLI
606
+ * maps the typed `.code` to a process exit.
412
607
  */
413
- export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST) => {
608
+ export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST, auditedSet = UNIVERSAL_READONLY_ALLOWLIST) => {
414
609
  if (!Array.isArray(allowEntries)) {
415
610
  throw makeVelocityProfileError(VELOCITY_INVALID_ARGUMENT, 'allow entries must be an array', { entry: allowEntries });
416
611
  }
@@ -422,7 +617,7 @@ export const validateProfile = (allowEntries = UNIVERSAL_READONLY_ALLOWLIST) =>
422
617
  if (!screenAllowlistEntry(entry)) {
423
618
  throw makeVelocityProfileError(VELOCITY_NON_READONLY, `not a read-only allow entry: ${entry}`, { entry });
424
619
  }
425
- if (!UNIVERSAL_READONLY_ALLOWLIST.includes(entry)) {
620
+ if (!auditedSet.includes(entry)) {
426
621
  throw makeVelocityProfileError(VELOCITY_OFFCORE, `allow entry is outside the audited core: ${entry}`, { entry });
427
622
  }
428
623
  }
@@ -480,10 +675,25 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
480
675
  );
481
676
  }
482
677
 
483
- const preExistingNonReadonly = collectPreExistingNonReadonly([
484
- { source: SETTINGS_FILE, data: projectSettings.data },
485
- { source: SETTINGS_LOCAL_FILE, data: localSettings.data },
486
- ]);
678
+ // The advisory compares kit-tool-shaped entries against the tier derived for THIS kit + root.
679
+ // Defensive derive: on a machine whose paths cannot seed (space/metacharacter — the tier itself
680
+ // refuses loudly on --kit-tools), the flagless advisory falls back to flagging EVERY node-shaped
681
+ // entry — over-flagging is the safe direction, and flagless behavior never gains a new failure
682
+ // mode from local skill paths.
683
+ const derivedTier = (() => {
684
+ try {
685
+ return deriveKitToolsAllowlist({ projectDir });
686
+ } catch {
687
+ return [];
688
+ }
689
+ })();
690
+ const preExistingNonReadonly = collectPreExistingNonReadonly(
691
+ [
692
+ { source: SETTINGS_FILE, data: projectSettings.data },
693
+ { source: SETTINGS_LOCAL_FILE, data: localSettings.data },
694
+ ],
695
+ derivedTier,
696
+ );
487
697
  const gateCandidates = discoverGateCandidates(readPackageJson(projectDir, deps));
488
698
 
489
699
  return {
@@ -500,21 +710,30 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
500
710
  };
501
711
  };
502
712
 
503
- export const planVelocityProfile = (preflight, { acceptEdits } = {}) => {
713
+ export const planVelocityProfile = (preflight, { acceptEdits, kitTools } = {}) => {
504
714
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
505
715
  const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
506
716
  const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
507
- return { toAdd, alreadyPresent, setsDefaultMode: acceptEdits === true };
717
+ const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
718
+ const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
719
+ const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
720
+ return { toAdd, alreadyPresent, tierToAdd, tierAlreadyPresent, kitTools: kitTools === true, setsDefaultMode: acceptEdits === true };
508
721
  };
509
722
 
510
- export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true } = {}, deps = {}) => {
723
+ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false } = {}, deps = {}) => {
511
724
  const projectDir = cwd ?? deps.cwd ?? process.cwd();
512
725
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
513
- const plan = planVelocityProfile(preflight, { acceptEdits });
726
+ const plan = planVelocityProfile(preflight, { acceptEdits, kitTools });
514
727
  // Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
515
728
  // validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
516
729
  // when it is already present in the user's allow list (and toAdd is a subset of the core anyway).
517
730
  validateProfile();
731
+ // A --kit-tools run additionally validates the SELECTED tier against core + tier — flagless
732
+ // behavior never depends on local skill paths (the derivation does not even run without the flag).
733
+ if (kitTools === true) {
734
+ const tier = deriveKitToolsAllowlist({ projectDir });
735
+ validateProfile(tier, [...UNIVERSAL_READONLY_ALLOWLIST, ...tier]);
736
+ }
518
737
  const resultBase = { ...preflight, ...plan };
519
738
  if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
520
739
 
@@ -528,7 +747,7 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true }
528
747
  const fs = fsDeps(deps);
529
748
  const settingsPath = join(projectDir, SETTINGS_FILE);
530
749
  if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
531
- const merged = mergeProjectSettings(preflight.projectSettings.data, plan.toAdd, acceptEdits);
750
+ const merged = mergeProjectSettings(preflight.projectSettings.data, [...plan.toAdd, ...plan.tierToAdd], acceptEdits);
532
751
  fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
533
752
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
534
753
  };
@@ -541,6 +760,7 @@ export const parseArgs = (argv) => {
541
760
  if (arg === FLAG_DRY_RUN) return { ...state, dryRunFlag: true };
542
761
  if (arg === FLAG_APPLY) return { ...state, apply: true };
543
762
  if (arg === FLAG_ACCEPT_EDITS) return { ...state, acceptEdits: true };
763
+ if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
544
764
  if (arg === FLAG_CWD) {
545
765
  const next = allArgs[index + 1];
546
766
  if (next === undefined || next.startsWith('-')) throw fail(EXIT_USAGE, `${FLAG_CWD} needs a directory argument`);
@@ -549,7 +769,7 @@ export const parseArgs = (argv) => {
549
769
  if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
550
770
  throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
551
771
  },
552
- { help: false, dryRunFlag: false, apply: false, acceptEdits: false, cwd: undefined, skipNext: false },
772
+ { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, cwd: undefined, skipNext: false },
553
773
  );
554
774
 
555
775
  if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
@@ -558,6 +778,7 @@ export const parseArgs = (argv) => {
558
778
  dryRun: parsed.apply ? false : true,
559
779
  apply: parsed.apply,
560
780
  acceptEdits: parsed.acceptEdits,
781
+ kitTools: parsed.kitTools,
561
782
  cwd: parsed.cwd,
562
783
  };
563
784
  };
@@ -572,7 +793,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
572
793
  return EXIT_OK;
573
794
  }
574
795
  const result = writeVelocityProfile(
575
- { cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun },
796
+ { cwd: args.cwd ?? deps.cwd ?? process.cwd(), acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools },
576
797
  deps,
577
798
  );
578
799
  log(formatVelocityProfileResult(result));