@sabaiway/agent-workflow-kit 14.23.0 → 14.25.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.
package/tools/mcp.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // would be a client error on every startup;
17
17
  // • merge-don't-clobber — foreign servers, foreign settings keys and existing allow rules are
18
18
  // preserved; re-apply adds nothing twice; the file's EOL is kept;
19
- // • a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
19
+ // • without `--replace`, a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
20
20
  // re-serialized identical entry is the SAME registration) — silently changing what an MCP server
21
21
  // launches is exactly what consent must not slide past; the recovery is named;
22
22
  // • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
@@ -32,11 +32,16 @@
32
32
  // Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
33
33
  // Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
34
34
 
35
- import { lstatSync } from 'node:fs';
35
+ import {
36
+ lstatSync, openSync, closeSync, fstatSync, writeFileSync, ftruncateSync, fsyncSync,
37
+ renameSync, constants,
38
+ } from 'node:fs';
36
39
  import { join, resolve } from 'node:path';
37
40
  import { fileURLToPath } from 'node:url';
38
41
  import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
39
42
  import { isDirectRun } from './direct-run.mjs';
43
+ import { describeNonRegular } from './fs-read-nofollow.mjs';
44
+ import { assertContainedRealPath } from './fs-safe.mjs';
40
45
  import {
41
46
  CLAUDE_DIR_REL,
42
47
  ENABLED_KEY,
@@ -67,16 +72,16 @@ const LF = '\n';
67
72
  const JSON_INDENT = 2;
68
73
 
69
74
  export const MCP_TOOL = fileURLToPath(import.meta.url);
70
- export const applyMcpCommand = (root) => `node ${q(MCP_TOOL)} --apply --cwd ${q(root)}`;
75
+ export const applyMcpCommand = (root, replace = false) => `node ${q(MCP_TOOL)}${replace ? ' --replace' : ''} --apply --cwd ${q(root)}`;
71
76
 
72
- const USAGE = `usage: mcp [--dry-run | --apply] [--cwd <dir>] [--help]
77
+ const USAGE = `usage: mcp [--dry-run | --apply] [--replace] [--cwd <dir>] [--help]
73
78
 
74
79
  Registers this kit's stdio MCP server in ONE project: the "${SERVER_NAME}" entry in
75
80
  ${MCP_JSON_REL}, and "${ENABLED_KEY}" + the two tool allow rules in ${SETTINGS_REL}.
76
81
  Default is --dry-run (a preview that prints the exact entry and writes nothing).
77
- --apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept.
82
+ --apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept. An existing target is written in place; --replace replaces a differing entry only with --apply.
78
83
 
79
- An existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
84
+ Without --replace, an existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
80
85
  order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
81
86
  mask is the usual cause — the entry to merge is printed and nothing is written.
82
87
  Never writes settings.local.json; never commits.`;
@@ -122,7 +127,7 @@ const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
122
127
  }
123
128
  };
124
129
 
125
- export const preflightMcp = ({ cwd }, deps = {}) => {
130
+ export const preflightMcp = ({ cwd, replace = false }, deps = {}) => {
126
131
  const root = resolve(cwd ?? deps.cwd ?? process.cwd());
127
132
  // The ROOT is judged BEFORE the registration is read, not merely before the first write: reading
128
133
  // first would follow the very link this refuses, which is exactly what the shipped claim denies.
@@ -152,7 +157,7 @@ export const preflightMcp = ({ cwd }, deps = {}) => {
152
157
  }
153
158
  assertTargetUsable(registration.mcpJson, { maskedAllowed: true });
154
159
  assertTargetUsable(registration.settings);
155
- if (registration.mcpJson.differs) {
160
+ if (registration.mcpJson.differs && !replace) {
156
161
  throw makeMcpError(
157
162
  MCP_DIFFERS,
158
163
  `${MCP_JSON_REL} already carries an "${SERVER_NAME}" server entry that STRUCTURALLY DIFFERS from this kit copy's registration — refusing to change what it launches; review that entry and remove or rename it, then re-run`,
@@ -173,8 +178,53 @@ export const planMcp = (registration) => ({
173
178
 
174
179
  // ── the writer ─────────────────────────────────────────────────────────────────────────
175
180
 
176
- export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
177
- const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
181
+ const writeTarget = (root, target, body, deps, stop) => {
182
+ const leaf = lstatNoFollow(target.abs, deps.lstat);
183
+ if (target.state === STATE.ABSENT) {
184
+ const assertAbsent = (dst) => {
185
+ if (lstatNoFollow(dst, deps.lstat) !== null) {
186
+ throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' });
187
+ }
188
+ };
189
+ assertAbsent(target.abs);
190
+ const rename = (tmp, dst) => {
191
+ assertAbsent(dst);
192
+ (deps.rename ?? renameSync)(tmp, dst);
193
+ };
194
+ return writeContainedFileAtomic(root, target.abs, body, { ...deps, rename }, { stop, label: target.rel });
195
+ }
196
+ if (leaf?.isSymbolicLink()) {
197
+ throw stop(`${target.rel} is a symlink — refusing to replace it (a write would clobber the link target)`);
198
+ }
199
+ try {
200
+ assertContainedRealPath(root, target.abs, deps);
201
+ } catch (err) {
202
+ throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
203
+ }
204
+ const flags = deps.constants ?? constants;
205
+ if (!flags.O_NOFOLLOW || !flags.O_NONBLOCK) {
206
+ throw stop('this platform exposes no usable O_NOFOLLOW|O_NONBLOCK open flags — refusing to open (fail closed)');
207
+ }
208
+ const fd = (deps.open ?? openSync)(target.abs, flags.O_WRONLY | flags.O_NOFOLLOW | flags.O_NONBLOCK);
209
+ try {
210
+ const stat = (deps.fstat ?? fstatSync)(fd);
211
+ if (!stat.isFile()) {
212
+ throw stop(`${target.rel} is a ${describeNonRegular(stat)} — refusing to write through it`);
213
+ }
214
+ if (leaf === null || stat.dev !== leaf.dev || stat.ino !== leaf.ino) {
215
+ throw stop('the leaf changed identity between lstat and open (fail closed)');
216
+ }
217
+ const bytes = Buffer.from(body, 'utf8');
218
+ (deps.writeFile ?? writeFileSync)(fd, bytes);
219
+ (deps.ftruncate ?? ftruncateSync)(fd, bytes.length);
220
+ (deps.fsync ?? fsyncSync)(fd);
221
+ } finally {
222
+ (deps.close ?? closeSync)(fd);
223
+ }
224
+ };
225
+
226
+ export const writeMcp = ({ cwd, dryRun = true, replace = false } = {}, deps = {}) => {
227
+ const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd(), replace }, deps);
178
228
  const base = { ...preflight, dryRun, wrote: false };
179
229
  if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
180
230
  if (dryRun) return base;
@@ -182,7 +232,7 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
182
232
  const { root, registration, plan } = preflight;
183
233
  const stop = (message) => makeMcpError(MCP_SYMLINK, message);
184
234
  if (plan.writeMcpJson) {
185
- writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
235
+ writeTarget(root, registration.mcpJson, plan.mcpBody, deps, stop);
186
236
  }
187
237
  if (plan.writeSettings) {
188
238
  // A settings failure AFTER the entry landed leaves a STANDING registration on disk that never
@@ -194,12 +244,12 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
194
244
  // The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so
195
245
  // it belongs on the apply lane only — a preview that made a directory would not be a preview.
196
246
  assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
197
- writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
247
+ writeTarget(root, registration.settings, plan.settingsBody, deps, stop);
198
248
  } catch (err) {
199
249
  // Reaching here always leaves a STANDING registration on disk — either this run wrote the
200
250
  // entry (`writeMcpJson`) or the preflight found it already current (`matches`), and those two
201
251
  // are exhaustive, so the note is unconditional rather than guarded by a branch nothing can
202
- // take. A differing entry never reaches the writer at all; it STOPs in the preflight.
252
+ // take. Without --replace, a differing entry STOPs in the preflight.
203
253
  throw Object.assign(err, { message: `${err.message}\n${HIDDEN_MODE_LINE}` });
204
254
  }
205
255
  }
@@ -221,7 +271,9 @@ const indented = (text) => text.trimEnd().split(LF).map((line) => ` ${line}`)
221
271
 
222
272
  const mcpJsonLine = (result) => {
223
273
  if (!result.plan.writeMcpJson) return ` - ${MCP_JSON_REL}: already current`;
224
- const verb = result.dryRun ? 'would add' : 'added';
274
+ const verb = result.registration.mcpJson.differs
275
+ ? (result.dryRun ? 'would replace' : 'replaced')
276
+ : (result.dryRun ? 'would add' : 'added');
225
277
  return ` - ${MCP_JSON_REL}: ${verb} the "${SERVER_NAME}" stdio entry`;
226
278
  };
227
279
 
@@ -264,24 +316,29 @@ export const formatResult = (result) => {
264
316
  : 'agent-workflow MCP registration — APPLY',
265
317
  mcpJsonLine(result),
266
318
  settingsLine(result),
319
+ ...(result.registration.mcpJson.differs ? [
320
+ ' the entry standing in .mcp.json now:',
321
+ indented(JSON.stringify(result.registration.mcpJson.existing, null, JSON_INDENT)),
322
+ ] : []),
267
323
  ' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
268
324
  indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
269
325
  POSTURE_LINE,
270
326
  HIDDEN_MODE_LINE,
271
327
  ];
272
- if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
328
+ if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root, result.registration.mcpJson.differs)}`);
273
329
  return lines.join(LF);
274
330
  };
275
331
 
276
332
  // ── CLI ────────────────────────────────────────────────────────────────────────────────
277
333
 
278
334
  export const parseArgs = (argv) => {
279
- const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
335
+ const opts = { dryRunFlag: false, apply: false, replace: false, cwd: undefined, help: false };
280
336
  for (let i = 0; i < argv.length; i += 1) {
281
337
  const arg = argv[i];
282
338
  if (arg === '--help' || arg === '-h') opts.help = true;
283
339
  else if (arg === '--dry-run') opts.dryRunFlag = true;
284
340
  else if (arg === '--apply') opts.apply = true;
341
+ else if (arg === '--replace') opts.replace = true;
285
342
  else if (arg === '--cwd') {
286
343
  i += 1;
287
344
  // An EMPTY (or whitespace) value passes both guards above, and `resolve('')` silently means the
@@ -296,7 +353,7 @@ export const parseArgs = (argv) => {
296
353
  }
297
354
  }
298
355
  if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
299
- return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
356
+ return { help: opts.help, dryRun: !opts.apply, replace: opts.replace, cwd: opts.cwd };
300
357
  };
301
358
 
302
359
  export const main = (argv = process.argv.slice(2), deps = {}) => {
@@ -308,10 +365,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
308
365
  log(USAGE);
309
366
  return EXIT_OK;
310
367
  }
311
- log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
368
+ log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun, replace: args.replace }, deps)));
312
369
  return EXIT_OK;
313
370
  } catch (err) {
314
- errlog(err?.message ?? String(err));
371
+ const message = err?.message ?? String(err);
372
+ errlog(err?.code && err.name !== 'McpError' && !message.includes(err.code) ? `${err.code}: ${message}` : message);
315
373
  if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
316
374
  return err?.exitCode ?? EXIT_PRECONDITION;
317
375
  }
@@ -19,9 +19,9 @@
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. The kit never seeds
23
- // sandbox.network.allowedDomains / filesystem.allowWrite (HAND-APPLY territory; bridge council
24
- // 2026-07-11, both backends concur); the sandbox-lane item's convergence is a NEUTRAL
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
25
25
  // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
26
26
  // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
27
27
  //
@@ -48,7 +48,8 @@ import {
48
48
  SETTINGS_LOCAL_FILE,
49
49
  } from './velocity-profile.mjs';
50
50
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
51
- import { deriveDoctorPlan } from './autonomy-doctor.mjs';
51
+ import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
52
+ import { laneOf, routeLine } from './write-lanes.mjs';
52
53
  import { detectBackends, findOnPath } from './detect-backends.mjs';
53
54
  import { isDirectRun } from './direct-run.mjs';
54
55
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
@@ -60,7 +61,6 @@ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKit
60
61
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
61
62
  // The typed channel's READ-ONLY leaf only — never tools/mcp.mjs, which reaches the write core.
62
63
  import {
63
- MCP_JSON_REL,
64
64
  SERVER_NAME as MCP_SERVER_NAME,
65
65
  STATE as MCP_STATE,
66
66
  readRegistration,
@@ -79,7 +79,7 @@ import { CHECKER_CLAIM, INITIAL_ADOPTION_REASON, SOURCE_SIZE_GATE_ID, classifySo
79
79
  import { resolveDeclaredDir, dirCovers, isResolvableDeclaredEntry } from './declared-paths.mjs';
80
80
  import { resolveGitHooksPath } from './commit-guard.mjs';
81
81
  import { loadConfig } from './orchestration-config.mjs';
82
- import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
82
+ import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
83
83
  import { assertContainedRealPath } from './fs-safe.mjs';
84
84
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
85
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
@@ -103,7 +103,7 @@ import {
103
103
  import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
104
104
  import { ENSURE_OPS } from './ensure-vocabulary.mjs';
105
105
  import { composeProfileGapScreen } from './profile-gap-screen.mjs';
106
- import { NO_APPLY_LINE, SKILL_PINS, claudeDirOf, connectLine, keySet, placeOf, printable, skillLine, skillState, skillTargets } from './jev-facts.mjs';
106
+ import { NO_APPLY_LINE, SKILL_PINS, claudeDirOf, keySet, placeOf, printable, skillLine, skillState, skillTargets } from './jev-facts.mjs';
107
107
 
108
108
  // The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
109
109
  const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
@@ -117,7 +117,7 @@ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_
117
117
  const HERE = dirname(fileURLToPath(import.meta.url));
118
118
  const toolPath = (rel) => join(HERE, rel);
119
119
  const q = shellQuoteArg;
120
- const ENFORCEMENT_ABSENT_CODE = 'ENOENT';
120
+ const ABSENT_CODE = 'ENOENT';
121
121
  const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
122
122
  const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
123
123
  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';
@@ -155,6 +155,7 @@ export const SEVERITIES = Object.freeze({
155
155
  'autonomy-policy': SEVERITY_OPTIONAL,
156
156
  'autonomy-render': SEVERITY_ATTENTION,
157
157
  'sandbox-provision': SEVERITY_OPTIONAL,
158
+ 'sandbox-provision.installable': SEVERITY_OPTIONAL,
158
159
  'review-recipe': SEVERITY_ATTENTION,
159
160
  'gates-declaration': SEVERITY_OPTIONAL,
160
161
  'gates-inert': SEVERITY_ATTENTION,
@@ -185,12 +186,7 @@ export const SEVERITIES = Object.freeze({
185
186
  'read-lane.stale': SEVERITY_ATTENTION,
186
187
  'read-lane.missing': SEVERITY_ATTENTION,
187
188
  'state-block': SEVERITY_OPTIONAL,
188
- // The typed channel. The base arm is an ordinary offer; `.masked` stays an offer too (nothing is
189
- // broken — the kit simply cannot write through a device node, so the remedy is handed over);
190
- // `.differing` reports a CONFIGURED declaration that would launch something else, which is the
191
- // one state here a maintainer must actually look at.
192
189
  'mcp-channel': SEVERITY_OPTIONAL,
193
- 'mcp-channel.masked': SEVERITY_OPTIONAL,
194
190
  'mcp-channel.differing': SEVERITY_ATTENTION,
195
191
  agents: SEVERITY_OPTIONAL,
196
192
  // The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
@@ -252,7 +248,7 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
252
248
  export const WHATS = Object.freeze({
253
249
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
254
250
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
255
- 'bridge-tier': 'council review runs prompt per bridge invocation — {n} bridge-wrappers tier entr(ies) not seeded (placed bridges only, code mode only)',
251
+ 'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, sandbox exclusions or declared hosts of placed bridges)',
256
252
  'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
257
253
  'autonomy-render': 'the declared autonomy policy is not rendered into .claude/settings.json — drift: {drift}',
258
254
  'sandbox-provision': 'the OS sandbox is unavailable: {reason}',
@@ -276,7 +272,6 @@ export const WHATS = Object.freeze({
276
272
  'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
277
273
  'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
278
274
  'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
279
- 'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
280
275
  'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
281
276
  agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
282
277
  'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
@@ -415,7 +410,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
415
410
  { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
416
411
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
417
412
  // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
418
- // same doctrine that keeps sandbox network/filesystem allowances HAND-APPLY); velocity presents the
413
+ // same doctrine that keeps sandbox filesystem allowances HAND-APPLY); velocity presents the
419
414
  // full honest posture at its own consent moment, where the user is already deciding.
420
415
  {
421
416
  id: 'accept-edits',
@@ -465,8 +460,8 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
465
460
  skip('kit-tools-tier', err);
466
461
  }
467
462
  try {
468
- const bt = planVelocityProfile(preflight, { bridgeTier: true, findWrapper: deps.findWrapper });
469
- const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length;
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;
470
465
  if (delta > 0) {
471
466
  add('bridge-tier', fillTemplate(WHATS['bridge-tier'], { n: delta }), applyLine(' --bridge-tier'));
472
467
  }
@@ -507,12 +502,18 @@ const probeSandboxProvision = ({ root, deps, add, skip }) => {
507
502
  try {
508
503
  const p = probeSandboxAvailability(deps);
509
504
  if (p.available) return;
510
- const plan = deriveDoctorPlan({ probeResult: p, env: deps.env ?? process.env, isExec: deps.isExecutable ?? isExecutableFile });
511
- const variant = plan.tuple ? 'sandbox-provision.installable' : 'sandbox-provision';
512
- const reason = truncatedTo(oneLineOf(p.reason), templateBudget(WHATS[variant]) - (plan.tuple ? String(plan.tuple).length : 0));
505
+ const isExec = deps.isExecutable ?? isExecutableFile;
506
+ const plan = deriveDoctorPlan({ probeResult: p, env: deps.env ?? process.env, isExec });
507
+ const installable = plan.tuple !== null && plan.untrusted.length === 0 && !plan.envMissing
508
+ && ((deps.euid ?? (() => process.geteuid?.()))() === 0 || TRUSTED_DIRS.some((dir) => isExec(join(dir, 'sudo'))));
509
+ const variant = installable ? 'sandbox-provision.installable' : 'sandbox-provision';
510
+ const reason = truncatedTo(oneLineOf(p.reason), templateBudget(WHATS[variant]) - (installable ? plan.tuple.length : 0));
513
511
  // The doctor reads process.cwd() (deployment-gated) and takes no --cwd flag — the one-liner
514
512
  // pins the target project via a cd prefix (Segment B).
515
- add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }), `cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`);
513
+ add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }),
514
+ `cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`, variant,
515
+ installable ? null : 'on native Windows Claude Code runs no sandbox: this item takes no effect there',
516
+ );
516
517
  } catch (err) {
517
518
  skip('sandbox-provision', err);
518
519
  }
@@ -884,6 +885,7 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
884
885
  if (hooksPath === null) return; // not a git tree — nothing to hook
885
886
  const hook = (() => {
886
887
  try {
888
+ if (readsAbsent(deps, join(hooksPath, 'pre-commit'))) return null;
887
889
  return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
888
890
  } catch {
889
891
  return null;
@@ -904,7 +906,7 @@ const readEnforcementNode = (path, lstat) => {
904
906
  try {
905
907
  return lstat(path);
906
908
  } catch (err) {
907
- if (err?.code === ENFORCEMENT_ABSENT_CODE) return null;
909
+ if (err?.code === ABSENT_CODE) return null;
908
910
  throw new Error(`${path}: ${err?.code ?? err?.message ?? err}`);
909
911
  }
910
912
  };
@@ -954,7 +956,7 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
954
956
  // is silently dark; surface it as attention with a place-first recovery (council R2-M2).
955
957
  const placeRecovery = () =>
956
958
  add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
957
- if (!sg.filePlaced) {
959
+ if (!sg.filePlaced || readsAbsent(deps, join(root, GATE_HOOK_REL))) {
958
960
  placeRecovery();
959
961
  return;
960
962
  }
@@ -1099,9 +1101,9 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
1099
1101
  }),
1100
1102
  // The writer places a missing vehicle and re-derives a placed executor whose bytes differ from the derived body (an invalid
1101
1103
  // frontmatter or read-only tools included), so those states need no precondition; every precondition above is a refusal the apply would meet.
1102
- `${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1104
+ `node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1103
1105
  'executor-vehicle',
1104
- `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1106
+ `${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1105
1107
  );
1106
1108
  } catch (err) {
1107
1109
  skip('executor-vehicle', err);
@@ -1157,37 +1159,6 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
1157
1159
  // knob is retired in the wrappers (recognized, arms nothing) and an oversized code review is now a
1158
1160
  // chunked feed with a per-part delivery proof, so there is nothing left to offer.
1159
1161
 
1160
- // The manifest-declared session-sandbox recipe surfaces of every BUNDLED bridge whose review
1161
- // wrapper is in the wired set — networkHosts ∪ writableDirs, derived from the manifests (the
1162
- // single documentation source), never hardcoded here.
1163
- const bundledSandboxRecipe = (placedWrappers, deps) => {
1164
- const readFile = deps.readFile ?? readFileSync;
1165
- const readDir = deps.readdir ?? readdirSync;
1166
- const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
1167
- const hosts = [];
1168
- const dirEntries = [];
1169
- for (const dir of readDir(bundleRoot)) {
1170
- // An unreadable/unparsable bundled manifest must NOT thin the recipe silently — a partial
1171
- // recipe rendered as complete is worse than no item. The throw reaches the probe's catch and
1172
- // becomes a stated skip.
1173
- let manifest;
1174
- try {
1175
- manifest = JSON.parse(readFile(join(bundleRoot, dir, 'capability.json'), 'utf8'));
1176
- } catch (err) {
1177
- // ENOTDIR = the entry is a stray regular file (.DS_Store, a README), not a bridge bundle.
1178
- if (err?.code === 'ENOTDIR') continue;
1179
- throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
1180
- }
1181
- const reviewCmd = manifest?.roles?.review?.cmd;
1182
- if (!reviewCmd || !placedWrappers.includes(reviewCmd)) continue;
1183
- if (Array.isArray(manifest.networkHosts)) {
1184
- for (const h of manifest.networkHosts) if (!hosts.includes(h)) hosts.push(h);
1185
- }
1186
- if (Array.isArray(manifest.writableDirs)) dirEntries.push(...manifest.writableDirs);
1187
- }
1188
- return { hosts, dirEntries };
1189
- };
1190
-
1191
1162
  // D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
1192
1163
  // `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
1193
1164
  // never resolve as a home path — anchors like a relative path). The advisor anchors to the TARGET
@@ -1265,7 +1236,17 @@ const isStateBlockGuardWired = (data) => {
1265
1236
  // itself (a hook that is not there belongs to the place offers, never to a refresh arm); a symlink,
1266
1237
  // a directory and an unreadable target all THROW into the probe's stated-skip lane.
1267
1238
  const HOOK_CURRENCY = Object.freeze({ CURRENT: 'current', STALE: 'stale', ABSENT: 'absent' });
1239
+ const readsAbsent = (deps, path) => {
1240
+ try {
1241
+ (deps.lstat ?? lstatSync)(path);
1242
+ return false;
1243
+ } catch (err) {
1244
+ return err?.code === ABSENT_CODE;
1245
+ }
1246
+ };
1247
+
1268
1248
  const readPlacedHookCurrency = (root, deps) => {
1249
+ if (readsAbsent(deps, join(root, GATE_HOOK_REL))) return HOOK_CURRENCY.ABSENT;
1269
1250
  const read = deps.readRegularFileNoFollow ?? readRegularFileNoFollow;
1270
1251
  const placed = read(join(root, GATE_HOOK_REL));
1271
1252
  if (placed.outcome === 'absent') return HOOK_CURRENCY.ABSENT;
@@ -1322,7 +1303,7 @@ const readReadLaneToggle = (root, deps) => {
1322
1303
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1323
1304
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1324
1305
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1325
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
1306
+ export const RISK_NOTED_KEYS = Object.freeze(['bridge-tier', 'sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap', 'jev-connect', 'jev-skill']);
1326
1307
 
1327
1308
  // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1328
1309
  // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
@@ -1362,9 +1343,11 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
1362
1343
  const sandbox = settings.data?.sandbox;
1363
1344
  const excluded = Array.isArray(sandbox?.excludedCommands) ? sandbox.excludedCommands : [];
1364
1345
  const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
1365
- // Wired = the FULL two-surface tier proof (excludedCommands + the code-mode allow rule, either
1366
- // scope) — surfacing the recipe while the tier is half-configured would front-run the
1367
- // bridge-tier item (codex terminal). Byte-form from the tier's own constants.
1346
+ // Wired = the two-surface proof the tier wrote before AD-172 (the wrapper in the PROJECT
1347
+ // excludedCommands + its code-mode allow rule, either scope) — surfacing the recipe before those
1348
+ // 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.
1368
1351
  const allowRules = [
1369
1352
  ...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
1370
1353
  ...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
@@ -1372,7 +1355,7 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
1372
1355
  const wired = BRIDGE_REVIEW_WRAPPERS.filter(
1373
1356
  (w) => excluded.includes(w) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
1374
1357
  );
1375
- if (wired.length === 0) return; // the tier is not (fully) wired — the bridge-tier item covers first
1358
+ if (wired.length === 0) return; // the tier's rules and exclusions are not wired — the bridge-tier item covers first
1376
1359
  const { hosts, dirEntries } = bundledSandboxRecipe(wired, deps);
1377
1360
  const env = deps.getenv ?? process.env;
1378
1361
  const home = deps.home ?? homedir();
@@ -1550,9 +1533,6 @@ export const probeAdrStore = ({ root, deps, add, skip }) => {
1550
1533
 
1551
1534
  // The typed-channel item. It asks the READ-ONLY registration leaf and nothing else — importing the
1552
1535
  // writer would pull the atomic-write core into the advisor's graph (read-graph-purity.test.mjs).
1553
- // The rendered apply is the mode's FLAGLESS preview on purpose: registering an MCP server means the
1554
- // client will RUN that command, so the entry is read before it is declared and the `--apply` stays
1555
- // the maintainer's separate step.
1556
1536
  const probeMcpChannel = ({ root, deps, add, skip }) => {
1557
1537
  try {
1558
1538
  const registration = readRegistration(root, deps);
@@ -1567,49 +1547,16 @@ const probeMcpChannel = ({ root, deps, add, skip }) => {
1567
1547
  // settings half can end the probe. A differing entry is fully observable, and an unreadable
1568
1548
  // settings file says nothing about it — returning on the settings mask first hid it.
1569
1549
  assertReadable(registration.mcpJson);
1570
- const settingsUsable = registration.settings.state !== MCP_STATE.MASKED
1571
- && registration.settings.state !== MCP_STATE.FOREIGN
1572
- && registration.settings.state !== MCP_STATE.UNREADABLE
1573
- && registration.settings.state !== MCP_STATE.MALFORMED;
1574
1550
  if (registration.mcpJson.differs) {
1575
- // The remedy is the maintainer's edit either way; the "then run" tail is dropped where that
1576
- // command could not succeed, so the item never hands over a line that exits 1.
1577
- const tail = settingsUsable ? `, then run ${preview}` : '';
1578
1551
  add(
1579
1552
  'mcp-channel',
1580
1553
  fillTemplate(WHATS['mcp-channel.differing'], { server: MCP_SERVER_NAME }),
1581
- `HAND-APPLY: edit ${q(join(root, MCP_JSON_REL))} → remove or rename the "${MCP_SERVER_NAME}" entry${tail}`,
1554
+ preview,
1582
1555
  'mcp-channel.differing',
1583
1556
  );
1584
1557
  return;
1585
1558
  }
1586
1559
  assertReadable(registration.settings);
1587
- // The HAND-APPLY arm renders the MODE's own preview, so it may fire only where that command can
1588
- // actually run — and the writer refuses a masked settings.json outright (it can neither write it
1589
- // nor merge into what it cannot read). A masked settings half is therefore a stated SKIP: the
1590
- // completeness this item decides on is unknowable, and offering a command that exits 1 is worse
1591
- // than saying nothing.
1592
- if (registration.settings.state === MCP_STATE.MASKED) {
1593
- skip('mcp-channel', new Error(`${registration.settings.rel} is a ${registration.settings.className} here (a sandbox device mask is the usual cause) — the registration cannot be judged or written from in here; verify it outside the sandbox`));
1594
- return;
1595
- }
1596
- if (registration.mcpJson.state === MCP_STATE.MASKED) {
1597
- // The kit cannot write through the mask either, so the remedy is HAND-APPLY. But when the
1598
- // settings half is already complete, the registration was almost certainly made from outside
1599
- // the sandbox: what cannot be observed becomes a stated SKIP (optimality withheld), never the
1600
- // same offer again on every single upgrade.
1601
- if (registration.settings.complete) {
1602
- skip('mcp-channel', new Error(`${registration.mcpJson.rel} is a ${registration.mcpJson.className} here (a sandbox device mask is the usual cause) and the settings half is already complete — verify the entry outside the sandbox`));
1603
- return;
1604
- }
1605
- add(
1606
- 'mcp-channel',
1607
- fillTemplate(WHATS['mcp-channel.masked'], { rel: registration.mcpJson.rel, className: registration.mcpJson.className }),
1608
- `HAND-APPLY: ${preview}`,
1609
- 'mcp-channel.masked',
1610
- );
1611
- return;
1612
- }
1613
1560
  if (registration.registered) return;
1614
1561
  add('mcp-channel', fillTemplate(WHATS['mcp-channel'], {}), preview);
1615
1562
  } catch (err) {
@@ -1618,16 +1565,16 @@ const probeMcpChannel = ({ root, deps, add, skip }) => {
1618
1565
  };
1619
1566
 
1620
1567
  // The Jev offer (contract: kit/jev-guide, part jev-offer). The key is judged first, through the facts leaf's one rule over
1621
- // the injected environment; the apply is the connect command the USER runs in a terminal of their own (HAND-APPLY), never a guide.
1568
+ // the injected environment.
1622
1569
  export const probeJevConnect = ({ root, deps, add, skip }) => {
1623
1570
  try {
1624
1571
  const env = deps.getenv ?? {};
1625
- if (keySet(env)) return;
1572
+ if (deps.keySet === true || keySet(env)) return;
1626
1573
  if (readAckValue(root, deps, ACKS_JEV_CONNECT_KEY) === JEV_CONNECT_DECLINE) return;
1627
1574
  const host = deps.jevHost ?? {};
1628
1575
  const place = placeOf({ env, platform: host.platform, hostname: host.hostname, container: host.container });
1629
1576
  const decline = `node ${q(toolPath('ack-write.mjs'))} --lane jev-connect --fingerprint ${JEV_CONNECT_DECLINE} --cwd ${q(root)}`;
1630
- add('jev-connect', WHATS['jev-connect'], `HAND-APPLY: ${connectLine(HERE, host.platform)}`, 'jev-connect',
1577
+ add('jev-connect', WHATS['jev-connect'], routeLine('jev-connect'), 'jev-connect',
1631
1578
  `${place ? `run the apply in ${place}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${decline}`);
1632
1579
  } catch (err) {
1633
1580
  skip('jev-connect', err);
@@ -1639,7 +1586,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1639
1586
  export const probeJevSkill = ({ root, deps, add, skip }) => {
1640
1587
  try {
1641
1588
  const env = deps.getenv ?? {};
1642
- if (!keySet(env)) return;
1589
+ if (deps.keySet !== true && !keySet(env)) return;
1643
1590
  const host = deps.jevHost ?? {};
1644
1591
  if (typeof host.home !== 'string' || host.home === '') return;
1645
1592
  const pins = deps.jevPins ?? SKILL_PINS;
@@ -1739,14 +1686,29 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1739
1686
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
1740
1687
  return false;
1741
1688
  }
1742
- items.push({ key, variant, severity: SEVERITIES[variant], what, benefit: BENEFITS[key], apply, detail });
1689
+ items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key], apply: routeLine(variant) ?? apply, detail });
1743
1690
  return true;
1744
1691
  };
1745
1692
  // The per-run scratch a probe uses to tell a LATER probe what it actually did. Written by exactly
1746
1693
  // one pair today (the marker-stale ⟷ read-lane.stale precedence) and read in the frozen PROBES
1747
1694
  // order, so the reader can never run first.
1748
1695
  const shared = {};
1749
- for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip, shared });
1696
+ const contentDeps = Object.create(Object.getPrototypeOf(deps), {
1697
+ ...Object.getOwnPropertyDescriptors(deps),
1698
+ lstat: {
1699
+ enumerable: true,
1700
+ value: (path, ...args) => {
1701
+ const stat = (deps.lstat ?? deps.lstatSync ?? lstatSync)(path, ...args);
1702
+ if (stat.isCharacterDevice?.() || stat.isBlockDevice?.()) {
1703
+ throw Object.assign(new Error(`${ABSENT_CODE}: ${path}`), { code: ABSENT_CODE });
1704
+ }
1705
+ return stat;
1706
+ },
1707
+ },
1708
+ });
1709
+ for (const probe of deps.probes ?? PROBES) {
1710
+ probe({ root, deps: probe === probeMasksItem ? deps : contentDeps, add, skip, shared });
1711
+ }
1750
1712
  return { root, items, skips };
1751
1713
  };
1752
1714
 
@@ -1772,6 +1734,7 @@ export const formatRecommendations = ({ items, skips }) => {
1772
1734
  ordered.forEach((item, i) => {
1773
1735
  lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
1774
1736
  lines.push(` benefit: ${item.benefit}`);
1737
+ lines.push(` lane: ${item.lane}`);
1775
1738
  if (item.detail) lines.push(` recipe: ${item.detail}`);
1776
1739
  lines.push(` apply: ${item.apply}`);
1777
1740
  });
@@ -1797,15 +1760,15 @@ section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a pro
1797
1760
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
1798
1761
  the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
1799
1762
  conversational skill invocation labeled "run IN the target project") and preserve each writer's
1800
- own consent semantics; the kit never seeds sandbox network/filesystem allowances (HAND-APPLY
1801
- territory), and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
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
1802
1765
  the consent-gated ack writer into docs/ai/acks.json (never a security key).
1803
1766
 
1804
1767
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
1805
1768
  rendered (items or empty); 1 error; 2 usage.`;
1806
1769
 
1807
1770
  // The host facts of the key and skill offers: the platform, the hostname (a throw reads as none), /.dockerenv a regular file, the home.
1808
- const hostFacts = () => ({ platform: process.platform, hostname: (() => { try { return hostname(); } catch { return ''; } })(),
1771
+ export const hostFacts = () => ({ platform: process.platform, hostname: (() => { try { return hostname(); } catch { return ''; } })(),
1809
1772
  container: (() => { try { return statSync('/.dockerenv').isFile(); } catch { return false; } })(),
1810
1773
  home: (() => { try { return homedir(); } catch { return ''; } })() });
1811
1774