flowviant 0.117.0 → 0.124.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.
Files changed (64) hide show
  1. package/README.md +35 -0
  2. package/bin/cli.mjs +37 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +2 -2
  7. package/bin/lib/agentPlan.mjs +78 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentTaskKinds.mjs +1 -1
  10. package/bin/lib/artifactContracts.mjs +91 -20
  11. package/bin/lib/artifactIteration.mjs +182 -0
  12. package/bin/lib/artifactRecovery.mjs +36 -0
  13. package/bin/lib/artifactReporter.mjs +183 -19
  14. package/bin/lib/artifacts.mjs +6 -3
  15. package/bin/lib/claudeAuth.mjs +15 -5
  16. package/bin/lib/claudePosture.mjs +110 -14
  17. package/bin/lib/claudeStream.mjs +20 -2
  18. package/bin/lib/codexProjectRead.mjs +63 -0
  19. package/bin/lib/codexResearch.mjs +16 -3
  20. package/bin/lib/commandSpecs.mjs +6 -0
  21. package/bin/lib/daemonLocks.mjs +5 -3
  22. package/bin/lib/desktopContract.mjs +3 -0
  23. package/bin/lib/discoveryContract.mjs +19 -0
  24. package/bin/lib/fleet.mjs +46 -4
  25. package/bin/lib/fleetReports.mjs +44 -3
  26. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  27. package/bin/lib/hooks/readGuard.mjs +13 -4
  28. package/bin/lib/imageArtifact.mjs +74 -0
  29. package/bin/lib/maintenance.mjs +47 -0
  30. package/bin/lib/maintenanceInventory.mjs +39 -0
  31. package/bin/lib/mcpInstall.mjs +163 -0
  32. package/bin/lib/mcpInventory.mjs +119 -0
  33. package/bin/lib/planDependencies.mjs +70 -0
  34. package/bin/lib/postureTools.mjs +85 -0
  35. package/bin/lib/projectToolRuntimes.mjs +227 -3
  36. package/bin/lib/projectTools.mjs +61 -4
  37. package/bin/lib/prompts.mjs +164 -41
  38. package/bin/lib/repositoryBaseline.mjs +68 -0
  39. package/bin/lib/runTurn.mjs +35 -16
  40. package/bin/lib/runtimeCapabilities.mjs +77 -3
  41. package/bin/lib/runtimeClaude.mjs +27 -9
  42. package/bin/lib/runtimeCodex.mjs +27 -59
  43. package/bin/lib/runtimeDetection.mjs +6 -0
  44. package/bin/lib/runtimeEvents.mjs +12 -1
  45. package/bin/lib/runtimeOfferings.mjs +75 -3
  46. package/bin/lib/turnDiagnostics.mjs +93 -0
  47. package/bin/lib/turnProfile.mjs +15 -3
  48. package/bin/lib/work.mjs +28 -7
  49. package/bin/lib/workAgentBrief.mjs +92 -0
  50. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  51. package/bin/lib/workAgentPlans.mjs +21 -70
  52. package/bin/lib/workAgentPrecheck.mjs +5 -0
  53. package/bin/lib/workAgentTurnExecution.mjs +93 -22
  54. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  55. package/bin/lib/workAgentTurns.mjs +60 -4
  56. package/bin/lib/workArtifactReconcile.mjs +79 -0
  57. package/bin/lib/workCappedTurn.mjs +10 -0
  58. package/bin/lib/workCommandAudit.mjs +8 -1
  59. package/bin/lib/workPlaces.mjs +18 -2
  60. package/bin/lib/workSessionTurns.mjs +148 -13
  61. package/bin/lib/workToolLog.mjs +70 -3
  62. package/bin/lib/workTurnShape.mjs +6 -4
  63. package/npm-shrinkwrap.json +12 -2
  64. package/package.json +7 -3
@@ -15,9 +15,10 @@
15
15
  * projectTools.mjs. projectTools.test.mjs pins every transport word to here.
16
16
  */
17
17
 
18
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
19
19
  import { join, resolve } from 'node:path';
20
20
  import { codexHome } from './codexHome.mjs';
21
+ import { parseCodexToml, TomlRaw } from './codexPersonal.mjs';
21
22
 
22
23
  export const toml = (value) => JSON.stringify(String(value));
23
24
 
@@ -182,9 +183,14 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
182
183
  try { if (!lstatSync(dest).isDirectory()) rmSync(dest, { force: true }); } catch { /* absent */ }
183
184
  mkdirSync(dest, { recursive: true, mode: 0o700 });
184
185
  chmodSync(dest, 0o700);
185
- const personal = existsSync(source) ? readdirSync(source).filter((name) => name !== 'config.toml') : [];
186
+ const personal = existsSync(source) ? readdirSync(source).filter((name) => name !== 'config.toml' && name !== 'generated_images') : [];
186
187
  for (const name of personal) linkPersonalEntry(join(source, name), join(dest, name));
187
- const linked = new Set(personal);
188
+ // Native supporting images belong to this agent, not the person's shared
189
+ // image directory. Repair homes created before this distinction.
190
+ const generated = join(dest, 'generated_images');
191
+ try { if (lstatSync(generated).isSymbolicLink()) rmSync(generated); } catch { /* absent */ }
192
+ mkdirSync(generated, { recursive: true, mode: 0o700 });
193
+ const linked = new Set([...personal, 'generated_images']);
188
194
  for (const name of readdirSync(dest)) {
189
195
  if (linked.has(name) || name === 'config.toml') continue;
190
196
  // Only a LINK is ours to drop: a real entry under a name the person has
@@ -205,6 +211,212 @@ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
205
211
  return { path: dest, personalDeveloperInstructions };
206
212
  }
207
213
 
214
+ /** The environment names a fenced Codex turn's MCP header values ride under,
215
+ * one per header (`codexFencedMount`). */
216
+ export const MCP_HEADER_ENV_PREFIX = 'FLOWVIANT_MCP_HEADER_';
217
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
218
+ const SERVER_NAME = /^[A-Za-z0-9_-]{1,80}$/;
219
+ const expands = (value) => /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(String(value));
220
+ /**
221
+ * Names this machine's Codex, or the shell and tools under it, read for
222
+ * themselves. A server's env value rides in the CLI's own environment
223
+ * (`env_vars` forwards it), so a name the machine does not already set to that
224
+ * value would change how the CLI itself runs — a proxy, a CA bundle, a
225
+ * provider key. Such a server is left unmounted, in words.
226
+ */
227
+ const CLI_ENV =
228
+ /^(PATH|HOME|SHELL|USER|LOGNAME|PWD|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|DISPLAY|WAYLAND_DISPLAY|BROWSER|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|RUST_LOG|RUST_BACKTRACE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy)$|^(LC|XDG|LD|DYLD|OPENAI|CODEX|AZURE_OPENAI|FLOWVIANT)_/;
229
+
230
+ /** This machine's own Codex servers: its config.toml's `[mcp_servers.*]`,
231
+ * read the way codexPersonal.mjs reads the login (bounded; a file that does
232
+ * not parse carries nothing). */
233
+ function machineCodexServers(machineEnv) {
234
+ try {
235
+ const path = join(resolve(codexHome(machineEnv)), 'config.toml');
236
+ const st = statSync(path);
237
+ if (!st.isFile() || st.size > 1024 * 1024) return [];
238
+ const servers = parseCodexToml(readFileSync(path, 'utf8'))?.mcp_servers;
239
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
240
+ return Object.entries(servers).filter(([, v]) => v && typeof v === 'object' && !Array.isArray(v));
241
+ } catch {
242
+ return [];
243
+ }
244
+ }
245
+ const tomlScalar = (v) => (v instanceof TomlRaw ? v.text : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
246
+ const words = (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null);
247
+ const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
248
+
249
+ /**
250
+ * THE MCP SERVERS ON A FENCED CODEX TURN — the project's and, since 2026-10-11,
251
+ * the machine's own (the owner: "the server that is running flowviant's claude
252
+ * HAS these MCPs yet the agents are not using them"). A Codex build reads both
253
+ * from the agent's config.toml (`isolatedCodexHome`: the person's own tables
254
+ * kept, the repo's beside them). Every fenced posture — write-up, mockup,
255
+ * 3D model, deck, image — passes `--ignore-user-config`, which drops that
256
+ * file, and a `-p` profile layered beside the flag was measured to load no
257
+ * server either (codex-cli 0.159.2, artifactProjectMcp.test.mjs). So these
258
+ * turns take the servers as `-c` pairs, the way codexPersonal.mjs carries a
259
+ * login back, under the same rule: A LITERAL SECRET NEVER RIDES ARGV.
260
+ *
261
+ * · A stdio server's command and arguments ride as written: they are the
262
+ * server process's own command line once Codex starts it, so `-c` adds no
263
+ * reader. A project server whose command or arguments expand a `${VAR}`
264
+ * is still left unmounted (the 2026-10-10 audit took expanded values off
265
+ * argv, and this keeps that line).
266
+ * · Every env value rides in the turn's ENVIRONMENT under its own name,
267
+ * forwarded by Codex's `env_vars` — except a project value with no `${VAR}`,
268
+ * which is text of the reviewed file and rides as written. Refused (the
269
+ * server is left unmounted, with the reason) when the name is the
270
+ * daemon's own (`FLOWVIANT_*`, `CODEX_HOME`), when this machine already
271
+ * sets it to something else, or when it is one the CLI reads itself
272
+ * (`CLI_ENV`) and the machine does not set it.
273
+ * · Every header value rides in the environment, named by
274
+ * `env_http_headers`. A machine server's own `env_http_headers`,
275
+ * `bearer_token_env_var` and `env_vars` are names, and ride as written.
276
+ * · A URL that expands a `${VAR}`, or carries a query, fragment or
277
+ * credentials, has no indirection in Codex's config: left unmounted.
278
+ * · A machine server keeps its own `cwd`, timeouts and tool lists; it is
279
+ * skipped where the person turned it off (`enabled = false`), and a
280
+ * project server of the same name replaces it, as on the build.
281
+ * · Each mounted server's tools are approved by name: an unannotated tool
282
+ * under a fenced posture failed "MCP tool call requires approval, but
283
+ * approval policy is never" (measured, 0.159.2) — never a blanket
284
+ * approval policy.
285
+ *
286
+ * `usable` is the base snapshot's ready entries; `expand` reads a value the
287
+ * way projectTools.mjs does; `machineEnv` is the caller's own (what the child
288
+ * would inherit). Returns `{ args, env, mounted, machine, unmounted }`:
289
+ * `mounted` the project servers, `machine` the machine's own, and
290
+ * `unmounted` `[{ name, reason, source: 'project' | 'machine' }]`.
291
+ */
292
+ function codexFencedMount(usable, expand, machineEnv) {
293
+ const args = [];
294
+ const childEnv = {};
295
+ const mounted = [];
296
+ const machine = [];
297
+ const unmounted = [];
298
+ let header = 0;
299
+ /** A refusal sentence, or null with the value staged in `extra`. */
300
+ const forward = (key, value, extra) => {
301
+ if (key.startsWith('FLOWVIANT_') || key === 'CODEX_HOME') return `its env ${key} is a name this daemon keeps for itself`;
302
+ const has = machineEnv[key];
303
+ if (has !== undefined) return has === value ? null : `its env ${key} would replace this machine's own ${key}, which a fenced Codex turn cannot do`;
304
+ if (CLI_ENV.test(key)) return `its env ${key} is one this machine's Codex reads itself, which a fenced Codex turn cannot set`;
305
+ const staged = childEnv[key] ?? extra[key];
306
+ if (staged !== undefined && staged !== value) return `its env ${key} would replace another server's ${key}`;
307
+ extra[key] = value;
308
+ return null;
309
+ };
310
+ const headerEnv = (headers, extra) =>
311
+ Object.entries(headers).map(([key, value]) => {
312
+ const envName = `${MCP_HEADER_ENV_PREFIX}${header++}`;
313
+ extra[envName] = value;
314
+ return `${toml(key)}=${toml(envName)}`;
315
+ });
316
+ const accept = (name, pairs, extra, list) => {
317
+ args.push(...pairs, '-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`);
318
+ Object.assign(childEnv, extra);
319
+ list.push(name);
320
+ };
321
+
322
+ for (const { name, config } of usable) {
323
+ const pairs = [];
324
+ const extra = {};
325
+ let refused = null;
326
+ if (typeof config.command === 'string') {
327
+ const rawArgs = Array.isArray(config.args) ? config.args : [];
328
+ if (expands(config.command) || rawArgs.some(expands)) {
329
+ refused = 'its command or arguments name a ${VAR}, which a fenced Codex turn would put on the command line';
330
+ } else {
331
+ pairs.push('-c', `mcp_servers.${name}.command=${toml(String(config.command))}`);
332
+ pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map((v) => toml(String(v))).join(',')}]`);
333
+ const forwarded = [];
334
+ for (const [key, value] of Object.entries(config.env ?? {})) {
335
+ if (!ENV_NAME.test(key)) continue;
336
+ if (!expands(value)) {
337
+ pairs.push('-c', `mcp_servers.${name}.env.${key}=${toml(String(value))}`);
338
+ continue;
339
+ }
340
+ refused = forward(key, expand(value), extra);
341
+ if (refused) break;
342
+ forwarded.push(key);
343
+ }
344
+ if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
345
+ }
346
+ } else if (typeof config.url === 'string') {
347
+ if (expands(config.url)) {
348
+ refused = 'its URL names a ${VAR}, which a fenced Codex turn would put on the command line';
349
+ } else {
350
+ pairs.push('-c', `mcp_servers.${name}.url=${toml(String(config.url))}`);
351
+ const named = headerEnv(Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)])), extra);
352
+ if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
353
+ }
354
+ } else continue;
355
+ if (refused) unmounted.push({ name, reason: refused, source: 'project' });
356
+ else accept(name, pairs, extra, mounted);
357
+ }
358
+
359
+ const projectNames = new Set(usable.map((t) => t.name));
360
+ for (const [name, server] of machineCodexServers(machineEnv)) {
361
+ if (projectNames.has(name) || name === 'flowviant' || server.enabled === false) continue;
362
+ if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
363
+ const pairs = [];
364
+ const extra = {};
365
+ let refused = SERVER_NAME.test(name) ? null : 'its name cannot be spelled as a command-line setting';
366
+ if (!refused && typeof server.command === 'string') {
367
+ const rawArgs = server.args === undefined ? [] : words(server.args);
368
+ if (!rawArgs) refused = 'its args are not a list of words';
369
+ else {
370
+ pairs.push('-c', `mcp_servers.${name}.command=${toml(server.command)}`);
371
+ pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map(toml).join(',')}]`);
372
+ const forwarded = [...(words(server.env_vars) ?? []).filter((k) => ENV_NAME.test(k))];
373
+ for (const [key, value] of Object.entries(table(server.env))) {
374
+ if (!ENV_NAME.test(key) || typeof value !== 'string') {
375
+ refused = `its env ${String(key).slice(0, 40)} is not a name and text`;
376
+ break;
377
+ }
378
+ refused = forward(key, value, extra);
379
+ if (refused) break;
380
+ if (!forwarded.includes(key)) forwarded.push(key);
381
+ }
382
+ if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
383
+ if (typeof server.cwd === 'string') pairs.push('-c', `mcp_servers.${name}.cwd=${toml(server.cwd)}`);
384
+ }
385
+ } else if (!refused) {
386
+ let url = null;
387
+ try { url = new URL(server.url); } catch { /* refused below */ }
388
+ if (!url || url.search || url.hash || url.username || url.password) {
389
+ refused = 'its URL carries a query, fragment or credentials, which a fenced Codex turn would put on the command line';
390
+ } else {
391
+ pairs.push('-c', `mcp_servers.${name}.url=${toml(server.url)}`);
392
+ if (typeof server.bearer_token_env_var === 'string' && ENV_NAME.test(server.bearer_token_env_var))
393
+ pairs.push('-c', `mcp_servers.${name}.bearer_token_env_var=${toml(server.bearer_token_env_var)}`);
394
+ const literal = table(server.http_headers);
395
+ const byName = table(server.env_http_headers);
396
+ if (Object.values(literal).some((v) => typeof v !== 'string') || Object.values(byName).some((v) => typeof v !== 'string' || !ENV_NAME.test(v))) {
397
+ refused = 'its headers are not text';
398
+ } else {
399
+ const named = [...headerEnv(literal, extra), ...Object.entries(byName).map(([key, envName]) => `${toml(key)}=${toml(envName)}`)];
400
+ if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
401
+ }
402
+ }
403
+ }
404
+ if (!refused) {
405
+ for (const key of ['startup_timeout_sec', 'startup_timeout_ms', 'tool_timeout_sec']) {
406
+ const v = tomlScalar(server[key]);
407
+ if (v != null && /^[0-9_.]+$/.test(v)) pairs.push('-c', `mcp_servers.${name}.${key}=${v}`);
408
+ }
409
+ for (const key of ['enabled_tools', 'disabled_tools']) {
410
+ const list = words(server[key]);
411
+ if (list) pairs.push('-c', `mcp_servers.${name}.${key}=[${list.map(toml).join(',')}]`);
412
+ }
413
+ }
414
+ if (refused) unmounted.push({ name, reason: refused, source: 'machine' });
415
+ else accept(name, pairs, extra, machine);
416
+ }
417
+ return { args, env: childEnv, mounted, machine, unmounted };
418
+ }
419
+
208
420
  /** Claude takes the base branch's skills as a plugin written for this turn. */
209
421
  function reviewedClaudePlugin(dir, snapshot) {
210
422
  if (!snapshot.skillFiles.length) return null;
@@ -245,6 +457,8 @@ const TOOL_RUNTIMES = {
245
457
  const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
246
458
  return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
247
459
  },
460
+ // A fenced posture ignores that home's config.toml (2026-10-10).
461
+ fencedMount: codexFencedMount,
248
462
  },
249
463
  antigravity: { mcp: false, skills: false },
250
464
  };
@@ -253,6 +467,16 @@ const TOOL_RUNTIMES = {
253
467
  const takesNothing = { mcp: false, skills: false };
254
468
  const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES[runner] : takesNothing);
255
469
 
470
+ /** The servers as a fenced posture takes them where it ignores the runner's
471
+ * own file — Codex's `-c` pairs and environment (`codexFencedMount`) — or
472
+ * null for a runner whose fenced turns read the same file as its build
473
+ * (Claude's strict mcp.json) or that takes none. `usable` is the base
474
+ * snapshot's ready entries; `expand(value)` reads a `${VAR}`. */
475
+ export function fencedMountFor(runner, usable, expand, machineEnv = process.env) {
476
+ const mount = rowFor(runner).fencedMount;
477
+ return mount ? mount(usable, expand, machineEnv) : null;
478
+ }
479
+
256
480
  /** Pure mapping used before a future tool-attach gesture is offered. */
257
481
  export function runnerToolCapabilities(runner) {
258
482
  const { mcp, skills } = rowFor(runner);
@@ -1,10 +1,10 @@
1
1
  /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
- import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { delimiter, isAbsolute, join } from 'node:path';
2
+ import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { git, gitRaw, splitNul } from './git.mjs';
6
6
  import { withoutMachineCredentials } from './machineEnv.mjs';
7
- import { mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
7
+ import { fencedMountFor, mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
8
8
 
9
9
  const MAX_TOOLS = 40;
10
10
  const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
@@ -124,6 +124,7 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
124
124
 
125
125
  const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
126
126
 
127
+
127
128
  /**
128
129
  * Temporary per-turn files are removed after the last retry. No personal CLI config is written.
129
130
  *
@@ -135,12 +136,19 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
135
136
  *
136
137
  * NO EXPANDED VALUE RIDES ARGV (2026-10-10, the audit): Claude's servers go
137
138
  * to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
138
- * (projectToolRuntimes.mjs) — never `-c` pairs on the command line.
139
+ * (projectToolRuntimes.mjs) — never `-c` pairs on the command line. A fenced
140
+ * Codex turn, which ignores that file, takes `fencedMount` instead: the
141
+ * repo's own words as `-c` pairs and every expanded value in its environment.
139
142
  */
140
143
  export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
141
144
  const env = expansionEnv(callerEnv);
142
145
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
143
146
  const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
147
+ // What the base lists and this turn cannot mount, and why — the turn is told
148
+ // (postureTools.mjs), so a missing server is a named fact, never a guess.
149
+ const mcpUnmounted = snapshot.tools
150
+ .filter((t) => !usable.includes(t))
151
+ .map((t) => ({ name: t.name, reason: String(mcpRefusal(runner, t.config) ?? t.reason ?? 'not mounted').slice(0, 200) }));
144
152
  const mcpServers = {};
145
153
  for (const { name, config } of usable) {
146
154
  if (typeof config.command === 'string') {
@@ -177,6 +185,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
177
185
  const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
178
186
  return {
179
187
  mcpPath,
188
+ /** The names of the servers mounted, as Claude's allow rules need them. */
189
+ mcpServers: Object.keys(mcpServers),
190
+ mcpUnmounted,
191
+ /** The mount a fenced turn takes where the runner's file is ignored
192
+ * (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
193
+ fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
180
194
  instructions,
181
195
  pluginDir,
182
196
  codexHome,
@@ -197,3 +211,46 @@ export function toolReadout(snapshot, runner) {
197
211
  }));
198
212
  return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
199
213
  }
214
+
215
+ const MCP_JSON_MAX_BYTES = 1024 * 1024;
216
+ const MCP_JSON_MAX_NAMES = 500;
217
+ /**
218
+ * EVERY SERVER NAMED IN A `.mcp.json` FROM `dir` UP TO `/` (2026-10-11). An
219
+ * agent turn that mounts the machine's MCP servers is not strict, and Claude
220
+ * Code then reads `.mcp.json` in its cwd and every parent wherever a settings
221
+ * scope approves one (measured, 2.1.290). The base branch's copy is the
222
+ * project's only `.mcp.json` input to an agent, so each of these names is
223
+ * disabled for the turn (claudePosture.mjs `withMachineMcp`): the agent's edit
224
+ * of its worktree's file mounts only after review lands it on base. A server
225
+ * the machine should have everywhere belongs in its CLI's user scope.
226
+ *
227
+ * NULL when the answer cannot be bounded — a `.mcp.json` over 1 MiB, or more
228
+ * than 500 names — and the lane then mounts strictly (the base copy alone)
229
+ * rather than leave one discovered. A file that does not parse names nothing:
230
+ * the CLI cannot read it either.
231
+ */
232
+ export function mcpJsonServerNamesAbove(dir) {
233
+ const names = new Set();
234
+ let at = resolve(dir);
235
+ for (;;) {
236
+ const file = join(at, '.mcp.json');
237
+ let raw = null;
238
+ try {
239
+ const st = statSync(file);
240
+ if (st.isFile()) {
241
+ if (st.size > MCP_JSON_MAX_BYTES) return null;
242
+ raw = readFileSync(file, 'utf8');
243
+ }
244
+ } catch { /* none here */ }
245
+ if (raw != null) {
246
+ try {
247
+ const servers = JSON.parse(raw)?.mcpServers;
248
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) for (const name of Object.keys(servers)) names.add(name);
249
+ } catch { /* the CLI cannot parse it either */ }
250
+ }
251
+ if (names.size > MCP_JSON_MAX_NAMES) return null;
252
+ const up = dirname(at);
253
+ if (up === at) return [...names];
254
+ at = up;
255
+ }
256
+ }