@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.
@@ -0,0 +1,94 @@
1
+ // bridge-state-dirs.mjs — the one leaf that turns the bundled bridges' declared writable state dirs
2
+ // (`writableDirs`) into `sandbox.filesystem.allowWrite` entries (contract: bridges/velocity-profile).
3
+ // The bridge tier writes them, the advisor counts them, the danger check admits them, the autonomy
4
+ // preview names them: one resolution, one home-relative spelling, one merge, so the four cannot drift.
5
+ //
6
+ // A LEAF: imports declared-paths.mjs and node:path only. No side effects on import. Dependency-free,
7
+ // Node >= 22.
8
+
9
+ import { dirname, relative, resolve } from 'node:path';
10
+ import { dirCovers, isResolvableDeclaredEntry, resolveDeclaredDir } from './declared-paths.mjs';
11
+
12
+ // D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
13
+ // `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
14
+ // never resolve as a home path — anchors like a relative path). Anchored to the TARGET PROJECT ROOT,
15
+ // matching what a wrapper invoked from the project root resolves.
16
+ export const resolveWritableDir = (entry, { env, root }) => {
17
+ const value = entry.env == null ? '' : (env[entry.env] ?? '');
18
+ if (value === '') return entry.default;
19
+ if (value === '~' || value.startsWith('~/') || value.startsWith('/')) return value;
20
+ return resolve(root, value);
21
+ };
22
+
23
+ // The HOME-SYMBOLIC spelling: tilde forms stay symbolic, an absolute dir under the home (by path
24
+ // segment, never a string prefix) folds back to `~/…`, anything outside the home stays absolute.
25
+ export const homeRelative = (dir, home) => {
26
+ if (dir === '~') return '~';
27
+ if (dir.startsWith('~/')) return `~/${dir.slice(2)}`;
28
+ const homeAbs = resolve(home);
29
+ const abs = resolve(dir);
30
+ if (abs === homeAbs) return '~';
31
+ return dirCovers(homeAbs, abs) ? `~/${relative(homeAbs, abs)}` : abs;
32
+ };
33
+
34
+ const ancestorsOf = (dir) => {
35
+ const out = [];
36
+ for (let at = dirname(dir); at !== dir; dir = at, at = dirname(at)) out.push(at);
37
+ return out;
38
+ };
39
+
40
+ // A grant on `/`, on the home, or on an ancestor of the home or of the root would hand every
41
+ // sandboxed command the user's whole tree — never a bridge's state dir. The override that produced
42
+ // it is named, so the user knows which variable to fix.
43
+ const dangerOf = (abs, { home, root }) => {
44
+ const homeAbs = resolve(home);
45
+ if (abs === '/') return 'the filesystem root';
46
+ if (abs === homeAbs) return 'the home directory';
47
+ if (ancestorsOf(homeAbs).includes(abs)) return 'an ancestor of the home directory';
48
+ if (ancestorsOf(resolve(root)).includes(abs)) return 'an ancestor of the project root';
49
+ return null;
50
+ };
51
+
52
+ export const stateDirsOf = (dirEntries, { env, root, home }) => {
53
+ const dirs = [];
54
+ for (const entry of dirEntries) {
55
+ const raw = resolveWritableDir(entry, { env, root });
56
+ const danger = dangerOf(resolveDeclaredDir(raw, { home, root }), { home, root });
57
+ if (danger !== null) {
58
+ const source = entry.env == null ? 'its declared default' : entry.env;
59
+ throw new Error(`writable state dir "${raw}" from ${source} resolves to ${danger} — refusing to grant it; point ${source} at the bridge's own state dir`);
60
+ }
61
+ const rendered = homeRelative(raw, home);
62
+ if (!dirs.includes(rendered)) dirs.push(rendered);
63
+ }
64
+ return dirs;
65
+ };
66
+
67
+ // The dirs no existing allowWrite entry covers (resolved both sides, containment by path segment).
68
+ export const missingStateDirs = (dirs, allowWrite, { home, root }) => {
69
+ const existing = (Array.isArray(allowWrite) ? allowWrite : [])
70
+ .filter(isResolvableDeclaredEntry)
71
+ .map((entry) => resolveDeclaredDir(entry, { home, root }));
72
+ return dirs.filter((dir) => {
73
+ const abs = resolveDeclaredDir(dir, { home, root });
74
+ return !existing.some((container) => dirCovers(container, abs));
75
+ });
76
+ };
77
+
78
+ // The ONE writer of `sandbox.filesystem.allowWrite`: the other sandbox and filesystem sub-keys stay
79
+ // where they are, each dir not already present appends once. Pure: returns a new object, and with
80
+ // nothing to add returns the input unchanged (no sandbox key is created for nothing).
81
+ export const mergeAllowWrite = (settings, dirs) => {
82
+ const base = settings ?? {};
83
+ const existing = Array.isArray(base.sandbox?.filesystem?.allowWrite) ? base.sandbox.filesystem.allowWrite : [];
84
+ const toAppend = [];
85
+ for (const dir of dirs) if (!existing.includes(dir) && !toAppend.includes(dir)) toAppend.push(dir);
86
+ if (toAppend.length === 0) return base;
87
+ return {
88
+ ...base,
89
+ sandbox: {
90
+ ...base.sandbox,
91
+ filesystem: { ...base.sandbox?.filesystem, allowWrite: [...existing, ...toAppend] },
92
+ },
93
+ };
94
+ };
@@ -4,15 +4,16 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { checkApply } from './apply-danger-check.mjs';
7
8
  import { resolveGitHooksPath } from './commit-guard.mjs';
8
9
  import { dirCovers } from './declared-paths.mjs';
9
10
  import { isDirectRun } from './direct-run.mjs';
10
- import { GIT_MAX_BUFFER } from './git-env.mjs';
11
+ import { GIT_MAX_BUFFER, stripGitLocationEnv } from './git-env.mjs';
11
12
  import { preflightInit } from './init-preflight.mjs';
12
13
  import { buildRecommendations, hostFacts } from './recommendations.mjs';
13
14
  import { compareSemver } from './semver-lite.mjs';
14
15
  import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
15
- import { LANE_CONSOLE, VARIANT_LANES, consoleArgv, isProtectedPath, laneOf, routeLine, writesOf } from './write-lanes.mjs';
16
+ import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, laneOf, writesOf } from './write-lanes.mjs';
16
17
 
17
18
  const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
18
19
  const APPLY_PROMPT = 'apply? y/N: ';
@@ -96,29 +97,21 @@ const readState = (path, io) => {
96
97
  if (stat.isSymbolicLink()) return `link:${io.readlink(path)}:${followedState(path, io)}`;
97
98
  return statState(path, stat, io);
98
99
  };
99
- const stateOf = (path, io) => {
100
+ const readStateOrElse = (path, io, onError) => {
100
101
  try {
101
102
  return readState(path, io);
102
103
  } catch (error) {
103
- throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
104
+ return onError(error);
104
105
  }
105
106
  };
107
+ const stateOf = (path, io) => readStateOrElse(path, io, (error) => {
108
+ throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
109
+ });
106
110
  const recordPaths = (paths, io) => new Map([...new Set(paths)].map((path) => [path, stateOf(path, io)]));
107
- const findChanged = (record, io) => [...record].find(([path, state]) => {
108
- try {
109
- return readState(path, io) !== state;
110
- } catch {
111
- return true;
112
- }
113
- })?.[0];
111
+ const findChanged = (record, io) => [...record].find(([path, state]) =>
112
+ readStateOrElse(path, io, () => null) !== state)?.[0];
114
113
  // After an apply a path that cannot be read is recorded as such, so the next comparison stops the batch on it.
115
- const refreshState = (path, io) => {
116
- try {
117
- return readState(path, io);
118
- } catch (error) {
119
- return `unreadable:${error.code ?? error.message}`;
120
- }
121
- };
114
+ const refreshState = (path, io) => readStateOrElse(path, io, (error) => `unreadable:${error.code ?? error.message}`);
122
115
  const DIRECTORY = 'directory:';
123
116
  const entriesOf = (state) => (state === 'absent' ? [] : JSON.parse(state.slice(DIRECTORY.length)));
124
117
  // A recorded parent moves only by the written entry, so an entry created or removed beside it still stops the batch.
@@ -179,16 +172,13 @@ const reachesQueue = (variant, facts) => {
179
172
  const isMasked = (variant, context) => (writesOf(variant, context.facts) ?? [])
180
173
  .some((write) => [...context.masks].some((mask) => dirCovers(write, mask)));
181
174
 
182
- // context.items keeps the console items of the advisor's latest return, before the queue's filters.
183
175
  const getQueue = async (context) => {
184
- const { items } = await context.recommend(context.facts.root, { keySet: context.keySet });
185
- context.items = items.filter(({ variant }) => laneOf(variant) === LANE_CONSOLE);
176
+ context.items = (await context.recommend(context.facts.root, { keySet: context.keySet })).items;
186
177
  return context.items.filter(({ variant }) => reachesQueue(variant, context.facts) && !isMasked(variant, context));
187
178
  };
188
179
 
189
180
  const printNotPending = (context) => {
190
- const plain = routeLine('velocity-core');
191
- const variants = Object.keys(VARIANT_LANES).filter((variant) => laneOf(variant) === LANE_CONSOLE && routeLine(variant)?.length > plain.length);
181
+ const variants = Object.keys(VARIANT_LANES).filter(isEnvDependent);
192
182
  const keys = new Set();
193
183
  for (const variant of variants) {
194
184
  const key = variant.split('.')[0];
@@ -227,6 +217,16 @@ const previewQueue = async (context, queue) => {
227
217
  context.print(result.stdout);
228
218
  const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
229
219
  const facts = { ...context.facts, tuple };
220
+ if (laneOf(item.variant) === LANE_HARNESS) {
221
+ try {
222
+ const { lines, refused } = context.checkApply(item.variant, facts, {});
223
+ for (const line of lines) context.print(line);
224
+ if (refused.length > 0) throw new Error(refused.map(({ key }) => key).join(', '));
225
+ } catch (error) {
226
+ context.print(`not applied: ${item.variant} — the danger check refused: ${error.message}`);
227
+ continue;
228
+ }
229
+ }
230
230
  batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
231
231
  printNote(context, item.key);
232
232
  }
@@ -295,7 +295,7 @@ const runPass = async (context, queue) => {
295
295
  const selectRoot = async (context) => {
296
296
  const { candidates } = findProject({ cwd: context.cwd, lstat: context.io.lstat });
297
297
  if (candidates.length === 0) {
298
- context.print("no agent-workflow project here — the project items apply when init runs from that project's folder");
298
+ context.print('no agent-workflow project here — run /agent-workflow-kit upgrade in your agent from that project, or deploy it there first: its yes applies the project items');
299
299
  return { root: null };
300
300
  }
301
301
  if (candidates.length === 1) return candidates[0];
@@ -317,7 +317,7 @@ const gateStamp = (candidate, context) => {
317
317
  if (compareSemver(stamp, EXPECTED_WORKFLOW_VERSION) === 1) {
318
318
  context.print(`this project's stamp ${stamp} is ahead of this kit's lineage ${EXPECTED_WORKFLOW_VERSION} — update the kit, then init again`);
319
319
  } else {
320
- context.print('run /agent-workflow-kit upgrade, then init again');
320
+ context.print('run /agent-workflow-kit upgrade in your agent: its yes applies the items');
321
321
  }
322
322
  return null;
323
323
  };
@@ -334,10 +334,8 @@ export const runInitProject = async (deps) => {
334
334
  print(INSIDE_LINE);
335
335
  return { code: 0 };
336
336
  }
337
- const spawnEnv = { ...deps.env };
338
- delete spawnEnv.GIT_DIR;
339
- delete spawnEnv.GIT_WORK_TREE;
340
- const context = { ...deps, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
337
+ const spawnEnv = stripGitLocationEnv(deps.env);
338
+ const context = { ...deps, checkApply: deps.checkApply ?? checkApply, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
341
339
  const candidate = await selectRoot(context);
342
340
  if (candidate === null) return { code: 0 };
343
341
  if (!preflightInit({ ...deps, root: candidate.root, io }).ok) {
@@ -4,8 +4,8 @@
4
4
  // their own: it reads the key with no echo, verifies it with one request, saves it where the shell
5
5
  // reads it (bash, zsh, fish) or in the Windows user environment, and prints the restart step. It never
6
6
  // prints the key, never puts it in an argv, and refuses when stdin is not a terminal, so a
7
- // non-interactive run (the agent's) cannot reach the prompt — the mode doc keeps the rule that the
8
- // agent never runs it. It claims no connection: the guide's key mark, read after the restart, is the proof.
7
+ // non-interactive run cannot reach the prompt. The key never reaches the agent or the chat.
8
+ // It claims no connection: the guide's key mark, read after the restart, is the proof.
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
@@ -45,11 +45,11 @@ export const JEV_TEXT = Object.freeze([
45
45
  ]);
46
46
 
47
47
  const KEY_GET = 'Get a key at console.typesafe.ai and set it on every host the agent runs on.';
48
- const KEY_CONNECT = 'Connect it from a terminal of your own, never through the agent: the command asks for the key with no echo, checks it with one request and saves it for bash, zsh or fish in your shell\'s startup files, or on Windows as a user environment variable.';
48
+ const KEY_CONNECT = 'Connect it from a terminal of your own — the key never reaches the agent or the chat: the command asks for the key with no echo, checks it with one request and saves it for bash, zsh or fish in your shell\'s startup files, or on Windows as a user environment variable.';
49
49
  const KEY_RESTART = `Then ${RESTART_STEP}. Run /agent-workflow-kit jev again: the key mark should read set.`;
50
50
  const KEY_NEVER = 'Never paste the key into the chat or into a project file.';
51
51
  const KEY_NOT_SET = `key: ${KEY_VARIABLE} not set. A host setting that filters the environment of the agent's commands also hides it. If it still reads not set after that restart and no such setting applies, run the connect line again and follow its last line.`;
52
- const SKILL_OPTIONAL = 'Optional, for your own code and prompts — the vendor skill. The kit installs it pinned, one verified copy per agent skill root (Codex, Claude Code, Antigravity CLI); after your yes in the chat, run this in a terminal of your own:';
52
+ const SKILL_OPTIONAL = 'Optional, for your own code and prompts — the vendor skill. The kit installs it pinned, one verified copy per agent skill root (Codex, Claude Code, Antigravity CLI); if Recommendations offers this item, the agent applies it on your chat yes after its danger check; you can also run this yourself in a terminal of your own:';
53
53
  const NO_HOME = 'no home directory found: the kit\'s install needs one';
54
54
  const INSTALL = [
55
55
  'Or the vendor\'s own routes:',
@@ -100,15 +100,16 @@ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (hel
100
100
  `networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
101
101
  `*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
102
102
  `capability.json`) — the **single source** of the hosts `velocity-profile --bridge-tier` seeds
103
- into the project's `sandbox.network.allowedDomains` for every placed bridge, and of a hand-applied
103
+ into the project's `sandbox.network.allowedDomains` for every used bridge, and of a hand-applied
104
104
  session sandbox allowlist. Rules:
105
105
 
106
106
  - Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
107
107
  Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
108
108
  into allowlist lines by the Recommendations advisor).
109
- - **The `--bridge-tier` seed is the primary lane** (AD-172): beside the wrappers' exclusion it
110
- merges the placed bridges' hosts into the project's `sandbox.network.allowedDomains`, so a
111
- settings-native harness that drops the exclusion still admits them; that pre-allow widens egress
109
+ - **The `--bridge-tier` seed is the primary lane** (AD-172): for every used bridge it merges
110
+ hosts into the project's `sandbox.network.allowedDomains` beside the wrapper's `<wrapper> *`
111
+ exclusion (`codex-exec *` for the execution wrapper). A settings-native harness that drops the
112
+ exclusion still admits them; that pre-allow widens egress
112
113
  for **every** sandboxed command, which the tier's consent states. The **hand-apply** fallback
113
114
  stays for a harness-managed sandbox that ignores the settings keys.
114
115
  - Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
@@ -132,8 +133,9 @@ declared path is only the **default under no override**. Rules:
132
133
  relative path. The advisor anchors those to the **target project root** (its pinned `--cwd`),
133
134
  matching what a wrapper invoked from the project root resolves — the documented dispatch form
134
135
  (the wrapper itself anchors to its invocation `$PWD`); else the `default` applies.
135
- - Unlike `networkHosts`, this is a **documentation source** only: the kit never seeds
136
- `sandbox.filesystem.allowWrite` or any other filesystem allowance — the entries feed the
136
+ - **The `--bridge-tier` seeds every used bridge's state dirs**, home-relative under the home
137
+ (absolute outside it), into `sandbox.filesystem.allowWrite` beside the wrapper's `<wrapper> *`
138
+ exclusion (`codex-exec *` for the execution wrapper) — the entries feed the
137
139
  sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
138
140
 
139
141
  ## Mode catalog (BRIDGE-MODES-CATALOG)
@@ -19,9 +19,10 @@
19
19
  // below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
20
20
  // Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
21
21
  // risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
22
- // skipped-item line — never a crash, never a fabricated item. sandbox.filesystem.allowWrite stays
23
- // HAND-APPLY territory; sandbox.network.allowedDomains is seeded only by the consented
24
- // `velocity-profile --bridge-tier` (AD-172); the sandbox-lane item's convergence is a NEUTRAL
22
+ // skipped-item line — never a crash, never a fabricated item. The consented
23
+ // `velocity-profile --bridge-tier` seeds used bridges' allow rules, <wrapper> * exclusions
24
+ // (codex-exec * where an execute slot delegates), hosts in sandbox.network.allowedDomains and
25
+ // state dirs in sandbox.filesystem.allowWrite; the sandbox-lane item's convergence is a NEUTRAL
25
26
  // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
26
27
  // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
27
28
  //
@@ -49,7 +50,7 @@ import {
49
50
  } from './velocity-profile.mjs';
50
51
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
51
52
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
52
- import { laneOf, routeLine } from './write-lanes.mjs';
53
+ import { laneOf, applySlot } from './write-lanes.mjs';
53
54
  import { detectBackends, findOnPath } from './detect-backends.mjs';
54
55
  import { isDirectRun } from './direct-run.mjs';
55
56
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
@@ -80,6 +81,7 @@ import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './decl
80
81
  import { resolveGitHooksPath } from './commit-guard.mjs';
81
82
  import { loadConfig } from './orchestration-config.mjs';
82
83
  import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
84
+ import { homeRelative, resolveWritableDir } from './bridge-state-dirs.mjs';
83
85
  import { assertContainedRealPath } from './fs-safe.mjs';
84
86
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
87
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
@@ -248,7 +250,7 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
248
250
  export const WHATS = Object.freeze({
249
251
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
250
252
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
251
- 'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, sandbox exclusions or declared hosts of placed bridges)',
253
+ 'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, exclusions, hosts or state dirs of used bridges)',
252
254
  'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
253
255
  'autonomy-render': 'the declared autonomy policy is not rendered into .claude/settings.json — drift: {drift}',
254
256
  'sandbox-provision': 'the OS sandbox is unavailable: {reason}',
@@ -324,7 +326,7 @@ export const BENEFITS = Object.freeze({
324
326
  'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away',
325
327
  'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
326
328
  'bridge-tier':
327
- 'velocity — placed review wrappers run code-mode council reviews promptless (plan/diff modes and delegated execution keep their prompt)',
329
+ 'velocity — used bridges get <wrapper> * exclusions (codex-exec * if delegated), hosts, state dirs (allowWrite); reviews: code-mode allows',
328
330
  'autonomy-policy': 'clarity — the per-activity autonomy policy becomes an explicit, versioned declaration instead of implicit computed defaults',
329
331
  'autonomy-render': `velocity — confined commands auto-allow per your declared policy; ${DUAL_SECURITY_BENEFIT}`,
330
332
  'sandbox-provision': `velocity — confined ad-hoc commands stop prompting; ${DUAL_SECURITY_BENEFIT}`,
@@ -409,9 +411,9 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
409
411
  // state that makes a configured subagent carrier run solo.
410
412
  { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
411
413
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
412
- // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
413
- // same doctrine that keeps sandbox filesystem allowances HAND-APPLY); velocity presents the
414
- // full honest posture at its own consent moment, where the user is already deciding.
414
+ // a TRUST-POSTURE change. Velocity presents its full posture at its own consent moment;
415
+ // the bridge tier separately seeds used bridges' allow rules, <wrapper> * exclusions
416
+ // (codex-exec * where delegated), hosts and state dirs (allowWrite).
415
417
  {
416
418
  id: 'accept-edits',
417
419
  mode: 'velocity',
@@ -460,8 +462,12 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
460
462
  skip('kit-tools-tier', err);
461
463
  }
462
464
  try {
463
- const bt = planVelocityProfile(preflight, { bridgeTier: true, findWrapper: deps.findWrapper, bundleRoot: deps.bundleRoot, readdir: deps.readdir, readFile: deps.readFile });
464
- const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length + bt.hostsToAdd.length;
465
+ const bt = planVelocityProfile(preflight, {
466
+ bridgeTier: true, findWrapper: deps.findWrapper,
467
+ bundleRoot: deps.bundleRoot, readdir: deps.readdir, readFile: deps.readFile,
468
+ detect: deps.detect, env: deps.getenv ?? deps.env, home: deps.home,
469
+ });
470
+ const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length + bt.hostsToAdd.length + bt.dirsToAdd.length;
465
471
  if (delta > 0) {
466
472
  add('bridge-tier', fillTemplate(WHATS['bridge-tier'], { n: delta }), applyLine(' --bridge-tier'));
467
473
  }
@@ -1159,18 +1165,6 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
1159
1165
  // knob is retired in the wrappers (recognized, arms nothing) and an oversized code review is now a
1160
1166
  // chunked feed with a per-part delivery proof, so there is nothing left to offer.
1161
1167
 
1162
- // D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
1163
- // `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
1164
- // never resolve as a home path — anchors like a relative path). The advisor anchors to the TARGET
1165
- // PROJECT ROOT (the pinned --cwd), matching what a wrapper invoked from the project root resolves
1166
- // (the documented dispatch form; the wrapper itself anchors to its invocation $PWD).
1167
- const resolveWritableDir = (entry, { env, root }) => {
1168
- const value = entry.env == null ? '' : (env[entry.env] ?? '');
1169
- if (value === '') return entry.default;
1170
- if (value === '~' || value.startsWith('~/') || value.startsWith('/')) return value;
1171
- return resolve(root, value);
1172
- };
1173
-
1174
1168
  // The NEUTRAL recipe fingerprint (D4): a hash over the resolved hosts ∪ dirs data — an
1175
1169
  // acknowledgement token, never a security key. Canonical form is HOME-SYMBOLIC: an
1176
1170
  // absolute dir under the resolved home canonicalizes BACK to its `~/…` form and tilde forms stay
@@ -1179,14 +1173,7 @@ const resolveWritableDir = (entry, { env, root }) => {
1179
1173
  // churns between them); only a genuinely-outside-home absolute override stays absolute
1180
1174
  // (machine-specific by nature). Any change to the recipe re-fires the item.
1181
1175
  export const recipeFingerprint = ({ hosts, dirs, home }) => {
1182
- const homeAbs = resolve(home);
1183
- const norm = (d) => {
1184
- if (d === '~') return '~';
1185
- if (d.startsWith('~/')) return `~/${d.slice(2)}`;
1186
- const abs = resolve(d);
1187
- if (abs === homeAbs) return '~';
1188
- return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
1189
- };
1176
+ const norm = (dir) => homeRelative(dir, home);
1190
1177
  return factFingerprint(JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() }));
1191
1178
  };
1192
1179
 
@@ -1346,14 +1333,14 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
1346
1333
  // Wired = the two-surface proof the tier wrote before AD-172 (the wrapper in the PROJECT
1347
1334
  // excludedCommands + its code-mode allow rule, either scope) — surfacing the recipe before those
1348
1335
  // exist would front-run the bridge-tier item (codex terminal). The hosts surface (AD-172) is
1349
- // deliberately not required: the recipe also carries the writable dirs no tier seeds, so it may
1350
- // render beside a bridge-tier item offering the hosts re-run. Byte-form from the tier's own constants.
1336
+ // deliberately not required: hosts and state dirs may still be missing, so the recipe may
1337
+ // render beside a bridge-tier item offering the re-run. Byte-form from the tier's own constants.
1351
1338
  const allowRules = [
1352
1339
  ...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
1353
1340
  ...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
1354
1341
  ];
1355
1342
  const wired = BRIDGE_REVIEW_WRAPPERS.filter(
1356
- (w) => excluded.includes(w) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1343
+ (w) => (excluded.includes(w) || excluded.includes(`${w} *`)) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1357
1344
  );
1358
1345
  if (wired.length === 0) return; // the tier's rules and exclusions are not wired — the bridge-tier item covers first
1359
1346
  const { hosts, dirEntries } = bundledSandboxRecipe(wired, deps);
@@ -1574,7 +1561,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1574
1561
  const host = deps.jevHost ?? {};
1575
1562
  const place = placeOf({ env, platform: host.platform, hostname: host.hostname, container: host.container });
1576
1563
  const decline = `node ${q(toolPath('ack-write.mjs'))} --lane jev-connect --fingerprint ${JEV_CONNECT_DECLINE} --cwd ${q(root)}`;
1577
- add('jev-connect', WHATS['jev-connect'], routeLine('jev-connect'), 'jev-connect',
1564
+ add('jev-connect', WHATS['jev-connect'], applySlot('jev-connect', { toolsDir: HERE }).apply, 'jev-connect',
1578
1565
  `${place ? `run the apply in ${place}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${decline}`);
1579
1566
  } catch (err) {
1580
1567
  skip('jev-connect', err);
@@ -1582,7 +1569,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1582
1569
  };
1583
1570
 
1584
1571
  // The Jev skill offer (contract: kit/jev-guide, part jev-offer): only once the key is set, so one jev item shows at a time.
1585
- // Each target is judged by the facts leaf's one state rule; the apply is the skill command the USER runs (HAND-APPLY).
1572
+ // Each target is judged by the facts leaf's one state rule; the item's apply comes from the lane leaf's checked slot.
1586
1573
  export const probeJevSkill = ({ root, deps, add, skip }) => {
1587
1574
  try {
1588
1575
  const env = deps.getenv ?? {};
@@ -1608,7 +1595,7 @@ export const probeJevSkill = ({ root, deps, add, skip }) => {
1608
1595
  const untouched = judged.filter(({ state }) => state === 'foreign')
1609
1596
  .map(({ path, reason }) => `left untouched: ${printable(path)} — ${printable(reason)}; `).join('');
1610
1597
  const record = `node ${q(toolPath('ack-write.mjs'))} --lane jev-skill --fingerprint ${decline} --cwd ${q(root)}`;
1611
- add('jev-skill', what, `HAND-APPLY: ${line}`, behind.length ? 'jev-skill.earlier' : 'jev-skill',
1598
+ add('jev-skill', what, line, behind.length ? 'jev-skill.earlier' : 'jev-skill',
1612
1599
  `${place ? `run the apply in ${place}; ` : ''}${untouched}${note ? `${note}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${record}`);
1613
1600
  } catch (err) {
1614
1601
  skip('jev-skill', err);
@@ -1686,7 +1673,13 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1686
1673
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
1687
1674
  return false;
1688
1675
  }
1689
- items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key], apply: routeLine(variant) ?? apply, detail });
1676
+ const slot = applySlot(variant, {
1677
+ toolsDir: HERE, root, env: deps.getenv ?? {},
1678
+ home: deps.jevHost?.home ?? deps.home ?? '',
1679
+ platform: deps.jevHost?.platform ?? deps.platform,
1680
+ });
1681
+ items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key],
1682
+ check: slot?.check ?? null, apply: slot?.apply ?? apply, detail });
1690
1683
  return true;
1691
1684
  };
1692
1685
  // The per-run scratch a probe uses to tell a LATER probe what it actually did. Written by exactly
@@ -1736,6 +1729,7 @@ export const formatRecommendations = ({ items, skips }) => {
1736
1729
  lines.push(` benefit: ${item.benefit}`);
1737
1730
  lines.push(` lane: ${item.lane}`);
1738
1731
  if (item.detail) lines.push(` recipe: ${item.detail}`);
1732
+ if (item.check != null) lines.push(` check: ${item.check}`);
1739
1733
  lines.push(` apply: ${item.apply}`);
1740
1734
  });
1741
1735
  }
@@ -1752,16 +1746,21 @@ Usage:
1752
1746
 
1753
1747
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
1754
1748
  one composed verdict line opens every non-optimal render, then per item {severity · what is
1755
- sub-optimal · the benefit in one plain line · an optional \`recipe:\` line (the sandbox-lane live
1749
+ sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the sandbox-lane live
1756
1750
  recipe, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile
1757
- follow-up) · the exact consent-gated apply one-liner}. --cwd is
1751
+ follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1758
1752
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
1759
1753
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
1760
1754
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
1761
1755
  the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
1762
1756
  conversational skill invocation labeled "run IN the target project") and preserve each writer's
1763
- own consent semantics; sandbox filesystem allowances stay HAND-APPLY territory, the placed bridges' declared hosts reach sandbox.network.allowedDomains only through the
1764
- consented velocity-profile --bridge-tier, and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
1757
+ own consent semantics. The consented velocity-profile --bridge-tier seeds used bridges' code-mode
1758
+ allow rules, <wrapper> * exclusions (codex-exec * where an execute slot delegates), hosts in
1759
+ sandbox.network.allowedDomains and state dirs in sandbox.filesystem.allowWrite. Where the host
1760
+ honors the settings sandbox keys, an excluded wrapper runs outside the sandbox, every sandboxed
1761
+ command of the project can reach each host, and every sandboxed command can write each state dir;
1762
+ plan/diff review modes and delegated execution keep their prompt. Items marked HAND-APPLY stay
1763
+ the user's own step. The sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
1765
1764
  the consent-gated ack writer into docs/ai/acks.json (never a security key).
1766
1765
 
1767
1766
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report