flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -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 +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -0,0 +1,70 @@
1
+ /** The planner declares prerequisites; the daemon enforces them without reading
2
+ * human prose. Declared collisions use the same compatible groups; evidence stays on the wire. */
3
+ export function groupDependencies(agents, dependencies, collisions) {
4
+ if (dependencies === undefined && collisions === undefined) return agents;
5
+ if (dependencies === undefined) dependencies = [];
6
+ if (collisions === undefined) collisions = [];
7
+ if (!Array.isArray(collisions) || collisions.length > 1200) return null;
8
+ if (!Array.isArray(dependencies) || dependencies.length > 1200) return null;
9
+ if (!dependencies.length && !collisions.length) return agents;
10
+ const owner = new Map();
11
+ for (const [i, group] of agents.entries()) {
12
+ for (const id of group.taskIds) {
13
+ if (owner.has(id)) return null;
14
+ owner.set(id, i);
15
+ }
16
+ }
17
+ const parents = agents.map((_, i) => i);
18
+ const root = (i) => {
19
+ while (parents[i] !== i) i = parents[i];
20
+ return i;
21
+ };
22
+ const needs = new Map();
23
+ for (const edge of dependencies) {
24
+ if (!edge || !owner.has(edge.taskId) || !Array.isArray(edge.requires) || edge.requires.length > 60) return null;
25
+ for (const id of edge.requires) {
26
+ if (!owner.has(id) || id === edge.taskId) return null;
27
+ const a = root(owner.get(edge.taskId));
28
+ const b = root(owner.get(id));
29
+ parents[Math.max(a, b)] = Math.min(a, b);
30
+ if (!needs.has(edge.taskId)) needs.set(edge.taskId, new Set());
31
+ needs.get(edge.taskId).add(id);
32
+ }
33
+ }
34
+ for (const collision of collisions) {
35
+ if (!collision || !Array.isArray(collision.taskIds) || new Set(collision.taskIds).size < 2 || collision.taskIds.length > 60) return null;
36
+ for (const id of collision.taskIds) {
37
+ if (!owner.has(id)) return null;
38
+ const a = root(owner.get(collision.taskIds[0]));
39
+ const b = root(owner.get(id));
40
+ parents[Math.max(a, b)] = Math.min(a, b);
41
+ }
42
+ }
43
+ const groups = new Map();
44
+ for (const [i, group] of agents.entries()) {
45
+ const key = root(i);
46
+ if (!groups.has(key)) groups.set(key, []);
47
+ groups.get(key).push(group);
48
+ }
49
+ const result = [];
50
+ for (const members of groups.values()) {
51
+ const attached = [...new Set(members.map((g) => g.intoAgentId).filter(Boolean))];
52
+ if (attached.length > 1) return null;
53
+ const pending = members.flatMap((g) => g.taskIds);
54
+ if (pending.length > 60) return null;
55
+ const taskIds = [];
56
+ // Stable topological order: unrelated cards keep their proposed order.
57
+ while (pending.length) {
58
+ const at = pending.findIndex((id) => [...(needs.get(id) ?? [])].every((need) => taskIds.includes(need)));
59
+ if (at < 0) return null; // a cycle cannot become a runnable proposal
60
+ taskIds.push(...pending.splice(at, 1));
61
+ }
62
+ const budget = members.reduce((sum, g) => sum + (g.pointsBudget ?? 0), 0);
63
+ result.push({ ...members[0], taskIds,
64
+ ...(members.some(g => g.rationale) ? { rationale: members.map(g => g.rationale).filter(Boolean).join('\n').slice(0, 1000) } : {}),
65
+ ...(budget ? { pointsBudget: Math.min(budget, 100_000) } : {}),
66
+ ...(attached.length ? { intoAgentId: attached[0] } : {}),
67
+ });
68
+ }
69
+ return result;
70
+ }
@@ -0,0 +1,85 @@
1
+ /** Per-turn readout of mounted tools and remaining permission boundaries.
2
+ * Kind selects the delivery contract; CLI/configuration and the filesystem
3
+ * fence determine supporting capabilities. Keep these claims beside the
4
+ * actual builders and the spawned-lane tests in artifactProjectMcp.test.mjs.
5
+ */
6
+
7
+ /** What the machine's own MCP servers are, per CLI. */
8
+ const MACHINE = {
9
+ claude: 'the MCP servers and claude.ai connectors this machine’s Claude Code has (its user and local servers)',
10
+ codex: 'the MCP servers in this machine’s Codex config',
11
+ };
12
+
13
+ /** Per posture and CLI: what the card has and what it lacks. */
14
+ const POSTURES = {
15
+ research: {
16
+ claude: {
17
+ has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
18
+ lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
19
+ },
20
+ codex: {
21
+ has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
22
+ lacks: 'no host files outside that copy except native generated images; native image generation depends on this CLI and account',
23
+ },
24
+ },
25
+ design: {
26
+ claude: {
27
+ has: 'the repository’s files (never .env files), the project’s knowledge library, guarded read commands, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
28
+ lacks: 'no measured native image-generation tool, no delegation, no helper scripts or browsers, and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
29
+ },
30
+ codex: {
31
+ has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
32
+ lacks: 'no native web on this broad-read fence (host credentials are readable); use a configured project web MCP server when available',
33
+ },
34
+ },
35
+ image: {
36
+ codex: {
37
+ has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
38
+ lacks: 'no web',
39
+ },
40
+ },
41
+ };
42
+
43
+ /** Configuration supplies tools; a skill supplies instructions, not a tool. */
44
+ const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not which supporting capabilities
45
+ may be used. Use available tools needed for the person's request, including
46
+ native image generation for requested textures or illustrations. A listed skill
47
+ is not proof that its tool is installed, mounted or entitled on this account.
48
+ Do not re-file the kind or require a separate image card just to use a tool.
49
+ If a needed tool is absent or fails, relay the measured limitation and ask for
50
+ the missing configuration or a decision. A separately billed CLI/API fallback
51
+ requires the person's explicit choice first; never default to OPENAI_API_KEY.
52
+ Use personal connectors only for the requested work; sending messages or other
53
+ external writes requires explicit authorization. Never open the person's browser
54
+ or desktop; headless inspection must stay within the granted filesystem fence.
55
+ Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.`;
56
+
57
+ const list = (names) => names.join(', ');
58
+
59
+ /**
60
+ * The note for a turn, or '' when its posture withholds nothing to say.
61
+ * `mounted` is the names of the base `.mcp.json` servers this turn mounted;
62
+ * `machine` whether the machine's own servers were mounted beside them (a
63
+ * Claude turn whose `.mcp.json` walk could not be bounded mounts the base copy
64
+ * alone); `unmounted` is `[{ name, reason, source }]` for the servers it
65
+ * could not mount — `source` 'project' (the base `.mcp.json`) or 'machine'.
66
+ */
67
+ export function postureToolsNote({ posture, runtime, mounted = [], machine = true, unmounted = [] }) {
68
+ const row = POSTURES[posture]?.[runtime];
69
+ if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration. ${SUPPORTING_CAPABILITIES.replace('Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.', 'Delegate only when the person explicitly requests it.')} ` : '';
70
+ const fromProject = unmounted.filter((u) => u.source !== 'machine');
71
+ const fromMachine = unmounted.filter((u) => u.source === 'machine');
72
+ const project = mounted.length
73
+ ? `The project’s MCP servers from the base branch’s .mcp.json are mounted: ${list(mounted)}.`
74
+ : fromProject.length
75
+ ? 'None of the project’s MCP servers is mounted on this machine.'
76
+ : 'The base branch’s .mcp.json lists no MCP server.';
77
+ const own = machine
78
+ ? ` So are ${MACHINE[runtime]}.`
79
+ : ` ${MACHINE[runtime][0].toUpperCase()}${MACHINE[runtime].slice(1)} are not mounted on this turn.`;
80
+ const missing = (rows, where) => (rows.length ? ` ${where}, but not mounted here: ${list(rows.map(({ name, reason }) => `${name} (${reason})`))}.` : '');
81
+ return `TOOLS ON THIS CARD. You have ${row.has}. You have ${row.lacks}. ${project}${own}${missing(fromProject, 'Listed in the base branch’s .mcp.json')}${missing(fromMachine, `In ${MACHINE[runtime]}`)} A server added to .mcp.json only in this worktree is not mounted; the base branch’s file is the one that counts.
82
+ There is no Flowviant setting that adds a tool, so never invent one. A missing
83
+ server can be configured in the base branch's .mcp.json or the machine's CLI.
84
+ ${SUPPORTING_CAPABILITIES}`;
85
+ }
@@ -51,6 +51,9 @@
51
51
  * without GitHub; nothing in the daemon passes them except the fetch.
52
52
  */
53
53
 
54
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
55
+ import { tmpdir } from 'node:os';
56
+ import { join } from 'node:path';
54
57
  import { execFileSync } from 'node:child_process';
55
58
  import { git, stripUrlUserinfo } from './git.mjs';
56
59
  import { scrub } from './uplinkScrub.mjs';
@@ -232,3 +235,15 @@ export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fet
232
235
  const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
233
236
  return landed ? { ok: true } : { notOnBase: true };
234
237
  }
238
+
239
+ /** Cross-links are written only to verified PR URLs, before any PR lands. */
240
+ export function linkRepositoryPrs(url, { cwd, cards, order, repositories, states, gh = runGh }) {
241
+ if (!PR_URL_RE.test(url)) throw new Error('GitHub did not return a verified pull request URL.');
242
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-pr-'));
243
+ try {
244
+ const file = join(dir, 'body.md');
245
+ const body = `Flowviant cards: ${cards.map((c) => c.url ? `[${c.id}](${c.url})` : c.id).join(', ')}\n\nMerge order:\n${order.map((id, i) => `${i + 1}. ${repositories.find((r) => r.id === id)?.name ?? id}${states[id]?.prUrl ? ` — ${states[id].prUrl}` : ''}`).join('\n')}\n\n${cards.map((c) => c.mergeOrderReason).filter(Boolean).join('\n')}\n`;
246
+ writeFileSync(file, body, { mode: 0o600 });
247
+ gh(['pr', 'edit', url, '--body-file', file], { cwd, timeout: 60_000 });
248
+ } finally { rmSync(dir, { recursive: true, force: true }); }
249
+ }
@@ -0,0 +1,16 @@
1
+ /** Each repository observes its own base, including PRs merged outside this daemon. */
2
+ import { createLandedObserver } from './landed.mjs';
3
+ import { gitNetAsync } from './git.mjs';
4
+ export function createProjectLandedObserver({ repoRoot, baseRef, repositories }) {
5
+ const legacy = createLandedObserver({ repoRoot, baseRef });
6
+ const observers = new Map();
7
+ return { observe: async () => {
8
+ if (!repositories?.multiple()) return legacy.observe();
9
+ for (const repo of repositories.list().filter((r) => r.repoRoot && r.baseRef)) {
10
+ const key = `${repo.id}:${repo.repoRoot}`;
11
+ if (!observers.has(key)) observers.set(key, createLandedObserver({ repoRoot: repo.repoRoot, baseRef: () => repositories.list().find((r) => r.id === repo.id)?.baseRef, repo: repo.id }));
12
+ try { await gitNetAsync(['fetch', 'origin', '--quiet'], repo.repoRoot); } catch { /* local-only or offline */ }
13
+ await observers.get(key).observe();
14
+ }
15
+ } };
16
+ }
@@ -0,0 +1,60 @@
1
+ /** The project's catalog joined to this machine's credential bindings. */
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { printableLine } from './printable.mjs';
4
+ import { listStoredProjects } from './credentials.mjs';
5
+ import { checkoutIdentity } from './checkoutIdentity.mjs';
6
+ import { detectBaseRef, gitOrNull, usableBaseRef, baseBranchName } from './git.mjs';
7
+
8
+ export function storedProject(token) {
9
+ return listStoredProjects().find((entry) => entry.fleetToken === token) ?? null;
10
+ }
11
+
12
+ function address(value) {
13
+ let remote = String(value ?? '');
14
+ try {
15
+ const url = new URL(remote);
16
+ if (url.hostname.toLowerCase() === 'github.com') remote = url.pathname.replace(/^\//, '');
17
+ } catch { /* full name or scp remote */ }
18
+ remote = remote.replace(/^(?:git@)?github\.com:/i, '').replace(/\.git\/?$/i, '');
19
+ return /^[\w.-]+\/[\w.-]+$/.test(remote) ? remote.toLowerCase() : remote;
20
+ }
21
+
22
+ export function createProjectRepositories({ entry, repoRoot, getEntry = () => entry }) {
23
+ let catalog = null;
24
+ const learn = (rows) => {
25
+ if (!Array.isArray(rows)) return;
26
+ if (!rows.length || rows.length > 64 || rows.some((r) => !r || typeof r.id !== 'string' || typeof r.name !== 'string') ||
27
+ rows.filter((r) => r.isPrimary).length !== 1)
28
+ throw new Error('The project repository catalog is invalid.');
29
+ catalog = rows;
30
+ };
31
+ const list = () => {
32
+ const bindings = Object.entries(getEntry()?.repositories ?? {});
33
+ return (catalog ?? []).map((repo) => {
34
+ // Match remotes as well as names: a rename must not strand a binding,
35
+ // and reusing a short name must never select a different git repository.
36
+ const matches = bindings.filter(([name, root]) => {
37
+ if (!existsSync(root)) return false;
38
+ return repo.remote ? address(checkoutIdentity(root).remote) === address(repo.remote) : name === repo.name;
39
+ });
40
+ const root = matches.find(([name]) => name === repo.name)?.[1] ?? matches[0]?.[1] ??
41
+ (catalog.length === 1 && bindings.length === 0 ? repoRoot : null);
42
+ const checkout = root && gitOrNull(['rev-parse', '--show-toplevel'], root);
43
+ const available = checkout && realpathSync(checkout) === realpathSync(root);
44
+ return { ...repo, repoRoot: available ? root : null,
45
+ baseRef: available ? (repo.baseBranch ? usableBaseRef(root, `origin/${baseBranchName(repo.baseBranch)}`) : detectBaseRef(root)) : null };
46
+ });
47
+ };
48
+ const targets = (job) => {
49
+ const rows = list();
50
+ const ids = job.repos ?? job.task?.repos;
51
+ return Array.isArray(ids) && ids.length ? [...new Set(ids)] : rows.filter((r) => r.isPrimary).map((r) => r.id);
52
+ };
53
+ const missing = (job) => {
54
+ if (!catalog) return null; // an older server, not a measurement of absence
55
+ const rows = list();
56
+ const absent = targets(job).filter((id) => !rows.find((r) => r.id === id)?.repoRoot);
57
+ return absent.length ? printableLine(`Repositories not checked out on this machine: ${absent.map((id) => rows.find((r) => r.id === id)?.name ?? id).join(', ')}. Run flowviant login in each checkout to connect it.`, 4000) : null;
58
+ };
59
+ return { learn, list, targets, missing, multiple: () => (catalog?.length ?? 0) > 1 };
60
+ }
@@ -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
- export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
143
+ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null, turnServers = {} } = {}) {
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') {
@@ -154,6 +162,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
154
162
  mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
155
163
  }
156
164
  }
165
+ Object.assign(mcpServers, turnServers); // daemon-owned names cannot be replaced by project config
157
166
  const mcpPath = join(dir, 'mcp.json');
158
167
  writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
159
168
  const pointers = [];
@@ -177,6 +186,12 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
177
186
  const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
178
187
  return {
179
188
  mcpPath,
189
+ /** The names of the servers mounted, as Claude's allow rules need them. */
190
+ mcpServers: Object.keys(mcpServers),
191
+ mcpUnmounted,
192
+ /** The mount a fenced turn takes where the runner's file is ignored
193
+ * (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
194
+ fencedMount: fencedMountFor(runner, usable, (value) => expand(value, env), callerEnv),
180
195
  instructions,
181
196
  pluginDir,
182
197
  codexHome,
@@ -197,3 +212,46 @@ export function toolReadout(snapshot, runner) {
197
212
  }));
198
213
  return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
199
214
  }
215
+
216
+ const MCP_JSON_MAX_BYTES = 1024 * 1024;
217
+ const MCP_JSON_MAX_NAMES = 500;
218
+ /**
219
+ * EVERY SERVER NAMED IN A `.mcp.json` FROM `dir` UP TO `/` (2026-10-11). An
220
+ * agent turn that mounts the machine's MCP servers is not strict, and Claude
221
+ * Code then reads `.mcp.json` in its cwd and every parent wherever a settings
222
+ * scope approves one (measured, 2.1.290). The base branch's copy is the
223
+ * project's only `.mcp.json` input to an agent, so each of these names is
224
+ * disabled for the turn (claudePosture.mjs `withMachineMcp`): the agent's edit
225
+ * of its worktree's file mounts only after review lands it on base. A server
226
+ * the machine should have everywhere belongs in its CLI's user scope.
227
+ *
228
+ * NULL when the answer cannot be bounded — a `.mcp.json` over 1 MiB, or more
229
+ * than 500 names — and the lane then mounts strictly (the base copy alone)
230
+ * rather than leave one discovered. A file that does not parse names nothing:
231
+ * the CLI cannot read it either.
232
+ */
233
+ export function mcpJsonServerNamesAbove(dir) {
234
+ const names = new Set();
235
+ let at = resolve(dir);
236
+ for (;;) {
237
+ const file = join(at, '.mcp.json');
238
+ let raw = null;
239
+ try {
240
+ const st = statSync(file);
241
+ if (st.isFile()) {
242
+ if (st.size > MCP_JSON_MAX_BYTES) return null;
243
+ raw = readFileSync(file, 'utf8');
244
+ }
245
+ } catch { /* none here */ }
246
+ if (raw != null) {
247
+ try {
248
+ const servers = JSON.parse(raw)?.mcpServers;
249
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) for (const name of Object.keys(servers)) names.add(name);
250
+ } catch { /* the CLI cannot parse it either */ }
251
+ }
252
+ if (names.size > MCP_JSON_MAX_NAMES) return null;
253
+ const up = dirname(at);
254
+ if (up === at) return [...names];
255
+ at = up;
256
+ }
257
+ }