@sabaiway/agent-workflow-kit 14.31.0 → 14.33.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.
@@ -46,7 +46,7 @@ import {
46
46
  } from './velocity-profile.mjs';
47
47
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
48
48
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
49
- import { laneOf, applySlot } from './write-lanes.mjs';
49
+ import { laneOf, applySlot, consoleArgv } from './write-lanes.mjs';
50
50
  import { STATE_BLOCK_HOOK_COMMAND, STATE_BLOCK_HOOK_RUNTIME, isStopHookWired } from './state-block-hook.mjs';
51
51
  import { detectBackends } from './detect-backends.mjs';
52
52
  import { isDirectRun } from './direct-run.mjs';
@@ -80,7 +80,7 @@ import { loadConfig } from './orchestration-config.mjs';
80
80
  import { homeRelative } from './bridge-state-dirs.mjs';
81
81
  import { sandboxEnabled, HOST_HONORS_QUALIFIER } from './bridge-wiring.mjs';
82
82
  import { assertContainedRealPath } from './fs-safe.mjs';
83
- import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
83
+ import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
84
84
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
85
85
  import { NODE_EVIDENCE, NODE_EVIDENCE_SCRIPTS, probeNodeEvidence } from './node-evidence.mjs';
86
86
  // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
@@ -120,8 +120,10 @@ const ABSENT_CODE = 'ENOENT';
120
120
  const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
121
121
  const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
122
122
  const ENFORCEMENT_WHAT = 'the enforcement scripts are not deployed beside docs/ai (no Node evidence) \u2014 the offer copies them and installs the pre-commit hook';
123
- const ENFORCEMENT_APPLY_TEMPLATE = 'HAND-APPLY: mkdir -p {root}/scripts && cp {kit}/references/scripts/*.mjs {root}/scripts/ && node {root}/scripts/install-git-hooks.mjs';
124
- const COMMIT_GUARD_INSTALLER_REMEDY = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 deploy the enforcement scripts (bootstrap steps 8\u20139) and install the hook, then re-run';
123
+ const ENFORCEMENT_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it, so the kit places no script here; with that hook moved aside (your own step) the enforcement item is offered';
124
+ const COMMIT_GUARD_INSTALLER_VIA_ITEM = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 apply the enforcement item first, then re-run';
125
+ const COMMIT_GUARD_INSTALLER_OWN_STEP = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed here \u2014 deploying the enforcement scripts into this project is your own step, then re-run';
126
+ const COMMIT_GUARD_UNMANAGED_HOOK = 'an UNMANAGED pre-commit hook exists \u2014 the installer never overwrites it; adding the guard line to it is your own step';
125
127
  // ── the section contract tokens (doc-parity-bound in upgrade.md + the mode doc) ────────────────
126
128
  export const RECOMMENDATIONS_SECTION_HEADER = '## Recommendations (agent-workflow)';
127
129
  export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.';
@@ -197,6 +199,7 @@ export const SEVERITIES = Object.freeze({
197
199
  'sandbox-masks': SEVERITY_OPTIONAL,
198
200
  'sandbox-masks.unfenced-mount': SEVERITY_ATTENTION,
199
201
  'worktrees-dir': SEVERITY_OPTIONAL,
202
+ 'worktrees-dir.home': SEVERITY_OPTIONAL,
200
203
  // The layer is opt-in, so both arms are OFFERS under the frozen registry (attention is a CONFIGURED
201
204
  // declaration that is broken): an absent store offers the seed, a store with no live contract offers
202
205
  // the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
@@ -280,6 +283,7 @@ export const WHATS = Object.freeze({
280
283
  'sandbox-masks': '{n} sandbox mask(s) clutter git status — the managed exclude block is absent or stale',
281
284
  'sandbox-masks.stale-real': '{n} sandbox mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
282
285
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
286
+ 'worktrees-dir.home': 'the worktrees parent dir {dir} is the home or holds it — the kit grants no write surface there',
283
287
  'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
284
288
  'profile-gap': '{what}',
285
289
  'jev-connect': 'Jev (TypeSafe) is not connected: TYPESAFE_API_KEY is not in the environment the agent\'s commands run in',
@@ -883,7 +887,7 @@ const probeSourceSize = ({ root, deps, add, skip }) => {
883
887
  // The D10 consumer surface: once the declaration is FINAL-capable (the canonical core checks
884
888
  // present, the checker LAST — the run-gates helper is the one home of that rule), offer the
885
889
  // consented commit-guard install — for a MANAGED guardless pre-commit hook AND for an absent one
886
- // alike; an UNMANAGED hook is a loud skip (manual merge, never an overwrite offer); a
890
+ // alike; an UNMANAGED hook is a loud skip naming the user's own step (never an overwrite offer); a
887
891
  // non-final-capable declaration gets NO offer (an armed guard over a matrix that cannot mint a
888
892
  // receipt would block every commit).
889
893
  const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
@@ -892,6 +896,23 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
892
896
  // the kit's OWN resolved guard path. A commented-out line or a lookalike path (fake-commit-guard)
893
897
  // must never read as armed (that would suppress the offer and leave commits unguarded).
894
898
  const canonicalGuardLineRe = () => new RegExp(`^node "${escapeRe(toolPath('commit-guard.mjs'))}" --check$`, 'm');
899
+ // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
900
+ export const readPreCommitState = (root, deps = {}) => {
901
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
902
+ if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
903
+ const path = join(hooksPath, 'pre-commit');
904
+ const hook = (() => {
905
+ try {
906
+ if (readsAbsent(deps, path)) return null;
907
+ return String((deps.readFile ?? readFileSync)(path, 'utf8'));
908
+ } catch {
909
+ return null;
910
+ }
911
+ })();
912
+ if (hook === null) return { state: 'absent', armed: false };
913
+ return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe().test(hook) };
914
+ };
915
+ const formsApply = (variant, root) => consoleArgv(variant, { toolsDir: HERE, root }).apply.map(q).join(' ');
895
916
  const probeCommitGuard = ({ root, deps, add, skip }) => {
896
917
  try {
897
918
  const read = deps.readFile ?? readFileSync;
@@ -907,25 +928,16 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
907
928
  if (!isFinalCapableDeclaration(gates, root)) return; // no receipt to guard yet — no offer
908
929
  const installer = join(root, 'scripts', 'install-git-hooks.mjs');
909
930
  if (!(deps.exists ?? existsSync)(installer)) {
910
- skip('commit-guard', new Error(COMMIT_GUARD_INSTALLER_REMEDY));
931
+ skip('commit-guard', new Error(enforcementAdmits(root, deps) ? COMMIT_GUARD_INSTALLER_VIA_ITEM : COMMIT_GUARD_INSTALLER_OWN_STEP));
911
932
  return;
912
933
  }
913
- const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
914
- if (hooksPath === null) return; // not a git tree — nothing to hook
915
- const hook = (() => {
916
- try {
917
- if (readsAbsent(deps, join(hooksPath, 'pre-commit'))) return null;
918
- return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
919
- } catch {
920
- return null;
921
- }
922
- })();
923
- if (hook !== null && canonicalGuardLineRe().test(hook)) return; // armed — converged (managed or not)
924
- if (hook !== null && !INSTALLER_MARKER_RE.test(hook)) {
925
- skip('commit-guard', new Error('an UNMANAGED pre-commit hook exists — merge the guard line by hand (the installer refuses to overwrite it)'));
934
+ const { state, armed } = readPreCommitState(root, deps);
935
+ if (armed || state === 'no-hooks-dir') return; // armed (managed or not), or not a git tree — converged
936
+ if (state === 'unmanaged') {
937
+ skip('commit-guard', new Error(COMMIT_GUARD_UNMANAGED_HOOK));
926
938
  return;
927
939
  }
928
- add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), `node ${q(installer)} --commit-guard ${q(toolPath('commit-guard.mjs'))}`);
940
+ add('commit-guard', fillTemplate(WHATS['commit-guard'], {}), formsApply('commit-guard', root));
929
941
  } catch (err) {
930
942
  skip('commit-guard', err);
931
943
  }
@@ -940,31 +952,46 @@ const readEnforcementNode = (path, lstat) => {
940
952
  }
941
953
  };
942
954
 
955
+ // The enforcement offer's one admission: true when the item renders, false when nothing renders, and
956
+ // a throw names the skip.
957
+ const enforcementOffer = (root, deps) => {
958
+ const lstat = deps.lstat ?? lstatSync;
959
+ const docsAi = join(root, 'docs', 'ai');
960
+ const docsAiNode = readEnforcementNode(docsAi, lstat);
961
+ if (docsAiNode === null) return false;
962
+ if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
963
+ const evidence = probeNodeEvidence(root, lstat);
964
+ if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return false;
965
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
966
+ if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
967
+ if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
968
+ const readDir = deps.readdir ?? readdirSync;
969
+ const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
970
+ const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
971
+ if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
972
+ const scripts = join(root, 'scripts');
973
+ const scriptsNode = readEnforcementNode(scripts, lstat);
974
+ if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
975
+ for (const basename of scriptsNode === null ? [] : bundleBasenames) {
976
+ const destination = join(scripts, basename);
977
+ if (readEnforcementNode(destination, lstat) === null) continue;
978
+ throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
979
+ }
980
+ if (readPreCommitState(root, deps).state === 'unmanaged') throw new Error(ENFORCEMENT_UNMANAGED_HOOK);
981
+ return true;
982
+ };
983
+
984
+ export const enforcementAdmits = (root, deps = {}) => {
985
+ try {
986
+ return enforcementOffer(root, deps);
987
+ } catch {
988
+ return false;
989
+ }
990
+ };
991
+
943
992
  export const probeEnforcement = ({ root, deps, add, skip }) => {
944
993
  try {
945
- const lstat = deps.lstat ?? lstatSync;
946
- const docsAi = join(root, 'docs', 'ai');
947
- const docsAiNode = readEnforcementNode(docsAi, lstat);
948
- if (docsAiNode === null) return;
949
- if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
950
- const evidence = probeNodeEvidence(root, lstat);
951
- if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return;
952
- if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
953
- if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
954
- if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
955
- const readDir = deps.readdir ?? readdirSync;
956
- const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
957
- const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
958
- if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
959
- const scripts = join(root, 'scripts');
960
- const scriptsNode = readEnforcementNode(scripts, lstat);
961
- if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
962
- for (const basename of scriptsNode === null ? [] : bundleBasenames) {
963
- const destination = join(scripts, basename);
964
- if (readEnforcementNode(destination, lstat) === null) continue;
965
- throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
966
- }
967
- add('enforcement', ENFORCEMENT_WHAT, fillTemplate(ENFORCEMENT_APPLY_TEMPLATE, { root: q(root), kit: q(ENFORCEMENT_KIT_ROOT) }));
994
+ if (enforcementOffer(root, deps)) add('enforcement', ENFORCEMENT_WHAT, formsApply('enforcement', root));
968
995
  } catch (err) {
969
996
  skip('enforcement', err);
970
997
  }
@@ -1379,46 +1406,31 @@ const declaresWritableDir = (root, probeDir, deps) => {
1379
1406
  // existing ancestor resolve to the same dir and keep the same ack.
1380
1407
  export const worktreesDirFingerprint = (probeDir, home) => recipeFingerprint({ hosts: [], dirs: [probeDir], home });
1381
1408
 
1382
- // The worktrees-dir arming item (parallel feature worktrees): fires when write access to the
1383
- // resolved worktrees parent dir (docs/ai/worktrees.json parentDir, else the repo's own parent) is
1384
- // neither confirmed by a trusted host signal nor declared/acknowledged by the maintainer. Before
1385
- // D7 the host signal was the ONLY convergence lane — injectable from tests but never supplied in
1386
- // production, so the item fired forever even once its own advice had been applied.
1409
+ const WORKTREES_ACK_FALLBACK = 'ack fallback, only on your word that this host ignores the settings key, instead of the grant: ';
1410
+
1411
+ // The worktrees-dir item, by the table of spec bridges/velocity-profile, part worktrees-grant: the grant
1412
+ // names the parent itself; the probe dir (its nearest existing ancestor) serves the convergence alone.
1387
1413
  const probeWorktreesDir = ({ root, deps, add, skip }) => {
1388
1414
  try {
1389
- const config = loadWorktreesConfig(root, deps);
1390
- const parent = config.parentDir == null ? dirname(root) : resolve(root, config.parentDir);
1391
- // the SAME canonical derivation the provision preflight probes (worktrees.mjs) — an absent
1392
- // configured dir resolves to its nearest existing ancestor, never a false denial
1393
- const probeDir = resolveProbeDir(parent, deps);
1394
- // A supplied host signal is runtime truth and OVERRIDES both declaration lanes in EITHER
1395
- // direction: a trusted yes converges, a trusted no fires however the project is configured.
1415
+ const grantDir = worktreesGrantDir(root, deps);
1416
+ const probeDir = nearestExistingDir(grantDir, deps);
1396
1417
  const hostSignal = deps.canWriteDir?.(probeDir);
1397
1418
  if (hostSignal === true) return;
1398
1419
  const home = deps.home ?? homedir();
1420
+ if (worktreesGrantHoldsHome(grantDir, home, deps)) {
1421
+ const dir = truncatedTo(oneLineOf(grantDir), templateBudget(WHATS['worktrees-dir.home']));
1422
+ add('worktrees-dir', fillTemplate(WHATS['worktrees-dir.home'], { dir }), '', 'worktrees-dir.home');
1423
+ return;
1424
+ }
1399
1425
  const fingerprint = worktreesDirFingerprint(probeDir, home);
1400
1426
  if (hostSignal !== false) {
1401
1427
  if (declaresWritableDir(root, probeDir, deps)) return;
1402
1428
  if (readAckValue(root, deps, ACKS_WORKTREES_DIR_KEY) === fingerprint) return;
1403
1429
  }
1404
- const dir = truncatedTo(oneLineOf(probeDir), templateBudget(WHATS['worktrees-dir']));
1405
- const grantAdvice = `add ${JSON.stringify(probeDir)} to sandbox.filesystem.allowWrite in .claude/settings.json on settings-native hosts; on harness-managed hosts grant this dir for the session or use the provision terminal fallback`;
1406
- // The consent-flow executes only the APPLY slot; a HAND-APPLY line is maintainer territory and
1407
- // a recipe: detail is informational. So when the ack lane is open, the runnable ack preview IS
1408
- // the apply (dry-run; it prints the exact --apply the flow then runs) and the grant advice
1409
- // rides the detail, labeled as the first step. Against a trusted NO the ack could never
1410
- // converge, so the grant advice is the apply and no ack surfaces.
1411
- if (hostSignal === false) {
1412
- add('worktrees-dir', fillTemplate(WHATS['worktrees-dir'], { dir }), `HAND-APPLY: ${grantAdvice}`);
1413
- } else {
1414
- add(
1415
- 'worktrees-dir',
1416
- fillTemplate(WHATS['worktrees-dir'], { dir }),
1417
- `node ${q(toolPath('ack-write.mjs'))} --lane worktrees-dir --fingerprint ${fingerprint} --cwd ${q(root)}`,
1418
- 'worktrees-dir',
1419
- `HAND-APPLY FIRST: ${grantAdvice}; THEN this item's apply one-liner previews the dir-bound ack and prints the exact --apply that records it`,
1420
- );
1421
- }
1430
+ const dir = truncatedTo(oneLineOf(grantDir), templateBudget(WHATS['worktrees-dir']));
1431
+ const ack = `node ${q(toolPath('ack-write.mjs'))} --lane worktrees-dir --fingerprint ${fingerprint} --cwd ${q(root)}`;
1432
+ add('worktrees-dir', fillTemplate(WHATS['worktrees-dir'], { dir }), ack, 'worktrees-dir',
1433
+ hostSignal === false ? null : `${WORKTREES_ACK_FALLBACK}${ack}`);
1422
1434
  } catch (err) {
1423
1435
  skip('worktrees-dir', err);
1424
1436
  }
@@ -1594,7 +1606,7 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1594
1606
  // pattern-matching prose that is free to be reworded.
1595
1607
  // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
1596
1608
  // the capped WHAT and does NOT belong in the pure-command apply (the bridge-tier missing-entries
1597
- // line; the worktrees-dir hand-apply-first grant advice; the
1609
+ // line; the worktrees-dir ack fallback; the
1598
1610
  // agents hidden-mode reconcile follow-up). Single-line like apply; absent for every other item.
1599
1611
  // Returns whether the item really RENDERED. One probe's disposition depends on another's having
1600
1612
  // spoken (the marker-stale ⟷ read-lane.stale precedence), and "the conditions still look right"
@@ -1686,7 +1698,7 @@ Usage:
1686
1698
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
1687
1699
  one composed verdict line opens every non-optimal render, then per item {severity · what is
1688
1700
  sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the bridge-tier
1689
- missing-entries line, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile
1701
+ missing-entries line, the worktrees-dir ack fallback, or the agents hidden-mode reconcile
1690
1702
  follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1691
1703
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
1692
1704
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
@@ -13,8 +13,9 @@ import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from
13
13
  import { isDirectRun } from './direct-run.mjs';
14
14
  import { compareSemver } from './semver-lite.mjs';
15
15
  import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
16
- import { stateDirsOf, mergeAllowWrite } from './bridge-state-dirs.mjs';
16
+ import { stateDirsOf, mergeAllowWrite, homeRelative } from './bridge-state-dirs.mjs';
17
17
  import { HOST_HONORS_QUALIFIER, sandboxSurfaceDelta } from './bridge-wiring.mjs';
18
+ import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
18
19
  import { loadConfig } from './orchestration-config.mjs';
19
20
  import { validateManifest } from './manifest/validate.mjs';
20
21
  export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
@@ -269,6 +270,7 @@ export const VELOCITY_STAMP = 'VELOCITY_STAMP';
269
270
  export const VELOCITY_UNSAFE_MODE = 'VELOCITY_UNSAFE_MODE';
270
271
  export const VELOCITY_MALFORMED = 'VELOCITY_MALFORMED';
271
272
  export const VELOCITY_SYMLINK = 'VELOCITY_SYMLINK';
273
+ export const VELOCITY_WORKTREES_HOME = 'VELOCITY_WORKTREES_HOME';
272
274
  // The --autonomy render refuses an absent policy file (a writer renders only a declared policy).
273
275
  export const VELOCITY_NO_POLICY = 'VELOCITY_NO_POLICY';
274
276
 
@@ -299,6 +301,7 @@ const FLAG_ACCEPT_EDITS = '--accept-edits';
299
301
  const FLAG_KIT_TOOLS = '--kit-tools';
300
302
  const FLAG_BRIDGE_TIER = '--bridge-tier';
301
303
  const FLAG_AUTONOMY = '--autonomy';
304
+ const FLAG_WORKTREES = '--worktrees';
302
305
  const FLAG_CHECK = '--check';
303
306
  const FLAG_CWD = '--cwd';
304
307
  const FLAG_HELP = '--help';
@@ -329,6 +332,7 @@ const RESIDUAL_NOTICE =
329
332
  const USAGE = `usage: velocity-profile [--dry-run | --apply] [--kit-tools] [--bridge-tier] [--accept-edits] [--cwd <dir>] [--help]
330
333
  velocity-profile --autonomy [--apply] [--cwd <dir>] (render the autonomy policy)
331
334
  velocity-profile --autonomy --check [--cwd <dir>] (read-only drift gate)
335
+ velocity-profile --worktrees [--apply] [--cwd <dir>] (grant the worktrees parent)
332
336
 
333
337
  Allowlist mode (default): seeds the fixed read-only Claude Code allowlist into .claude/settings.json.
334
338
  Default is --dry-run. --apply writes; --accept-edits only sets defaultMode when applying.
@@ -337,7 +341,7 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
337
341
  (project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
338
342
  byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
339
343
  dry-run preview byte-strings. Never touches settings.local.json.
340
- Every apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask once, so all work
344
+ Every flagless, --kit-tools and --bridge-tier apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask once, so all work
341
345
  can stay in auto mode: without it auto mode refuses a harness apply and you must leave auto mode for it; with it the harness shows its usual dialog. It allows nothing.
342
346
  --bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
343
347
  review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
@@ -354,7 +358,16 @@ permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never see
354
358
  leaves permissions.allow untouched. Preview by default; --apply writes; --check is a read-only drift
355
359
  gate (exit 1 on drift). --autonomy cannot combine with --accept-edits, --kit-tools, or --bridge-tier
356
360
  (allowlist-mode flags). Refuses an absent policy (seed one first with set-autonomy). Never touches
357
- settings.local.json.`;
361
+ settings.local.json.
362
+
363
+ --worktrees (own consent, its own mode) writes ONE sandbox.filesystem.allowWrite entry, the worktrees
364
+ parent (docs/ai/worktrees.json parentDir, else the repository's own parent), into this copy's
365
+ .claude/settings.local.json only, never .claude/settings.json, and creates the parent when it is
366
+ missing; it seeds no core and no ask rule.
367
+ It widens, ${HOST_HONORS_QUALIFIER}: every sandboxed command of this copy
368
+ and of every worktree provisioned from it can then write the whole parent dir, sibling repositories
369
+ under it included. A parent that is the home or holds it is refused. It cannot combine with
370
+ --autonomy, --kit-tools, --bridge-tier or --accept-edits.`;
358
371
 
359
372
  const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
360
373
 
@@ -578,13 +591,13 @@ const collectPreExistingNonReadonly = (sources, derivedTier = [], derivedBridgeA
578
591
  .map((entry) => ({ source, entry })),
579
592
  );
580
593
 
581
- const assertTargetWritable = (absPath, deps = {}) => {
594
+ const assertTargetWritable = (absPath, deps = {}, relPath = SETTINGS_FILE) => {
582
595
  const fs = fsDeps(deps);
583
596
  const stat = lstatNoFollow(absPath, fs);
584
597
  if (stat !== null && !stat.isFile()) {
585
598
  throw makeVelocityProfileError(
586
599
  VELOCITY_SYMLINK,
587
- `${SETTINGS_FILE} exists but is not a regular file - refusing to clobber it`,
600
+ `${relPath} exists but is not a regular file - refusing to clobber it`,
588
601
  );
589
602
  }
590
603
  };
@@ -689,6 +702,19 @@ const formatBridgeTier = (result) =>
689
702
  ]
690
703
  : [];
691
704
 
705
+ export const KIT_WORKTREES_GRANT_NOTICE =
706
+ `worktrees grant: ${HOST_HONORS_QUALIFIER}, every sandboxed command of this copy, and of every worktree provisioned from it afterwards (provision copies the local settings), can write the whole worktrees parent dir, sibling repositories under it included, for every later session. ` +
707
+ 'The entry lands in this copy\'s .claude/settings.local.json only, never the committed project settings. A host that ignores the key, a session under permissions.blockReadsOutsideWorkingDirectories and an admin-required sandbox are unchanged by it. ' +
708
+ 'Narrow it: choose a dedicated docs/ai/worktrees.json parentDir BEFORE the first apply — no apply removes an entry, so a parent changed later leaves the earlier entry in settings.local.json until you delete it, your own step.';
709
+
710
+ const formatWorktreesGrant = (result) => [
711
+ `${result.wrote ? 'added' : 'would add'} worktrees grant (sandbox.filesystem.allowWrite in ${SETTINGS_LOCAL_FILE}) entries: ${result.worktreesDirToAdd.length}`,
712
+ ...formatEntryList(result.worktreesDirToAdd),
713
+ `already present (worktrees grant): ${result.worktreesDirAlreadyPresent.length}`,
714
+ ...result.worktreesDirsToCreate.map((dir) => `${result.wrote ? 'created' : 'would create'}: ${dir}`),
715
+ KIT_WORKTREES_GRANT_NOTICE,
716
+ ];
717
+
692
718
  const formatGateAdvisory = (gateCandidates) => [
693
719
  'gate advisory: candidates you may add BY HAND to .claude/settings.json or settings.local.json; this tool will NOT add them',
694
720
  ...(gateCandidates.length
@@ -710,7 +736,10 @@ const formatDefaultMode = (result) =>
710
736
  : `defaultMode: ${result.setsDefaultMode ? `would set to ${ACCEPT_EDITS_MODE}` : 'would not be set by this run'}`;
711
737
 
712
738
  const formatVelocityProfileResult = (result) =>
713
- [
739
+ result.worktrees ? [
740
+ result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
741
+ ...formatWorktreesGrant(result),
742
+ ].join(LF) : [
714
743
  result.dryRun ? 'agent-workflow velocity profile - DRY RUN (no changes)' : 'agent-workflow velocity profile - APPLY',
715
744
  ...formatAllowlist(result),
716
745
  ...formatKitTier(result),
@@ -986,7 +1015,65 @@ const deriveUsedBridgeTier = (root, deps = {}) => {
986
1015
  return { used, recipe, dirs, env, home };
987
1016
  };
988
1017
 
1018
+ const NO_WORKTREES_GRANT = Object.freeze({
1019
+ worktrees: false, worktreesDir: null, worktreesDirToAdd: [], worktreesDirAlreadyPresent: [], worktreesDirsToCreate: [],
1020
+ });
1021
+
1022
+ const assertLocalSandboxMergeable = (data) => {
1023
+ const malformed = (key, shape) => makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_LOCAL_FILE}: ${key} must be ${shape}`);
1024
+ const sandbox = data?.sandbox;
1025
+ if (sandbox === undefined) return {};
1026
+ if (!isJsonObject(sandbox)) throw malformed('sandbox', 'a JSON object');
1027
+ for (const key of ['filesystem', 'network']) {
1028
+ if (sandbox[key] !== undefined && !isJsonObject(sandbox[key])) throw malformed(`sandbox.${key}`, 'a JSON object');
1029
+ }
1030
+ if (sandbox.excludedCommands !== undefined && !Array.isArray(sandbox.excludedCommands)) throw malformed('sandbox.excludedCommands', 'an array');
1031
+ if (sandbox.network?.allowedDomains !== undefined && !Array.isArray(sandbox.network.allowedDomains)) {
1032
+ throw malformed('sandbox.network.allowedDomains', 'an array');
1033
+ }
1034
+ const allowWrite = sandbox.filesystem?.allowWrite;
1035
+ if (allowWrite !== undefined && !(Array.isArray(allowWrite) && allowWrite.every(isResolvableDeclaredEntry))) {
1036
+ throw malformed('sandbox.filesystem.allowWrite', 'an array of non-blank strings');
1037
+ }
1038
+ return sandbox;
1039
+ };
1040
+
1041
+ const planWorktreesGrant = (preflight, opts) => {
1042
+ const home = opts.home ?? homedir();
1043
+ const root = preflight.cwd;
1044
+ const dir = (() => {
1045
+ try {
1046
+ return worktreesGrantDir(root, opts);
1047
+ } catch (err) {
1048
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /u, ''));
1049
+ }
1050
+ })();
1051
+ if (worktreesGrantHoldsHome(dir, home, opts)) {
1052
+ throw makeVelocityProfileError(VELOCITY_WORKTREES_HOME,
1053
+ `the worktrees parent dir ${dir} is the home or holds it - the kit grants no write surface there; point docs/ai/worktrees.json parentDir at a dedicated directory`);
1054
+ }
1055
+ const sandbox = assertLocalSandboxMergeable(preflight.localSettings?.data);
1056
+ const entry = homeRelative(dir, home);
1057
+ const { dirs: worktreesDirToAdd } = sandboxSurfaceDelta({ excludedCommands: [], hosts: [], dirs: [entry] }, sandbox, { home, root });
1058
+ const existing = nearestExistingDir(dir, opts);
1059
+ const worktreesDirsToCreate = [];
1060
+ for (let at = dir; at !== existing; at = dirname(at)) worktreesDirsToCreate.unshift(at);
1061
+ return {
1062
+ ...planVelocityProfile(preflight, {}),
1063
+ toAdd: [],
1064
+ alreadyPresent: [],
1065
+ askToAdd: [],
1066
+ askAlreadyPresent: [],
1067
+ worktrees: true,
1068
+ worktreesDir: entry,
1069
+ worktreesDirToAdd,
1070
+ worktreesDirAlreadyPresent: worktreesDirToAdd.length ? [] : [entry],
1071
+ worktreesDirsToCreate,
1072
+ };
1073
+ };
1074
+
989
1075
  export const planVelocityProfile = (preflight, opts = {}) => {
1076
+ if (opts.worktrees === true) return planWorktreesGrant(preflight, opts);
990
1077
  const { acceptEdits, kitTools, bridgeTier } = opts;
991
1078
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
992
1079
  const projectAsk = getPermissions(preflight.projectSettings?.data).ask ?? [];
@@ -1041,17 +1128,32 @@ export const planVelocityProfile = (preflight, opts = {}) => {
1041
1128
  dirsAlreadyPresent,
1042
1129
  executeToAdd,
1043
1130
  setsDefaultMode: acceptEdits === true,
1131
+ ...NO_WORKTREES_GRANT,
1044
1132
  };
1045
1133
  };
1046
1134
 
1047
- export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false, bridgeTier = false } = {}, deps = {}) => {
1135
+ // The parent chain goes first, outermost first, so the entry never names a missing directory.
1136
+ const applyWorktreesGrant = (projectDir, preflight, plan, resultBase, deps) => {
1137
+ const fs = fsDeps(deps);
1138
+ for (const dir of plan.worktreesDirsToCreate) fs.mkdir(dir);
1139
+ if (plan.worktreesDirToAdd.length === 0) return { wrote: true, dryRun: false, settingsPath: null, ...resultBase };
1140
+ if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
1141
+ const settingsPath = join(projectDir, SETTINGS_LOCAL_FILE);
1142
+ const merged = mergeAllowWrite(preflight.localSettings.data ?? {}, plan.worktreesDirToAdd);
1143
+ fs.writeFile(settingsPath, formatJson(merged, preflight.localSettings.eol ?? LF), UTF8);
1144
+ return { wrote: true, dryRun: false, settingsPath, ...resultBase };
1145
+ };
1146
+
1147
+ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true, kitTools = false, bridgeTier = false, worktrees = false } = {}, deps = {}) => {
1048
1148
  const projectDir = cwd ?? deps.cwd ?? process.cwd();
1149
+ if (worktrees === true) assertTargetWritable(join(projectDir, SETTINGS_LOCAL_FILE), deps, SETTINGS_LOCAL_FILE);
1049
1150
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1050
1151
  const plan = planVelocityProfile(preflight, {
1051
1152
  ...deps,
1052
1153
  acceptEdits,
1053
1154
  kitTools,
1054
1155
  bridgeTier,
1156
+ worktrees,
1055
1157
  });
1056
1158
  // Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
1057
1159
  // validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
@@ -1079,6 +1181,7 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1079
1181
  `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) - run init/upgrade first`,
1080
1182
  );
1081
1183
  }
1184
+ if (worktrees === true) return applyWorktreesGrant(projectDir, preflight, plan, resultBase, deps);
1082
1185
 
1083
1186
  const fs = fsDeps(deps);
1084
1187
  const settingsPath = join(projectDir, SETTINGS_FILE);
@@ -1697,7 +1800,7 @@ export const formatAutonomyResult = (r) => {
1697
1800
  // boundary is deliberate: permissions.* precedence is the harness's own documented permission
1698
1801
  // model (the layer this whole mode rests on), so those masks stay flat statements.
1699
1802
  const scoped = m.key.startsWith(`${SANDBOX_KEY}.`) ? ` ${HOST_HONORS_QUALIFIER}` : '';
1700
- lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and is never written by the kit.`);
1803
+ lines.push(` ⚠ ${SETTINGS_LOCAL_FILE} sets ${m.key}=${JSON.stringify(m.local)}, which MASKS this render's ${m.key}=${JSON.stringify(m.rendered)} (local > project) — the local value wins${scoped}, so the render is not effective for that key; the local file is the maintainer's and this render never writes it.`);
1701
1804
  }
1702
1805
  for (const b of r.redlineBypass ?? []) {
1703
1806
  lines.push(` ⚠ DEGRADE: ${b.source} has a pre-existing allow entry ${b.entry} that would BYPASS the rendered red-line(s) ${b.redlines.join('/')} (a matching allow rule AUTO-APPROVES the command, defeating ask/deny) — remove it by hand; this render never touches permissions.allow.`);
@@ -1881,6 +1984,7 @@ export const parseArgs = (argv) => {
1881
1984
  if (arg === FLAG_KIT_TOOLS) return { ...state, kitTools: true };
1882
1985
  if (arg === FLAG_BRIDGE_TIER) return { ...state, bridgeTier: true };
1883
1986
  if (arg === FLAG_AUTONOMY) return { ...state, autonomy: true };
1987
+ if (arg === FLAG_WORKTREES) return { ...state, worktrees: true };
1884
1988
  if (arg === FLAG_CHECK) return { ...state, check: true };
1885
1989
  if (arg === FLAG_CWD) {
1886
1990
  const next = allArgs[index + 1];
@@ -1890,8 +1994,11 @@ export const parseArgs = (argv) => {
1890
1994
  if (arg.startsWith('-')) throw fail(EXIT_USAGE, `unknown flag: ${arg}`);
1891
1995
  throw fail(EXIT_USAGE, `unexpected argument: ${arg}`);
1892
1996
  },
1893
- { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, bridgeTier: false, autonomy: false, check: false, cwd: undefined, skipNext: false },
1997
+ { help: false, dryRunFlag: false, apply: false, acceptEdits: false, kitTools: false, bridgeTier: false, autonomy: false, worktrees: false, check: false, cwd: undefined, skipNext: false },
1894
1998
  );
1999
+ const worktreesConflict = [[parsed.autonomy, FLAG_AUTONOMY], [parsed.kitTools, FLAG_KIT_TOOLS],
2000
+ [parsed.bridgeTier, FLAG_BRIDGE_TIER], [parsed.acceptEdits, FLAG_ACCEPT_EDITS]].find(([set]) => parsed.worktrees && set);
2001
+ if (worktreesConflict) throw fail(EXIT_USAGE, `${FLAG_WORKTREES} is its own mode writing ${SETTINGS_LOCAL_FILE} alone — it cannot be combined with ${worktreesConflict[1]}`);
1895
2002
 
1896
2003
  if (parsed.dryRunFlag && parsed.apply) throw fail(EXIT_USAGE, `${FLAG_DRY_RUN} and ${FLAG_APPLY} cannot be used together`);
1897
2004
  // The --autonomy render is a SEPARATE mode from the allowlist seeding: the allowlist-only flags
@@ -1911,6 +2018,7 @@ export const parseArgs = (argv) => {
1911
2018
  kitTools: parsed.kitTools,
1912
2019
  bridgeTier: parsed.bridgeTier,
1913
2020
  autonomy: parsed.autonomy,
2021
+ worktrees: parsed.worktrees,
1914
2022
  check: parsed.check,
1915
2023
  cwd: parsed.cwd,
1916
2024
  };
@@ -1939,7 +2047,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
1939
2047
  return EXIT_OK;
1940
2048
  }
1941
2049
  const result = writeVelocityProfile(
1942
- { cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools, bridgeTier: args.bridgeTier },
2050
+ { cwd, acceptEdits: args.acceptEdits, dryRun: args.dryRun, kitTools: args.kitTools, bridgeTier: args.bridgeTier, worktrees: args.worktrees },
1943
2051
  deps,
1944
2052
  );
1945
2053
  log(formatVelocityProfileResult(result));
@@ -23,11 +23,12 @@ import {
23
23
  patternToProbe,
24
24
  } from './known-footprint.mjs';
25
25
  import { isDirectRun } from './direct-run.mjs';
26
- import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './review-state.mjs';
26
+ import { isScratchPlanName, plansInFlight, PLANS_REL } from './plan-files.mjs';
27
+ import { shellQuoteArg } from './repo-lex.mjs';
27
28
  import { writeContainedFileAtomic } from './atomic-write.mjs';
28
29
  import { assertContainedRealPath } from './fs-safe.mjs';
29
30
  import { GIT_MAX_BUFFER } from './git-env.mjs';
30
- import { isFinalCapableDeclaration } from './run-gates.mjs';
31
+ import { isFinalCapableDeclaration } from './gates-declaration.mjs';
31
32
  import {
32
33
  WORKTREES_STOP, stop, EXIT, handoffBasename, recordValue, hasControlByte, displayValue,
33
34
  composeProvisionRecordSection, composeLandingValue, composeHandoffStub,
@@ -38,6 +39,8 @@ import {
38
39
  readSatelliteIdentity,
39
40
  } from './satellite-locator.mjs';
40
41
  import { composeSatellitePrompt, resolveSeededPlan } from './worktree-prompt.mjs';
42
+ import { applySlot } from './write-lanes.mjs';
43
+ import { dirCovers } from './declared-paths.mjs';
41
44
 
42
45
  // The record format and the satellite locator now live in leaves the dispatch side reads too — but
43
46
  // every name they took away is re-exported here, so no import site and no asserted error `code`
@@ -577,11 +580,25 @@ export const nearestExistingDir = (path, deps = {}) => {
577
580
  return walk(resolve(path));
578
581
  };
579
582
 
580
- // The ONE canonical probe-dir derivation provision AND the recommendations advisor share:
581
- // resolve through the existing-parent realpath, then walk to the nearest existing dir.
583
+ // The provision preflight's probe-dir derivation: resolve through the existing-parent realpath, then
584
+ // walk to the nearest existing dir (the advisor walks worktreesGrantDir's answer the same way).
582
585
  export const resolveProbeDir = (path, deps = {}) =>
583
586
  nearestExistingDir(realpathThroughExistingParent(path, deps), deps);
584
587
 
588
+ // The worktrees write grant names the parent ITSELF (spec bridges/velocity-profile, part worktrees-grant):
589
+ // the writer, the advisor and the danger check read this one derivation, never resolveProbeDir's ancestor.
590
+ export const worktreesGrantDir = (root, deps = {}) => {
591
+ const realRoot = realpathThroughExistingParent(root, deps);
592
+ const { parentDir } = loadWorktreesConfig(realRoot, deps);
593
+ const dir = realpathThroughExistingParent(parentDir == null ? dirname(realRoot) : resolve(realRoot, parentDir), deps);
594
+ const st = lstatNoFollow(fsOf(deps).lstat, dir);
595
+ if (st !== null && !st.isDirectory()) throw stop(`${CONFIG_REL}: parentDir names an existing non-directory: ${dir}`);
596
+ return dir;
597
+ };
598
+
599
+ export const worktreesGrantHoldsHome = (dir, home, deps = {}) =>
600
+ dirCovers(dir, resolve(home)) || dirCovers(dir, realpathThroughExistingParent(resolve(home), deps));
601
+
585
602
  export const probeParentWritable = (parentDir, deps = {}) => {
586
603
  const { mkdirPlain, rmdir, rand } = fsOf(deps);
587
604
  const probe = join(parentDir, `.aw-write-probe-${rand()}`);
@@ -620,10 +637,13 @@ const quoteOwnTool = () => `'${WORKTREES_TOOL_ABS.replace(/'/g, `'\\''`)}'`;
620
637
  const composeOwnToolPrefix = (root) =>
621
638
  `cd ${shellQuoteArg(root)} && node ${quoteOwnTool()}`;
622
639
 
640
+ const composeGrantRoute = (root) => `the kit's worktrees-dir item grants the configured worktrees parent on your yes: the agent runs ${
641
+ applySlot('worktrees-dir', { toolsDir: dirname(WORKTREES_TOOL_ABS), root }).check
642
+ } and, on your yes on its result, its apply line by the consent route, then runs this command again; when that check refuses, prints nothing to change, or grants a dir that does not hold this command's target, use the terminal fallback below`;
643
+
623
644
  const landWritabilityStop = ({ parentDir, root, slug }) => stop([
624
645
  `the worktrees parent dir is not writable from this session: ${parentDir}`,
625
- 'Arm the ONE-TIME consent (then land runs promptless):',
626
- ` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
646
+ composeGrantRoute(root),
627
647
  'Or run the full command yourself in a plain terminal:',
628
648
  ` ${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`,
629
649
  ].join('\n'));
@@ -638,8 +658,7 @@ const writabilityStop = ({ parentDir, root, slug, flags }) => {
638
658
  return stop(
639
659
  [
640
660
  `the worktrees parent dir is not writable from this session: ${parentDir}`,
641
- 'Arm the ONE-TIME consent (then every provision/cleanup runs promptless):',
642
- ` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
661
+ composeGrantRoute(root),
643
662
  'Or run the full command yourself in a plain terminal:',
644
663
  ` ${composeProvisionArgv({ root, slug, flags })}`,
645
664
  `(from the target repo root, when that checkout carries the kit at agent-workflow-kit/: node agent-workflow-kit/tools/worktrees.mjs provision ${q(slug)} ${provisionFlagsTail(flags, q).join(' ')})`,
@@ -3150,8 +3169,7 @@ const destructiveRecovery = ({ slug, branch }) =>
3150
3169
 
3151
3170
  const cleanupWritabilityStop = ({ parentDir, root, slug, branch, abandon }) => stop([
3152
3171
  `the worktrees parent dir is not writable from this session: ${parentDir}`,
3153
- 'Arm the ONE-TIME consent (then every provision/cleanup runs promptless):',
3154
- ` .claude/settings.json → sandbox.filesystem.allowWrite += ${JSON.stringify(parentDir)}`,
3172
+ composeGrantRoute(root),
3155
3173
  'Or run the full command yourself in a plain terminal:',
3156
3174
  ` ${composeOwnToolPrefix(root)} ${composeCleanupCommand({ slug, branch, abandon })}`,
3157
3175
  ].join('\n'));