@sabaiway/agent-workflow-kit 14.25.2 → 14.27.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,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -7,14 +7,16 @@ import { fileURLToPath } from 'node:url';
7
7
  // it never imports the policy fs-writer (autonomy-write.mjs) — the render owns the settings file, not
8
8
  // the policy file.
9
9
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from './autonomy-config.mjs';
10
- // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
11
- // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
12
- import { findOnPath } from './detect-backends.mjs';
10
+ import { detectBackends, findOnPath, wrapperCmdFor } from './detect-backends.mjs';
13
11
  // The seedable-path predicate lives in the pure leaf so the renders spell a path exactly as it is seeded.
14
12
  import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from './repo-lex.mjs';
15
13
  import { isDirectRun } from './direct-run.mjs';
16
14
  import { compareSemver } from './semver-lite.mjs';
17
- import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
15
+ import { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL, usedBridges, bundledSandboxRecipe, exclusionOf } from './bridge-sandbox-recipe.mjs';
16
+ import { stateDirsOf, missingStateDirs, mergeAllowWrite } from './bridge-state-dirs.mjs';
17
+ import { loadConfig } from './orchestration-config.mjs';
18
+ import { validateManifest } from './manifest/validate.mjs';
19
+ export { BRIDGE_REVIEW_WRAPPERS, BRIDGE_REVIEW_MODE, KIT_GROUNDING_TOOL };
18
20
  // The declared-path resolution + segment containment the allowWrite degrade shares with the
19
21
  // advisor's worktrees-dir convergence lane — ONE leaf, so the two readings cannot drift.
20
22
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
@@ -197,27 +199,6 @@ const KIT_ROOT = fileURLToPath(new URL('..', import.meta.url));
197
199
  const KIT_TOOL_INVOKER = 'node';
198
200
  const RUN_GATES_CWD_FLAG = '--cwd';
199
201
 
200
- // ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
201
- // The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
202
- // wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
203
- // THIS constant, never from the detector's wrapperCmds (which carries no role labels): codex-exec
204
- // (execution role) and agy-run (probe role) are deliberately ABSENT. The velocity pain this tier
205
- // closes is council REVIEW runs; codex-exec's nested-sandbox recovery is handled the canon way —
206
- // route it outside ON the OBSERVED failure (orchestration.md §5), NOT a preemptive tier seed (AD-054
207
- // final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
208
- // codex-exec.sh's own nested-sandbox detection hint.
209
- export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
210
- // Only the `code` review mode is auto-allowed (Segment A): a bare `Bash(<wrapper>:*)`
211
- // prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
212
- // repo — the tier's stated surface is the unattended council CODE review, so the seeded prefix is
213
- // `<wrapper> code`. Plan/diff invocations keep their prompt.
214
- export const BRIDGE_REVIEW_MODE = 'code';
215
- // The grounding pre-step tool (the reviews' facts assembler). Its tier entry is seeded in the
216
- // EXACT byte-form the procedures advisor renders — a DOUBLE-QUOTED absolute path (deliberate
217
- // there: a skill dir with a space must stay copy-pasteable) — so seeded↔rendered byte-parity
218
- // holds; the screen accepts the quoted form ONLY for this one tool (a tier-scoped acceptance,
219
- // never a general quote allowance).
220
- export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
221
202
  const WRAPPER_PLACED = 'present';
222
203
 
223
204
  // The quoted-grounding token: "<seedable-abs-path>/tools/grounding.mjs" — quotes stripped, the
@@ -229,41 +210,6 @@ const isQuotedGroundingToken = (token) => {
229
210
  return isSeedablePathToken(inner) && inner.endsWith(`/${KIT_GROUNDING_TOOL}`);
230
211
  };
231
212
 
232
- /**
233
- * Derive the opt-in bridge-wrappers tier: one code-mode allow rule per PLACED review wrapper (the
234
- * frozen constant is the membership source; placement is a probe, never a role source), the SAME
235
- * wrapper names for sandbox.excludedCommands (the harness runs an excluded command OUTSIDE the
236
- * sandbox, so a plain allowlisted invocation needs no sandbox-bypass approval — the zero-prompt
237
- * wiring), and the grounding pre-step rule in its rendered quoted byte-form (derived only when
238
- * agy-review is placed; an unseedable kit path is a stated skip). An absent bridge is a stated skip.
239
- */
240
- export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
241
- const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
242
- const placed = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => probe(cmd));
243
- const skips = BRIDGE_REVIEW_WRAPPERS.filter((cmd) => !placed.includes(cmd)).map((cmd) => ({
244
- entry: cmd,
245
- reason: `bridge wrapper "${cmd}" is not placed on PATH — its allow rule is not seeded (place the bridge with /agent-workflow-kit setup, then re-run)`,
246
- }));
247
- const allow = placed.map((cmd) => `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
248
- const excludedCommands = [...placed];
249
- // The grounding pre-step exists FOR agy (its grounded --facts reviews; codex grounds natively via
250
- // the AGENTS.md auto-merge) — a codex-only install must not auto-allow an unused writer.
251
- if (placed.includes('agy-review')) {
252
- // groundingAbsPath is a TEST seam only (an unseedable kit path — spaces — is not constructible
253
- // from a test against the real checkout); production callers never pass it.
254
- const groundingAbs = groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
255
- if (isSeedablePathToken(groundingAbs)) {
256
- allow.push(`Bash(${KIT_TOOL_INVOKER} "${groundingAbs}":*)`);
257
- } else {
258
- skips.push({
259
- entry: groundingAbs,
260
- reason: 'the kit path is not a POSIX absolute path free of spaces/metacharacters/quoting — the grounding pre-step rule is not seeded (its prompt stays); add a hand-picked entry if you accept the spelling',
261
- });
262
- }
263
- }
264
- return Object.freeze({ allow: Object.freeze(allow), excludedCommands: Object.freeze(excludedCommands), skips: Object.freeze(skips), placed: Object.freeze(placed) });
265
- };
266
-
267
213
  // Characters that make a pattern NOT a single read-only command, so the screen rejects them.
268
214
  // Per current Claude Code permission semantics: the recognized command SEPARATORS are
269
215
  // `&& || ; | |& &` plus newline (each segment must be independently permitted, so a chained
@@ -386,14 +332,15 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
386
332
  (project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
387
333
  byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
388
334
  dry-run preview byte-strings. Never touches settings.local.json.
389
- --bridge-tier (own consent) seeds the bridge REVIEW wrappers' CODE mode for PLACED bridges
390
- (codex-review code, agy-review code - never the execution/probe wrappers, never plan/diff modes)
391
- + the quoted grounding pre-step rule, and the wrapper names into sandbox.excludedCommands (they
392
- need network - ${HOST_HONORS_QUALIFIER} the harness runs them outside the sandbox). It also
393
- seeds the placed bridges' declared hosts into sandbox.network.allowedDomains - ${HOST_HONORS_QUALIFIER}
394
- every sandboxed command of the project can then reach those hosts; the writable state dirs stay
395
- hand-apply. Consented posture: an auto-allowed review wrapper runs UNATTENDED and sends the
396
- assembled repo payload to its subscription backend (see the printed tier notice).
335
+ --bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
336
+ review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
337
+ and <wrapper> * exclusions in sandbox.excludedCommands. An execute slot delegating to codex adds
338
+ codex-exec * as an exclusion; execution keeps its prompt, as do the review plan/diff modes.
339
+ ${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox;
340
+ sandbox.network.allowedDomains means every sandboxed command of the project can reach those hosts;
341
+ sandbox.filesystem.allowWrite means every sandboxed command can write those state dirs.
342
+ Consented posture: an auto-allowed review wrapper runs UNATTENDED and sends the assembled repo
343
+ payload to its subscription backend (see the printed tier notice).
397
344
 
398
345
  --autonomy renders docs/ai/autonomy.json into the settings blocks it OWNS — the sandbox block +
399
346
  permissions.ask/deny red-lines + permissions.defaultMode. POLICY-ONLY: never seeds the allowlist and
@@ -696,11 +643,9 @@ const formatKitTier = (result) =>
696
643
  // The bridge tier's honest posture, printed on EVERY --bridge-tier run: the informed-consent
697
644
  // resolution states the exfiltration surface, never pretends it away.
698
645
  export const KIT_BRIDGE_TIER_NOTICE =
699
- 'bridge-wrappers tier: seeds the REVIEW wrappers only, and only their CODE mode (`codex-review code`, `agy-review code` — never codex-exec/agy-run: delegated execution keeps its human prompt; never the plan/diff modes: their file arguments can point outside the repo, so they keep their prompt), each derived ONLY when its bridge is PLACED on PATH, plus the grounding pre-step rule in its rendered quoted byte-form. POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend, and prefix rules cannot inspect arguments, so a code-mode argument that names a readable file (agy\'s --facts/--decided) rides the same consent — the same documented residual class as the autonomy red-line rules; that is the tier\'s PURPOSE (unattended council review runs) and its residual — tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). The wrapper names are ALSO seeded into sandbox.excludedCommands IN THE PROJECT settings.json (an exclusion only in settings.local.json was live-observed NOT to route — the wrapper then runs sandboxed and dies on a read-only HOME): ' +
700
- // The interpolated seams in this notice: the shared host-conditional qualifier, so the tier's
701
- // routing promise and the render's degrade lines can never drift apart.
702
- `${HOST_HONORS_QUALIFIER} the harness runs an excluded command OUTSIDE the sandbox (the wrappers need network), so a plain allowlisted invocation triggers no sandbox-bypass approval — whether a host honors them is not knowable from here, and where it does not the wrapper simply starts sandboxed (fail-safe, never a silent widening). ` +
703
- `The placed bridges' declared hosts (each bundled manifest's networkHosts) are seeded into sandbox.network.allowedDomains IN THE PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, a wrapper the harness keeps sandboxed still reaches them, and every sandboxed command of the project can then reach those hosts, not only the wrappers — that widening is part of this consent, and the --autonomy preview reports it as a network weakening; the writable state dirs (writableDirs) stay hand-apply. ` +
646
+ 'bridge-wrappers tier: each entry derives for a used bridge from the effective project recipes and host readiness. Review allows cover only CODE mode (`codex-review code`, `agy-review code`) plus the grounding pre-step rule in its rendered quoted byte-form; the plan/diff modes keep their prompt because their file arguments can point outside the repo. An execute slot delegating to codex adds codex-exec * to sandbox.excludedCommands and keeps its prompt, with no execution allow rule; agy-run is not seeded. ' +
647
+ 'POSTURE (what this consent covers): an auto-allowed review wrapper runs UNATTENDED — it reads any repo file it is pointed at and sends the assembled payload to its subscription backend. Prefix rules cannot inspect arguments, so readable file arguments (agy\'s --facts/--decided) ride the same consent; tier entries get NO PreToolUse-hook coverage. The grounding entry\'s writer surface is bounded by grounding.mjs\'s OWN scratch-destination guard (a tracked or in-repo-not-ignored --out is refused by the tool). ' +
648
+ `The tier writes the PROJECT settings.json: ${HOST_HONORS_QUALIFIER}, an excluded wrapper runs outside the sandbox (<wrapper> * in sandbox.excludedCommands), every sandboxed command of the project can reach the declared hosts (sandbox.network.allowedDomains), and every sandboxed command can write the declared state dirs (sandbox.filesystem.allowWrite). These surfaces are part of the bridge-wrappers consent. Whether a host honors them is not knowable from here; an exclusion only in settings.local.json was live-observed not to route. ` +
704
649
  'INVOCATION SHAPE: a prefix rule matches only a PLAIN invocation starting with the wrapper name — an env-var prefix or a compound chain never matches (redirects are fine).';
705
650
 
706
651
  const formatBridgeTier = (result) =>
@@ -719,6 +664,13 @@ const formatBridgeTier = (result) =>
719
664
  `already present (allowedDomains): ${result.hostsAlreadyPresent.length}`,
720
665
  ]
721
666
  : []),
667
+ ...(result.dirsToAdd.length + result.dirsAlreadyPresent.length
668
+ ? [
669
+ `${result.wrote ? 'added' : 'would add'} sandbox.filesystem.allowWrite entries: ${result.dirsToAdd.length}`,
670
+ ...formatEntryList(result.dirsToAdd),
671
+ `already present (allowWrite): ${result.dirsAlreadyPresent.length}`,
672
+ ]
673
+ : []),
722
674
  ...result.bridgeSkips.map((s) => ` skipped: ${s.reason}`),
723
675
  KIT_BRIDGE_TIER_NOTICE,
724
676
  ]
@@ -941,7 +893,8 @@ export const preflightVelocityProfile = ({ cwd }, deps = {}) => {
941
893
  // bridge-shaped entry — over-flagging is the safe direction for a read-only advisory.
942
894
  const derivedBridge = (() => {
943
895
  try {
944
- return deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper }).allow;
896
+ const probe = deps.findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
897
+ return bundledSandboxRecipe(BRIDGE_REVIEW_WRAPPERS.filter(probe), deps).allow;
945
898
  } catch {
946
899
  return [];
947
900
  }
@@ -982,14 +935,54 @@ const assertNetworkMergeable = (sandbox) => {
982
935
  return sandbox.network?.allowedDomains ?? [];
983
936
  };
984
937
 
985
- export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTier, findWrapper, bundleRoot, readdir, readFile } = {}) => {
938
+ const assertFilesystemMergeable = (sandbox) => {
939
+ if (sandbox.filesystem !== undefined && !isJsonObject(sandbox.filesystem)) {
940
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem must be a JSON object`);
941
+ }
942
+ const allowWrite = sandbox.filesystem?.allowWrite;
943
+ if (allowWrite !== undefined && !Array.isArray(allowWrite)) {
944
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite must be an array`);
945
+ }
946
+ if (allowWrite?.some((entry) => !isResolvableDeclaredEntry(entry))) {
947
+ throw makeVelocityProfileError(VELOCITY_MALFORMED, `${SETTINGS_FILE}: sandbox.filesystem.allowWrite entries must be non-blank strings`);
948
+ }
949
+ return allowWrite ?? [];
950
+ };
951
+
952
+ // Readiness as a wrapper started from the project root sees it: every relative path the detector probes
953
+ // (an env override such as CODEX_HOME=state) resolves against the root, never this process's cwd, so the
954
+ // danger check and the writer it spawns with cwd=root derive one used set.
955
+ export const detectAtRoot = (root, { env, home }) => {
956
+ const at = (path) => resolve(root, path);
957
+ return detectBackends({
958
+ getenv: env, home,
959
+ exists: (path) => existsSync(at(path)), stat: (path) => statSync(at(path)),
960
+ access: (path) => accessSync(at(path), constants.X_OK), realpath: (path) => realpathSync(at(path)),
961
+ validate: (dir) => validateManifest(at(dir)),
962
+ });
963
+ };
964
+
965
+ const deriveUsedBridgeTier = (root, deps = {}) => {
966
+ const env = deps.env ?? process.env;
967
+ const home = deps.home ?? homedir();
968
+ const config = loadConfig(root).config;
969
+ const readiness = (deps.detect ?? (() => detectAtRoot(root, { env, home })))();
970
+ const used = usedBridges(config, readiness);
971
+ const recipe = bundledSandboxRecipe(used, deps);
972
+ const dirs = stateDirsOf(recipe.dirEntries, { env, root, home });
973
+ return { used, recipe, dirs, env, home };
974
+ };
975
+
976
+ export const planVelocityProfile = (preflight, opts = {}) => {
977
+ const { acceptEdits, kitTools, bridgeTier } = opts;
986
978
  const projectAllow = getAllowEntries(preflight.projectSettings?.data);
987
979
  const toAdd = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => !projectAllow.includes(entry));
988
980
  const alreadyPresent = UNIVERSAL_READONLY_ALLOWLIST.filter((entry) => projectAllow.includes(entry));
989
981
  const tier = kitTools === true ? deriveKitToolsAllowlist({ projectDir: preflight.cwd }) : [];
990
982
  const tierToAdd = tier.filter((entry) => !projectAllow.includes(entry));
991
983
  const tierAlreadyPresent = tier.filter((entry) => projectAllow.includes(entry));
992
- const bridge = bridgeTier === true ? deriveBridgeTierAllowlist({ findWrapper }) : { allow: [], excludedCommands: [], skips: [], placed: [] };
984
+ const derived = bridgeTier === true ? deriveUsedBridgeTier(preflight.cwd, opts) : null;
985
+ const bridge = derived?.recipe ?? { allow: [], excludedCommands: [], hosts: [], skips: [] };
993
986
  const bridgeToAdd = bridge.allow.filter((entry) => !projectAllow.includes(entry));
994
987
  const bridgeAlreadyPresent = bridge.allow.filter((entry) => projectAllow.includes(entry));
995
988
  const existingSandbox = isJsonObject(preflight.projectSettings?.data?.sandbox) ? preflight.projectSettings.data.sandbox : {};
@@ -997,9 +990,12 @@ export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTi
997
990
  const excludedToAdd = bridge.excludedCommands.filter((cmd) => !existingExcluded.includes(cmd));
998
991
  const excludedAlreadyPresent = bridge.excludedCommands.filter((cmd) => existingExcluded.includes(cmd));
999
992
  const existingHosts = bridgeTier === true ? assertNetworkMergeable(existingSandbox) : [];
1000
- const hosts = bridgeTier === true ? bundledSandboxRecipe(bridge.placed, { bundleRoot, readdir, readFile }).hosts : [];
1001
- const hostsToAdd = hosts.filter((host) => !existingHosts.includes(host));
1002
- const hostsAlreadyPresent = hosts.filter((host) => existingHosts.includes(host));
993
+ const hostsToAdd = bridge.hosts.filter((host) => !existingHosts.includes(host));
994
+ const hostsAlreadyPresent = bridge.hosts.filter((host) => existingHosts.includes(host));
995
+ const existingDirs = bridgeTier === true ? assertFilesystemMergeable(existingSandbox) : [];
996
+ const dirs = derived?.dirs ?? [];
997
+ const dirsToAdd = derived ? missingStateDirs(dirs, existingDirs, { home: derived.home, root: preflight.cwd }) : [];
998
+ const dirsAlreadyPresent = dirs.filter((dir) => !dirsToAdd.includes(dir));
1003
999
  return {
1004
1000
  toAdd,
1005
1001
  alreadyPresent,
@@ -1014,6 +1010,8 @@ export const planVelocityProfile = (preflight, { acceptEdits, kitTools, bridgeTi
1014
1010
  excludedAlreadyPresent,
1015
1011
  hostsToAdd,
1016
1012
  hostsAlreadyPresent,
1013
+ dirsToAdd,
1014
+ dirsAlreadyPresent,
1017
1015
  setsDefaultMode: acceptEdits === true,
1018
1016
  };
1019
1017
  };
@@ -1022,13 +1020,10 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1022
1020
  const projectDir = cwd ?? deps.cwd ?? process.cwd();
1023
1021
  const preflight = preflightVelocityProfile({ cwd: projectDir }, deps);
1024
1022
  const plan = planVelocityProfile(preflight, {
1023
+ ...deps,
1025
1024
  acceptEdits,
1026
1025
  kitTools,
1027
1026
  bridgeTier,
1028
- findWrapper: deps.findWrapper,
1029
- bundleRoot: deps.bundleRoot,
1030
- readdir: deps.readdir,
1031
- readFile: deps.readFile,
1032
1027
  });
1033
1028
  // Drift guard runs on BOTH dry-run and apply (so a dry-run faithfully predicts the apply) and
1034
1029
  // validates the FULL audited core, not just the to-add delta — a drifted core entry is caught even
@@ -1044,8 +1039,8 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1044
1039
  // (Decision 2): every seeded bridge entry passes the screen AND sits in the audited set, so the
1045
1040
  // flagless advisory can never flag an entry the tier itself seeded.
1046
1041
  if (bridgeTier === true) {
1047
- const bridge = deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper });
1048
- validateProfile(bridge.allow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridge.allow]);
1042
+ const bridgeAllow = [...plan.bridgeToAdd, ...plan.bridgeAlreadyPresent];
1043
+ validateProfile(bridgeAllow, [...UNIVERSAL_READONLY_ALLOWLIST, ...bridgeAllow]);
1049
1044
  }
1050
1045
  const resultBase = { ...preflight, ...plan };
1051
1046
  if (dryRun) return { wrote: false, dryRun: true, ...resultBase };
@@ -1060,13 +1055,13 @@ export const writeVelocityProfile = ({ cwd, acceptEdits = false, dryRun = true,
1060
1055
  const fs = fsDeps(deps);
1061
1056
  const settingsPath = join(projectDir, SETTINGS_FILE);
1062
1057
  if (preflight.claudeDirAbsent) fs.mkdir(join(projectDir, CLAUDE_DIR), { recursive: true });
1063
- const merged = mergeProjectSettings(
1058
+ const merged = mergeAllowWrite(mergeProjectSettings(
1064
1059
  preflight.projectSettings.data,
1065
1060
  [...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd],
1066
1061
  acceptEdits,
1067
1062
  plan.excludedToAdd,
1068
1063
  plan.hostsToAdd,
1069
- );
1064
+ ), plan.dirsToAdd);
1070
1065
  fs.writeFile(settingsPath, formatJson(merged, preflight.projectSettings.eol ?? LF), UTF8);
1071
1066
  return { wrote: true, dryRun: false, settingsPath, ...resultBase };
1072
1067
  };
@@ -1489,23 +1484,76 @@ const externalWriteEntries = (entries, { root, home, tmp }) => {
1489
1484
  };
1490
1485
  };
1491
1486
 
1492
- const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1493
- // Tier-known PROOF: an excludedCommands entry is downgraded to a note ONLY when it is
1494
- // demonstrably the consented tier's own output — it lives in the PROJECT settings.json (the file
1495
- // the tier writes; a local-file exclusion is never tier output) AND the matching derived
1496
- // code-mode allow rule is present there. A bare name match alone proves nothing.
1487
+ const cacheBundleReader = (read) => {
1488
+ const entries = new Map();
1489
+ return (path, ...args) => {
1490
+ if (!entries.has(path)) entries.set(path, read(path, ...args));
1491
+ return entries.get(path);
1492
+ };
1493
+ };
1494
+
1495
+ // The --autonomy preview's tier-known set (spec velocity-profile): only a used role whose consent
1496
+ // proof the PROJECT settings carry — a review role's derived code-mode allow rule, the execute
1497
+ // role's `codex-exec *` exclusion — makes its derived surfaces notes; any derivation failure is
1498
+ // returned as `error`, and the caller then judges every entry as revision 3 did.
1499
+ const deriveBridgeConsent = (data, root, deps) => {
1500
+ try {
1501
+ const bundleDeps = {
1502
+ ...deps,
1503
+ readdir: cacheBundleReader(deps.readdir ?? readdirSync),
1504
+ readFile: cacheBundleReader(deps.readFile ?? readFileSync),
1505
+ };
1506
+ const derived = deriveUsedBridgeTier(root, bundleDeps);
1507
+ const allow = getAllowEntries(data);
1508
+ const excluded = Array.isArray(data?.sandbox?.excludedCommands) ? data.sandbox.excludedCommands : [];
1509
+ const proven = derived.used.map(({ bridge, roles }) => ({
1510
+ bridge,
1511
+ roles: roles.filter((role) => {
1512
+ const wrapper = wrapperCmdFor(bridge, role);
1513
+ const exclusion = exclusionOf(wrapper);
1514
+ if (role === 'execute') return derived.recipe.excludedCommands.includes(exclusion) && excluded.includes(exclusion);
1515
+ const rule = `Bash(${wrapper} ${BRIDGE_REVIEW_MODE}:*)`;
1516
+ return derived.recipe.allow.includes(rule) && allow.includes(rule);
1517
+ }),
1518
+ })).filter(({ roles }) => roles.length);
1519
+ const allProven = JSON.stringify(proven) === JSON.stringify(derived.used);
1520
+ const recipe = allProven
1521
+ ? derived.recipe
1522
+ : bundledSandboxRecipe(proven, bundleDeps);
1523
+ const knownDirs = allProven ? derived.dirs : stateDirsOf(recipe.dirEntries, { env: derived.env, root, home: derived.home });
1524
+ const bareReview = recipe.excludedCommands
1525
+ .map((entry) => entry.slice(0, -2))
1526
+ .filter((cmd) => BRIDGE_REVIEW_WRAPPERS.includes(cmd));
1527
+ return { dirs: derived.dirs, knownDirs, hosts: recipe.hosts, excluded: [...recipe.excludedCommands, ...bareReview] };
1528
+ } catch (err) {
1529
+ return { error: err?.message ?? String(err) };
1530
+ }
1531
+ };
1532
+
1533
+ const collectSandboxWeakenings = (sources, { root, home, tmp }, tier) => {
1497
1534
  const project = sources.find((s) => s.source === SETTINGS_FILE);
1498
1535
  const projectAllow = getAllowEntries(project?.data);
1536
+ const judged = tier && !tier.error;
1537
+ const isTierKnown = (source, entry, entries) => source === SETTINGS_FILE && judged && entries.includes(entry);
1499
1538
  const isTierKnownExclusion = (source, cmd) =>
1500
- source === SETTINGS_FILE &&
1501
- BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
1502
- projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
1539
+ judged
1540
+ ? isTierKnown(source, cmd, tier.excluded)
1541
+ : source === SETTINGS_FILE && BRIDGE_REVIEW_WRAPPERS.includes(cmd) &&
1542
+ projectAllow.includes(`Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
1503
1543
  return sources.flatMap(({ source, data }) => {
1504
1544
  const sb = isJsonObject(data?.[SANDBOX_KEY]) ? data[SANDBOX_KEY] : {};
1505
1545
  const out = [];
1546
+ const addNotes = (key, entries, detail) => {
1547
+ for (const entry of entries) out.push({ source, key, entry, tierKnown: true, detail });
1548
+ };
1506
1549
  const net = isJsonObject(sb.network) ? sb.network : {};
1507
1550
  if (Array.isArray(net.allowedDomains) && net.allowedDomains.length) {
1508
- out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${net.allowedDomains.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
1551
+ const known = net.allowedDomains.filter((entry) => isTierKnown(source, entry, tier?.hosts));
1552
+ const foreign = net.allowedDomains.filter((entry) => !known.includes(entry));
1553
+ addNotes(`${SANDBOX_KEY}.network.allowedDomains`, known, 'every sandboxed command of the project can reach it');
1554
+ if (foreign.length) {
1555
+ out.push({ source, key: `${SANDBOX_KEY}.network.allowedDomains`, weakens: 'network', detail: `${foreign.length} pre-allowed domain(s) — ${HOST_HONORS_QUALIFIER}, egress to them is not gated` });
1556
+ }
1509
1557
  }
1510
1558
  const fsb = isJsonObject(sb.filesystem) ? sb.filesystem : {};
1511
1559
  // A PRESENT but unreadable declaration is reported, never assumed empty: what it would make
@@ -1516,16 +1564,24 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1516
1564
  // instead of asserting an effect it does not know.
1517
1565
  out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `the declared value is not an array (${typeof fsb.allowWrite}) — it cannot be read, so what it would make writable is UNKNOWN` });
1518
1566
  } else if (Array.isArray(fsb.allowWrite) && fsb.allowWrite.length) {
1567
+ const known = fsb.allowWrite.filter((entry) => isTierKnown(source, entry, tier?.knownDirs));
1568
+ const foreign = fsb.allowWrite.filter((entry) => !known.includes(entry));
1569
+ addNotes(`${SANDBOX_KEY}.filesystem.allowWrite`, known, 'every sandboxed command can write it');
1519
1570
  // Only the entries that RESOLVE outside the repo and $TMPDIR are a weakening at all — an entry
1520
1571
  // pointing INSIDE the repo grants nothing the red-line withholds, so reporting it would be an
1521
1572
  // over-report. All-contained ⇒ no line at all.
1522
1573
  // TWO records, never one: a resolved external path IS a weakening, an unresolvable entry is
1523
1574
  // UNVERIFIABLE, and a single line carrying both would assert an effect for entries it could
1524
- // not read — the contradiction this phase exists to remove. An array of only-unresolvable
1525
- // entries therefore yields no weakening claim at all.
1526
- const { external, unresolvable } = externalWriteEntries(fsb.allowWrite, { root, home, tmp });
1575
+ // not read. An array of only-unresolvable entries therefore yields no weakening claim at all.
1576
+ const { external, unresolvable } = externalWriteEntries(foreign, { root, home, tmp });
1527
1577
  if (external.length) {
1528
- out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${HOST_HONORS_QUALIFIER}, ${external.length} declared path(s) resolve OUTSIDE the repo and $TMPDIR and are writable: ${external.map((p) => JSON.stringify(p)).join(', ')}` });
1578
+ const covered = source === SETTINGS_FILE && judged
1579
+ ? tier.dirs.filter((dir) => foreign.some((entry) => isResolvableDeclaredEntry(entry) && entry !== dir &&
1580
+ external.includes(resolveDeclaredDir(entry, { home, root })) &&
1581
+ dirCovers(resolveDeclaredDir(entry, { home, root }), resolveDeclaredDir(dir, { home, root }))))
1582
+ : [];
1583
+ const coveringDetail = covered.map((dir) => `; covers the bridge state dir ${dir}`).join('');
1584
+ out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', detail: `${HOST_HONORS_QUALIFIER}, ${external.length} declared path(s) resolve OUTSIDE the repo and $TMPDIR and are writable: ${external.map((p) => JSON.stringify(p)).join(', ')}${coveringDetail}` });
1529
1585
  }
1530
1586
  if (unresolvable) {
1531
1587
  out.push({ source, key: `${SANDBOX_KEY}.filesystem.allowWrite`, weakens: 'fs_outside_repo', unverifiable: true, detail: `${unresolvable} declared entr(ies) could not be resolved (not a non-empty string) — what they would make writable is UNKNOWN` });
@@ -1535,17 +1591,20 @@ const collectSandboxWeakenings = (sources, { root, home, tmp }) => {
1535
1591
  out.push({ source, key: `${SANDBOX_KEY}.allowUnsandboxedCommands`, weakens: 'every sandbox red-line', detail: `${HOST_HONORS_QUALIFIER}, commands may run unsandboxed` });
1536
1592
  }
1537
1593
  if (Array.isArray(sb.excludedCommands) && sb.excludedCommands.length) {
1538
- // The bridge tier's OWN wrapper names are tier-known ONLY with the proof above (the
1539
- // --bridge-tier consent covers exactly this posture — the wrappers need network and
1540
- // genuinely run unsandboxed); then they are an informational note, never a weakening flag
1541
- // (the Decision-2 self-consistency bar). Every OTHER excluded command — hand-added names,
1542
- // local-file exclusions, tier names without their allow rules — stays a loud weakening.
1594
+ // The bridge tier's OWN exclusions are tier-known ONLY with their consent proof; then they are
1595
+ // a note, never a weakening flag. Every OTHER excluded command — hand-added names, local-file
1596
+ // exclusions, tier names without their proof — stays a loud weakening.
1543
1597
  const tierKnown = sb.excludedCommands.filter((c) => isTierKnownExclusion(source, c));
1544
1598
  const foreign = sb.excludedCommands.filter((c) => !tierKnown.includes(c));
1599
+ // A bare name matches only the argument-free command, so its note never claims real calls leave the sandbox.
1600
+ if (judged) {
1601
+ addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => c.endsWith(' *')), 'the wrapper runs outside the sandbox');
1602
+ addNotes(`${SANDBOX_KEY}.excludedCommands`, tierKnown.filter((c) => !c.endsWith(' *')), 'only its argument-free invocation runs outside the sandbox');
1603
+ }
1545
1604
  if (foreign.length) {
1546
- out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
1547
- } else if (tierKnown.length) {
1548
- out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER}, the consented bridge-wrappers tier runs them outside the sandbox (network), and its allow rules are present in the project settings` });
1605
+ out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: 'every sandbox red-line', detail: `${foreign.length} command(s) run UNSANDBOXED ${HOST_HONORS_QUALIFIER} (network/fs confinement not applied to them)${!judged && tierKnown.length ? `; ${tierKnown.length} bridge-review wrapper exclusion(s) are tier-known and not flagged` : ''}` });
1606
+ } else if (!judged && tierKnown.length) {
1607
+ out.push({ source, key: `${SANDBOX_KEY}.excludedCommands`, weakens: null, tierKnown: true, detail: `${tierKnown.length} bridge-review wrapper exclusion(s) (${tierKnown.join(', ')}) — tier-known: ${HOST_HONORS_QUALIFIER}, only their argument-free invocations run outside the sandbox, and their allow rules are present in the project settings` });
1549
1608
  }
1550
1609
  }
1551
1610
  return out;
@@ -1614,8 +1673,10 @@ export const formatAutonomyResult = (r) => {
1614
1673
  for (const b of r.redlineBypass ?? []) {
1615
1674
  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.`);
1616
1675
  }
1676
+ if (r.bridgeTierError) lines.push(` note: bridge-tier entries not judged — ${r.bridgeTierError}`);
1617
1677
  for (const w of r.sandboxWeakenings ?? []) {
1618
- if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1678
+ if (w.tierKnown && hasOwn(w, 'entry')) lines.push(` note: ${w.source} has ${w.key} entry ${JSON.stringify(w.entry)} — tier-known: part of the bridge-wrappers tier consent; ${HOST_HONORS_QUALIFIER}, ${w.detail}.`);
1679
+ else if (w.tierKnown) lines.push(` note: ${w.source} has ${w.key} (${w.detail}).`);
1619
1680
  else if (w.unverifiable) lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}) — the rendered ${w.weakens} red-line CANNOT BE VERIFIED against it (no claim either way); fix the declared value by hand, then re-run this preview.`);
1620
1681
  else lines.push(` ⚠ DEGRADE: ${w.source} has ${w.key} (${w.detail}), which WEAKENS the rendered ${w.weakens} red-line — the render preserves your sandbox tuning (never clobbers it), so remove it by hand if you want the red-line fully enforced.`);
1621
1682
  }
@@ -1663,11 +1724,12 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1663
1724
  const redlineBypass = collectRedlineBypass(settingsSources);
1664
1725
  // The allowWrite degrade resolves its entries before judging them, so it needs the same anchors a
1665
1726
  // host resolving the key would use: the project root, the resolved home, and $TMPDIR.
1727
+ const bridgeConsent = deriveBridgeConsent(preflight.projectSettings?.data, projectDir, deps);
1666
1728
  const sandboxWeakenings = collectSandboxWeakenings(settingsSources, {
1667
1729
  root: projectDir,
1668
1730
  home: deps.home ?? homedir(),
1669
1731
  tmp: (deps.env ?? process.env).TMPDIR ?? tmpdir(),
1670
- });
1732
+ }, bridgeConsent);
1671
1733
  const resultBase = {
1672
1734
  autonomy: true,
1673
1735
  source,
@@ -1677,6 +1739,7 @@ export const writeAutonomyProfile = ({ cwd, apply = false } = {}, deps = {}) =>
1677
1739
  localMasks,
1678
1740
  redlineBypass,
1679
1741
  sandboxWeakenings,
1742
+ bridgeTierError: bridgeConsent.error,
1680
1743
  stamp: preflight.stamp,
1681
1744
  stampOk: preflight.stampOk,
1682
1745
  };
@@ -1,19 +1,20 @@
1
1
  import { join, resolve } from 'node:path';
2
2
  import { dirCovers } from './declared-paths.mjs';
3
- import { claudeDirOf, skillTargets } from './jev-facts.mjs';
3
+ import { claudeDirOf, quoteArg, skillTargets } from './jev-facts.mjs';
4
4
  import { targetsFor } from './jev-connect.mjs';
5
5
 
6
- export const LANE_CONSOLE = 'console';
7
6
  export const LANE_CHAT = 'chat';
7
+ export const LANE_HARNESS = 'harness';
8
+ export const LANE_USER = 'user';
8
9
 
9
10
  export const VARIANT_LANES = Object.freeze({
10
- 'velocity-core': LANE_CONSOLE,
11
- 'kit-tools-tier': LANE_CONSOLE,
12
- 'bridge-tier': LANE_CONSOLE,
11
+ 'velocity-core': LANE_HARNESS,
12
+ 'kit-tools-tier': LANE_HARNESS,
13
+ 'bridge-tier': LANE_HARNESS,
13
14
  'autonomy-policy': LANE_CHAT,
14
- 'autonomy-render': LANE_CONSOLE,
15
- 'sandbox-provision': LANE_CONSOLE,
16
- 'sandbox-provision.installable': LANE_CONSOLE,
15
+ 'autonomy-render': LANE_HARNESS,
16
+ 'sandbox-provision': LANE_USER,
17
+ 'sandbox-provision.installable': LANE_USER,
17
18
  'review-recipe': LANE_CHAT,
18
19
  'gates-declaration': LANE_CHAT,
19
20
  'gates-inert': LANE_CHAT,
@@ -24,19 +25,19 @@ export const VARIANT_LANES = Object.freeze({
24
25
  'source-size.unminted': LANE_CHAT,
25
26
  'source-size.adopted-elsewhere': LANE_CHAT,
26
27
  'source-size.id-squatter': LANE_CHAT,
27
- 'gate-hook': LANE_CONSOLE,
28
- 'gate-hook.marker-stale': LANE_CONSOLE,
29
- 'commit-guard': LANE_CONSOLE,
30
- enforcement: LANE_CONSOLE,
28
+ 'gate-hook': LANE_HARNESS,
29
+ 'gate-hook.marker-stale': LANE_USER,
30
+ 'commit-guard': LANE_USER,
31
+ enforcement: LANE_USER,
31
32
  'read-lane': LANE_CHAT,
32
- 'read-lane.stale': LANE_CONSOLE,
33
- 'read-lane.missing': LANE_CONSOLE,
34
- 'state-block': LANE_CONSOLE,
35
- 'mcp-channel': LANE_CONSOLE,
36
- 'mcp-channel.differing': LANE_CONSOLE,
37
- agents: LANE_CONSOLE,
38
- 'executor-vehicle': LANE_CONSOLE,
39
- 'family-freshness': LANE_CONSOLE,
33
+ 'read-lane.stale': LANE_USER,
34
+ 'read-lane.missing': LANE_HARNESS,
35
+ 'state-block': LANE_USER,
36
+ 'mcp-channel': LANE_HARNESS,
37
+ 'mcp-channel.differing': LANE_HARNESS,
38
+ agents: LANE_HARNESS,
39
+ 'executor-vehicle': LANE_HARNESS,
40
+ 'family-freshness': LANE_USER,
40
41
  'adr-store-migration': LANE_CHAT,
41
42
  'sandbox-masks': LANE_CHAT,
42
43
  'sandbox-masks.unfenced-mount': LANE_CHAT,
@@ -44,9 +45,9 @@ export const VARIANT_LANES = Object.freeze({
44
45
  'worktrees-dir': LANE_CHAT,
45
46
  'spec-adoption': LANE_CHAT,
46
47
  'profile-gap': LANE_CHAT,
47
- 'jev-connect': LANE_CONSOLE,
48
- 'jev-skill': LANE_CONSOLE,
49
- 'jev-skill.earlier': LANE_CONSOLE,
48
+ 'jev-connect': LANE_USER,
49
+ 'jev-skill': LANE_HARNESS,
50
+ 'jev-skill.earlier': LANE_HARNESS,
50
51
  'spec-adoption.adopting': LANE_CHAT,
51
52
  });
52
53
 
@@ -78,8 +79,6 @@ export const isProtectedPath = (abs, { root, home, hooksDir }) => {
78
79
  || [...PROTECTED_DIRS.map((dir) => join(project, dir)), hooks].some((dir) => dirCovers(dir, path));
79
80
  };
80
81
 
81
- const ROUTE = "applies at your next npx @sabaiway/agent-workflow-kit@latest init, run from this project's folder";
82
- const RESTART = '; if init reports it not pending, restart the agent from that console';
83
82
  const SETTINGS_WRITES = [SETTINGS_PATH];
84
83
  const HOOK_WRITES = [join('.claude', 'hooks', 'agent-workflow-gates.mjs'), ...SETTINGS_WRITES];
85
84
  const MCP_WRITES = ['.mcp.json', ...SETTINGS_WRITES];
@@ -98,12 +97,16 @@ const getStartupPaths = ({ env, home, platform }) => targetsFor({
98
97
  platform,
99
98
  }).filter(({ path }) => path !== null).map(({ path }) => path);
100
99
 
101
- const createForm = (file, preview, apply, writes, restart = false) => Object.freeze({
100
+ const formatCommand = (argv) => argv.map(quoteArg).join(' ');
101
+ const TERMINAL_STEP = 'run in a terminal of your own: ';
102
+
103
+ const createForm = (file, preview, apply, writes, envDependent = false, userStep = null) => Object.freeze({
102
104
  file,
103
105
  preview,
104
106
  apply,
105
107
  writes,
106
- route: restart ? ROUTE + RESTART : ROUTE,
108
+ envDependent,
109
+ userStep,
107
110
  });
108
111
 
109
112
  const CORE_FORM = createForm('velocity-profile.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], SETTINGS_WRITES);
@@ -126,11 +129,17 @@ const FORMS = Object.freeze({
126
129
  ['--replace', '--cwd', '<root>'], ['--replace', '--apply', '--cwd', '<root>'], MCP_WRITES),
127
130
  agents: AGENT_FORM,
128
131
  'executor-vehicle': AGENT_FORM,
129
- 'sandbox-provision.installable': createForm('autonomy-doctor.mjs', [], ['--apply', '<tuple>'], [], true),
132
+ 'sandbox-provision.installable': createForm('autonomy-doctor.mjs', [], ['--apply', '<tuple>'], [], true,
133
+ (facts, argv) => 'your own step (a sudo install; the doctor prints the --apply line to run next) — '
134
+ + `${TERMINAL_STEP}cd ${quoteArg(facts.root)} && ${formatCommand(argv.preview)}`),
130
135
  'jev-skill': SKILL_FORM,
131
136
  'jev-skill.earlier': SKILL_FORM,
132
- 'jev-connect': createForm('jev-connect.mjs', null, [], getStartupPaths, true),
133
- 'family-freshness': { route: ROUTE },
137
+ 'jev-connect': createForm('jev-connect.mjs', null, [], getStartupPaths, true,
138
+ (facts, argv) => 'your own step — it asks for the key in your terminal and the key never goes into the chat — '
139
+ + `${TERMINAL_STEP}${formatCommand(argv.apply)}`),
140
+ 'family-freshness': Object.freeze({
141
+ userStep: () => `your own step — ${TERMINAL_STEP}${formatCommand(['npx', '@sabaiway/agent-workflow-kit@latest', 'init'])}`,
142
+ }),
134
143
  'sandbox-provision': null,
135
144
  'commit-guard': null,
136
145
  'gate-hook.marker-stale': null,
@@ -165,7 +174,22 @@ export const writesOf = (variant, facts) => {
165
174
  return form.writes.map((path) => join(facts.root, path));
166
175
  };
167
176
 
168
- export const routeLine = (variant) => {
177
+ export const isEnvDependent = (variant) => {
169
178
  laneOf(variant);
170
- return FORMS[variant]?.route ?? null;
179
+ return FORMS[variant]?.envDependent === true;
180
+ };
181
+
182
+ export const applySlot = (variant, facts) => {
183
+ const lane = laneOf(variant);
184
+ const form = FORMS[variant];
185
+ if (lane === LANE_HARNESS) {
186
+ const args = ['node', join(facts.toolsDir, 'apply-danger-check.mjs'), '--variant', variant, '--cwd', facts.root];
187
+ const skillArgs = form === SKILL_FORM ? form.preview.map((arg) => resolveArgument(arg, facts)) : [];
188
+ const check = formatCommand([...args, ...skillArgs]);
189
+ return { check, apply: `${check} --apply --expect <digest>` };
190
+ }
191
+ if (lane === LANE_USER && form?.userStep) {
192
+ return { check: null, apply: form.userStep(facts, consoleArgv(variant, facts)) };
193
+ }
194
+ return null;
171
195
  };