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
@@ -141,9 +141,54 @@ export function normalizeMcpReport(entries, { charset = null } = {}) {
141
141
  .slice(0, MAX_MCP_SERVERS);
142
142
  }
143
143
 
144
- export function recordMcpServers(list) {
144
+ /**
145
+ * EVERY SERVER CLAUDE CODE REPORTED, CONNECTED ONES INCLUDED, WITH ITS SOURCE
146
+ * AND — WHEN CONNECTED — ITS TOOLS' NAMES (2026-10-13, the owner: name every
147
+ * MCP server and capability each CLI gives the project). Learned off the same
148
+ * init as the list above, under the same harvest rule; it leaves the box only
149
+ * on this box's own `tools-report` (mcpInventory.mjs), never on the poll, and
150
+ * carries names, statuses and sources alone. The header's "only what is not
151
+ * connected leaves the box" still holds for the poll's `mcp`, which an older
152
+ * app reads.
153
+ *
154
+ * `scope` is the CLI's own word (measured on Claude Code 2.1.296's scope list:
155
+ * local, user, project, dynamic, enterprise, claudeai, managed, agent);
156
+ * mcpInventory.mjs maps it to a source. Tool names come from the init's
157
+ * `tools`, where a server's tools are `mcp__<server>__<tool>` and the server
158
+ * part is its name with every character outside `[A-Za-z0-9_-]` made `_`
159
+ * (`claude.ai Gmail` → `claude_ai_Gmail`, measured 2026-10-11).
160
+ */
161
+ let mcpInventoryCache = null;
162
+ const MAX_INVENTORY_SERVERS = 80;
163
+ const mcpToolPrefix = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
164
+
165
+ /** The init's servers and tools → `[{ name, status, scope, tools }]`. */
166
+ export function claudeMcpInventoryOf(list, tools) {
167
+ const names = Array.isArray(tools) ? tools.filter((t) => typeof t === 'string' && t.startsWith('mcp__')) : [];
168
+ const seen = new Map();
169
+ for (const e of list) {
170
+ if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
171
+ const name = e.name.trim().slice(0, MCP_NAME_MAX).trim();
172
+ if (!name || name === 'flowviant' || seen.has(name)) continue;
173
+ const status = MCP_STATUSES.has(e.status) ? e.status : 'other';
174
+ seen.set(name, { name, status, scope: typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
175
+ }
176
+ // The longest prefix wins, so `a_b` never claims `a`'s tools or the reverse.
177
+ const byPrefix = [...seen.values()].sort((a, b) => b.prefix.length - a.prefix.length);
178
+ for (const tool of names) {
179
+ const owner = byPrefix.find((s) => tool.startsWith(s.prefix));
180
+ if (owner?.tools) owner.tools.push(tool.slice(owner.prefix.length));
181
+ }
182
+ return [...seen.values()]
183
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
184
+ .slice(0, MAX_INVENTORY_SERVERS)
185
+ .map(({ prefix, ...server }) => server);
186
+ }
187
+
188
+ export function recordMcpServers(list, tools) {
145
189
  if (!Array.isArray(list)) return;
146
190
  mcpServersCache = normalizeMcpReport(list);
191
+ mcpInventoryCache = claudeMcpInventoryOf(list, tools);
147
192
  }
148
193
 
149
194
  /** What to send on the roster poll as `mcp` — null until a turn has taught us. */
@@ -151,6 +196,12 @@ export function knownMcpServers() {
151
196
  return mcpServersCache;
152
197
  }
153
198
 
199
+ /** Claude Code's whole server list for the box's inventory — null until a
200
+ * turn (or the probe) has taught us. */
201
+ export function knownClaudeMcpInventory() {
202
+ return mcpInventoryCache;
203
+ }
204
+
154
205
  /**
155
206
  * WHICH TURNS ARE A MEASUREMENT OF THE MACHINE (2026-10-10, the audit) —
156
207
  * asked by runTurn.mjs, the one spawn point, of the argv the adapter built.
@@ -339,6 +390,8 @@ export function parseInitLine(line) {
339
390
  skills: Array.isArray(ev.skills) ? ev.skills : null,
340
391
  // The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
341
392
  mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
393
+ // Its tool names, for the inventory's per-server lists (2026-10-13).
394
+ tools: Array.isArray(ev.tools) ? ev.tools : null,
342
395
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
343
396
  };
344
397
  }
@@ -377,17 +430,36 @@ export const SKILL_PROBE_ARGS = Object.freeze([
377
430
  export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
378
431
  if (skillProbeStarted || skillsCache !== null) return;
379
432
  skillProbeStarted = true;
433
+ runInitProbe(cwd, [], spawnChild);
434
+ }
435
+
436
+ /**
437
+ * LOOK AGAIN, NOW, WITH THE BASE BRANCH'S SERVERS MOUNTED (2026-10-13) — the
438
+ * re-check after a confirmed MCP install (mcpInstall.mjs). The same cheap
439
+ * probe, same rules (hooks off, unattended, its group killed), with the base
440
+ * copy added through `--mcp-config` so Claude Code says whether each project
441
+ * server now starts; that is what clears a machine's checklist on the Machines
442
+ * page. A person pressed Install for this, so it is not the once-per-process
443
+ * probe: it runs every time it is asked, which is once per finished install.
444
+ * Resolves when the probe has finished (or given up).
445
+ */
446
+ export function probeMcpNow(cwd, mcpConfigPath, { spawn: spawnChild = spawn } = {}) {
447
+ return new Promise((resolve) => runInitProbe(cwd, mcpConfigPath ? ['--mcp-config', mcpConfigPath] : [], spawnChild, resolve));
448
+ }
449
+
450
+ function runInitProbe(cwd, extraArgs, spawnChild, done = () => {}) {
380
451
  let child;
381
452
  try {
382
453
  // Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
383
454
  // is Claude Code's own field and the `/` tray only renders for claude tabs.
384
- child = spawnChild(RUNTIMES.claude.bin, [...SKILL_PROBE_ARGS], {
455
+ child = spawnChild(RUNTIMES.claude.bin, [...SKILL_PROBE_ARGS, ...extraArgs], {
385
456
  cwd,
386
457
  stdio: ['ignore', 'pipe', 'ignore'],
387
458
  detached: true,
388
459
  env: unattendedEnv(),
389
460
  });
390
461
  } catch {
462
+ done();
391
463
  return; // no claude on PATH — the machine simply stays unmeasured
392
464
  }
393
465
  let buf = '';
@@ -411,6 +483,7 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
411
483
  // AFTER the kill, and on a delay: the transcript is the CHILD's file, so
412
484
  // deleting it while the child still lives races a recreate.
413
485
  removeTranscriptSoon(cwd, sessionId);
486
+ done();
414
487
  };
415
488
  const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
416
489
  timer.unref?.();
@@ -436,7 +509,7 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
436
509
  const init = parseInitLine(line);
437
510
  if (!init) continue;
438
511
  if (init.skills) recordSkills(init.skills);
439
- if (init.mcpServers) recordMcpServers(init.mcpServers);
512
+ if (init.mcpServers) recordMcpServers(init.mcpServers, init.tools);
440
513
  finish(init.sessionId);
441
514
  return;
442
515
  }
@@ -450,5 +523,6 @@ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
450
523
  export function resetCapabilitiesForTest() {
451
524
  skillsCache = null;
452
525
  mcpServersCache = null;
526
+ mcpInventoryCache = null;
453
527
  skillProbeStarted = false;
454
528
  }
@@ -15,6 +15,11 @@ import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
17
  import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings } from './claudePersonal.mjs';
18
+ import { withMachineMcp } from './claudePosture.mjs';
19
+
20
+ /** The profiles an agent lane mounts the machine's MCP servers on (2026-10-11):
21
+ * the ones whose turn profile says `projectMcp` and that Claude declares. */
22
+ const MACHINE_MCP_PROFILES = Object.freeze(['build', 'design', 'research']);
18
23
 
19
24
  function claudeMcp(token, mcpUrl) {
20
25
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -53,8 +58,10 @@ export const CLAUDE_RUNTIME = {
53
58
  * contract was written, not a claim that only Claude could ever satisfy it.
54
59
  */
55
60
  // `design` and `research` (0.97.0): each is an `--allowedTools` list with a
56
- // PATH-SCOPED write (claudePosture.mjs). `research` is Codex's too since
57
- // 0.117.0, over an isolated copy (codexResearch.mjs);
61
+ // PATH-SCOPED write (claudePosture.mjs); an agent's turn mounts the
62
+ // project's base MCP servers (2026-10-10) and this machine's own
63
+ // (2026-10-11). `research` is Codex's too since 0.117.0,
64
+ // over an isolated copy (codexResearch.mjs);
58
65
  // `design` is Codex's too since 0.115.0 (2026-09-29), whose permission
59
66
  // profile was measured to write only the artifacts directory
60
67
  // (runtimeCodex.mjs `codexDesignFence`). agy's flags cannot express "write
@@ -94,7 +101,7 @@ export const CLAUDE_RUNTIME = {
94
101
  * strongest form of it available anywhere: `--append-system-prompt` sits
95
102
  * above the conversation rather than inside it.
96
103
  */
97
- args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings }) {
104
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false }) {
98
105
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
99
106
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
100
107
  // with no vault, or one no rule can name, has no list — refused here in
@@ -131,7 +138,9 @@ export const CLAUDE_RUNTIME = {
131
138
  if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
132
139
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
133
140
  else if (resume) a.push('--continue');
134
- a.push('-p', prompt, '--append-system-prompt', system);
141
+ if (streamInput) a.push('-p', '--input-format', 'stream-json', '--replay-user-messages');
142
+ else a.push('-p', prompt);
143
+ a.push('--append-system-prompt', system);
135
144
  /**
136
145
  * WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
137
146
  *
@@ -174,20 +183,31 @@ export const CLAUDE_RUNTIME = {
174
183
  a.push('--system-prompt-snapshot', 'off');
175
184
  if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
176
185
  }
177
- // These fenced postures have no project MCP, even if Claude discovers a
178
- // .mcp.json in the agent's worktree. Build gets its reviewed base config
179
- // explicitly from the caller; the plan profile keeps its own principal.
180
- if (['consult', 'wiki', 'design', 'research'].includes(profile)) a.push('--strict-mcp-config');
186
+ // WHICH MCP SERVERS A TURN MOUNTS. An agent's code, write-up and
187
+ // mockup/3D-model/deck turns (2026-10-11, the owner) mount this machine's
188
+ // own — its user and local servers and the account's claude.ai connectors —
189
+ // beside the base branch's `.mcp.json` copy the caller passes, so they are
190
+ // not strict; `machineMcp` names every `.mcp.json` server from the
191
+ // worktree up, which `withMachineMcp` disables so a worktree edit never
192
+ // mounts before review (claudePosture.mjs). Every other fenced posture —
193
+ // consult, wiki, and a design/research turn no lane mounted for — stays
194
+ // strict: it never discovers a .mcp.json, nor loads the machine's servers.
195
+ // The plan profile keeps its own principal.
196
+ const machine = Array.isArray(machineMcp) && MACHINE_MCP_PROFILES.includes(profile);
197
+ if (['consult', 'wiki', 'design', 'research'].includes(profile) && !machine && !mcp.includes('--strict-mcp-config')) a.push('--strict-mcp-config');
181
198
  a.push(...mcp, ...resultSchemaArgs);
182
199
  a.push('--model', model || MODEL);
183
200
  if (effort) a.push('--effort', effort);
184
201
  // ALWAYS THE EVENT STREAM (2026-10-10): runTurn reads every turn line by
185
202
  // line — the activity, the init's skills and servers, the result.
186
- a.push('--output-format', 'stream-json', '--verbose');
203
+ // A completed thinking block can arrive minutes after reasoning began.
204
+ // Ask for its start too, so both live surfaces can relay that fact.
205
+ a.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
187
206
  // The knowledge library (0.94.0) sits in the checkout, outside a
188
207
  // worktree's cwd: named as a readable directory so a curated profile
189
208
  // does not refuse the path the prompt just handed it. Before `perm`,
190
209
  // because `--allowedTools` is variadic and would swallow the flag.
210
+ if (profile === 'build') for (const dir of writableDirs) a.push('--add-dir', dir);
191
211
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
192
212
  // THE VAULT, likewise (2026-10-10): it lives under ~/.flowviant/vaults,
193
213
  // outside the wiki worktree, and its `mkdir`/`rm` are path-validated to the
@@ -199,7 +219,8 @@ export const CLAUDE_RUNTIME = {
199
219
  // kept is written to a private file, never onto argv (claudePersonal.mjs)
200
220
  // — so this is the LAST thing built: nothing after it may throw and
201
221
  // leave the file with no turn to release it.
202
- a.push(...(fenced ? withPersonalSettings(perm, personalSettings ?? personalClaudeSettings()) : perm));
222
+ const postured = machine ? withMachineMcp(perm, machineMcp) : perm;
223
+ a.push(...(fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured));
203
224
  return a;
204
225
  },
205
226
  /** What `args` wrote for a turn — the fenced posture's settings file —
@@ -15,6 +15,7 @@ import { parseCodexLine } from './runtimeEvents.mjs';
15
15
  import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
16
16
  import { codexPersonalTurn } from './codexPersonal.mjs';
17
17
  import { researchFence } from './codexResearch.mjs';
18
+ import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
18
19
 
19
20
  /**
20
21
  * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
@@ -90,21 +91,14 @@ export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '
90
91
  export const HERMETIC = Object.freeze(['--ignore-user-config', '--ignore-rules']);
91
92
 
92
93
  /**
93
- * THE TAIL EVERY FENCED POSTURE ENDS IN (2026-10-10, the audit), built once so
94
- * no fence can drift from it: no web unless the posture IS the write-up
95
- * card's (`web: true`, 0.117.0), no sub-agents, no browser, desktop or apps,
96
- * image generation off unless the posture IS the image card's, and nobody's
97
- * config or rules. It was hand-copied into five branches, and the
98
- * wiki's copy had already lost the web and sub-agent closures.
99
- *
100
- * Image generation ships ON in codex-cli 0.156.1 even under
101
- * --ignore-user-config (measured 2026-09-29): a turn that asked for no picture
102
- * is handed no tool that spends image credits, and the one that did must not
103
- * depend on the person's config to have it.
94
+ * Every fenced posture keeps config isolation, desktop/app closure and the
95
+ * delegation policy. Artifact postures explicitly enable native images as a
96
+ * supporting capability; consult, plan and wiki keep them off. Native web
97
+ * needs the isolated research read fence because broad-read tools can read
98
+ * host credentials. Feature flags do not prove account/model entitlement.
104
99
  */
105
100
  export const fencedTail = ({ imageGeneration = false, web = false } = {}) => [
106
- // The web stays on for one posture alone, the write-up card's, whose fence
107
- // reads only a copied view (CODEX_FENCES `research`).
101
+ // Native web is paired with the copied, root-denied read fence.
108
102
  ...(web ? [] : NO_WEB),
109
103
  ...NO_SUBAGENTS,
110
104
  ...NO_REACH_OUT,
@@ -135,33 +129,8 @@ export const CODEX_FENCED = Object.freeze({
135
129
  export const codexIgnoresUserConfig = ({ profile = 'build', vaultDir } = {}) =>
136
130
  Object.hasOwn(CODEX_FENCED, profile) && CODEX_FENCED[profile]({ vaultDir });
137
131
 
138
- /**
139
- * READ-ONLY, KERNEL-ENFORCED — the consult's and the plan's filesystem.
140
- *
141
- * Claude gets a VERB allowlist: Read, Grep, Glob and a handful of read-only
142
- * Bash forms. Codex has no such thing — it has no file-read tool at all, so
143
- * reading the repo IS command execution (`cat`, `rg`). Removing the shell
144
- * leaves the model with exactly ["update_plan","request_user_input"], which
145
- * cannot answer a question about a codebase. You get both capabilities or
146
- * neither.
147
- *
148
- * So the promise is kept one layer down instead. `read-only` is
149
- * kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
150
- * direct-IP connect fails with "Operation not permitted" — socket() is
151
- * denied, not merely DNS. An injected command still RUNS, and cannot write
152
- * or send anything itself.
153
- *
154
- * THE WAY OUT IS THE ANSWER. What such a turn reads can leave the box in
155
- * the words it hands back — a consult's reply, a plan's proposal, and
156
- * intake's drafted brief, which lands on the board with no press — and this
157
- * profile READS the whole box (see `codexArtifactFence`'s STATED note). Those
158
- * words are scrubbed on the way up (uplinkScrub.mjs) — of the machine's known
159
- * secret values and the token shapes that scrub knows, and of nothing else; it
160
- * is that scrub, not this sandbox, that stands between a read secret and the
161
- * board.
162
- */
163
- const READ_ONLY_SANDBOX = ['-c', 'sandbox_mode="read-only"'];
164
-
132
+ /** Consult/capture use root-denied tool reads over the actual project,
133
+ * knowledge and Git metadata. Scrubbing remains supplemental. */
165
134
  /**
166
135
  * THE CARTOGRAPHER: read the repo, write ONLY the vault. Codex's permission
167
136
  * profiles take a per-path filesystem map, so the rule is enforced by the
@@ -264,7 +233,7 @@ export const CODEX_RUNTIME = {
264
233
  * placed before it. Appending them after the positional is the kind of argv
265
234
  * that parses today and stops parsing on some future clap upgrade.
266
235
  */
267
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation }) {
236
+ args({ writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
268
237
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
269
238
  // agy moves) — codex has no adoptable store wired yet. Reaching here
270
239
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -296,7 +265,7 @@ export const CODEX_RUNTIME = {
296
265
  // posture builds its own (CODEX_FENCES below); anything else is a build.
297
266
  const fenced = codexIgnoresUserConfig({ profile, vaultDir });
298
267
  if (fenced) {
299
- a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation }));
268
+ a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads }));
300
269
  } else {
301
270
  // The daemon's build posture, mapped: SAFE keeps writes inside the
302
271
  // workspace, the default lets the agent run its own tests and git
@@ -308,6 +277,7 @@ export const CODEX_RUNTIME = {
308
277
  // `--sandbox` flag (codex-cli 0.156.1 lists only `-c`/`--enable`/…), so
309
278
  // every resumed turn — an agent's second card, an answer — was refused
310
279
  // before it started. `-c sandbox_mode=` means the same on both.
280
+ if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
311
281
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
312
282
  }
313
283
 
@@ -324,7 +294,10 @@ export const CODEX_RUNTIME = {
324
294
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
325
295
 
326
296
  a.push(...mcp, ...resultSchemaArgs);
327
- a.push(`${system}\n\n---\n\n${prompt}`);
297
+ const readers = profile === 'consult' || profile === 'plan'
298
+ ? '\nUse exec_command with shell="/bin/bash" and login=false for project discovery. Project files, local Git history and the project knowledge directory are readable; host files are outside this turn’s read boundary.'
299
+ : '';
300
+ a.push(`${system}${readers}\n\n---\n\n${prompt}`);
328
301
  return a;
329
302
  },
330
303
  /**
@@ -349,14 +322,14 @@ export const CODEX_RUNTIME = {
349
322
  * 2026-09-29, the owner: "its saying 3d models run on claude only but thats
350
323
  * not true"), one builder under each card's own profile name since 2026-10-10.
351
324
  *
352
- * The design card's shape is the image fence's without the image tool: a
325
+ * Design and image share supporting native image generation and a
353
326
  * permission profile that reads the repo and writes ONLY
354
327
  * `<worktree>/.flowviant/artifacts/` (and the turn's scratch), selected
355
328
  * by `default_permissions` (`codex exec` and `exec resume` refuse `-P` on
356
329
  * 0.156.1), no network, no web search, no sub-agents, no browser, computer or
357
- * apps, nobody's config or rules, and no approvals to wait on. Nothing about
358
- * the card's contract needs more: every one of the three is one HTML page, and
359
- * a page's CDN scripts load in the viewer, never on this machine.
330
+ * apps, nobody's config or rules, and no approvals to wait on. Needed image
331
+ * generation supports the page without changing its end product. CDN scripts
332
+ * load in the viewer, never on this machine.
360
333
  *
361
334
  * MEASURED on codex-cli 0.156.1 (Linux, bubblewrap), with gpt-6-luna at low
362
335
  * effort, in a scratch git repository (2026-09-29), with exactly this argv:
@@ -428,9 +401,9 @@ export function codexArtifactFence(cwd, { name, imageGeneration = false, what })
428
401
  }
429
402
 
430
403
  /** The design card's fence — `codexArtifactFence` under its own profile name,
431
- * with no image tool. */
404
+ * with native image generation available for supporting assets. */
432
405
  export const codexDesignFence = (cwd) =>
433
- codexArtifactFence(cwd, { name: 'flowviantdesign', what: 'a design turn' });
406
+ codexArtifactFence(cwd, { name: 'flowviantdesign', imageGeneration: true, what: 'a design turn' });
434
407
 
435
408
  /**
436
409
  * EVERY FENCED POSTURE'S ARGV, by the keys of CODEX_FENCED (a test holds the
@@ -439,8 +412,8 @@ export const codexDesignFence = (cwd) =>
439
412
  const CODEX_FENCES = {
440
413
  // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
441
414
  // shape from Claude's verb allowlist, which is the whole reason `profile` is
442
- // a promise rather than a flag list. See READ_ONLY_SANDBOX.
443
- consult: () => [...READ_ONLY_SANDBOX, ...fencedTail()],
415
+ // a promise rather than a flag list. See codexProjectRead.mjs.
416
+ consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
444
417
  // A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
445
418
  // the writes it makes go through the control plane, not through this box —
446
419
  // and for the same reason: its prompt is steered by anything a project
@@ -449,7 +422,7 @@ const CODEX_FENCES = {
449
422
  // turn's scoped token. The plan token has the planning tools; a capture
450
423
  // token has only staging and transcript tools. The server refuses
451
424
  // everything outside that scope.
452
- plan: () => [...READ_ONLY_SANDBOX, ...fencedTail()],
425
+ plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail()],
453
426
  wiki: ({ vaultDir }) => codexWikiFence(vaultDir),
454
427
  // AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
455
428
  // `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
@@ -475,12 +448,8 @@ const CODEX_FENCES = {
475
448
  // 2026-09-29); `:root` reads admit it, so the agent copies what it keeps
476
449
  // into the one writable directory, where the artifact scan finds it.
477
450
  //
478
- // IMAGE GENERATION IS SWITCHED ON HERE AND ONLY HERE — a decision. This is
479
- // the one turn whose card asks for pictures, so it must not depend on
480
- // whatever the person's config says about the feature. A BUILD turn keeps
481
- // the person's own config (not hermetic, by design), and the other fenced
482
- // postures never asked for a picture, so none of them is handed a tool that
483
- // spends image credits.
451
+ // Native images support every artifact kind; this profile still delivers
452
+ // PNG/WebP, while design/model/deck still deliver their self-contained page.
484
453
  image: ({ cwd }) => codexArtifactFence(cwd, { name: 'flowviantimage', imageGeneration: true, what: 'an image turn' }),
485
454
  // A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
486
455
  // `codexDesignFence`'s, argued and measured at `codexArtifactFence` above.
@@ -492,7 +461,7 @@ const CODEX_FENCES = {
492
461
  // tail leaves NO_WEB out and closes everything else.
493
462
  research: ({ researchIsolation }) => {
494
463
  if (!researchIsolation) throw new Error('Codex research requires its isolated input copy.');
495
- return [...researchFence(researchIsolation), ...fencedTail({ web: true })];
464
+ return [...researchFence(researchIsolation), ...fencedTail({ web: true, imageGeneration: true })];
496
465
  },
497
466
  };
498
467
  /** The fenced profile names — for the test that holds both tables to one set. */
@@ -86,6 +86,12 @@ export function runtimesReport(detected) {
86
86
  .join(',');
87
87
  }
88
88
 
89
+ /** Existing probe evidence only; diagnostics never spawn another CLI. */
90
+ export function measuredRuntimeVersion(id) {
91
+ const row = detectedCache?.find((runtime) => runtime.id === id);
92
+ return row ? { version: row.version, measuredAt: new Date(detectedAt).toISOString() } : null;
93
+ }
94
+
89
95
  export function detectRuntimes({ refresh = false } = {}) {
90
96
  if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
91
97
  detectedAt = Date.now();
@@ -322,6 +322,11 @@ export function parseCodexLine(line, cwd) {
322
322
  // text: nothing here is the model speaking.
323
323
  case 'thread.started':
324
324
  return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
325
+ case 'item.started': {
326
+ const item = ev.item ?? {};
327
+ if ((item.item_type ?? item.type) !== 'command_execution') return null;
328
+ return { toolEvent: ['Bash', { command: item.command }, { id: item.id, runtime: 'codex' }] };
329
+ }
325
330
  case 'item.completed': {
326
331
  const item = ev.item ?? {};
327
332
  const activity = humanizeCodexItem(item, cwd);
@@ -334,7 +339,13 @@ export function parseCodexLine(line, cwd) {
334
339
  // the agent said LAST" (the agent lane's final JSON object) cannot recover
335
340
  // it from `out`. Claude's stream has this for free under
336
341
  // `answerFromResult`; codex needed it said.
337
- return { activity, text, ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
342
+ const command = (item.item_type ?? item.type) === 'command_execution';
343
+ const toolEvent = command ? ['Bash', { command: item.command }, {
344
+ id: item.id, result: true, runtime: 'codex',
345
+ exitCode: item.exit_code, isError: item.status === 'failed',
346
+ output: item.aggregated_output ?? '',
347
+ }] : null;
348
+ return { activity, text, ...(toolEvent ? { toolEvent } : {}), ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
338
349
  }
339
350
  // THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
340
351
  // turn with `turn.completed` carrying `usage` — the codex twin of Claude's
@@ -1,7 +1,7 @@
1
1
  /** Local, bounded Codex readouts. No agent turn is started for these reports. */
2
2
  import { execFileSync } from 'node:child_process';
3
- import { readFileSync, readdirSync, statSync } from 'node:fs';
4
- import { join } from 'node:path';
3
+ import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { basename, delimiter, isAbsolute, join } from 'node:path';
5
5
  import { readBaseTools } from './projectTools.mjs';
6
6
  import { mcpRefusal } from './projectToolRuntimes.mjs';
7
7
  import { CODEX_MCP_NAME, normalizeMcpReport } from './runtimeCapabilities.mjs';
@@ -52,6 +52,7 @@ export function localCodexOfferings({ env = process.env, runCodex = run, read =
52
52
  } catch (error) { if (error?.code === 'ENOENT') result.skills = []; }
53
53
  try {
54
54
  const servers = JSON.parse(runCodex(['mcp', 'list', '--json'], env));
55
+ if (Array.isArray(servers)) result.servers = codexServerRows(servers, env);
55
56
  if (Array.isArray(servers)) result.mcp = codexMcpReport(servers
56
57
  // Unknown/unsupported auth is not proof of disconnection. Keep their
57
58
  // names local, just as connected servers stay local.
@@ -61,11 +62,82 @@ export function localCodexOfferings({ env = process.env, runCodex = run, read =
61
62
  return result;
62
63
  }
63
64
 
64
- export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
65
+ /** A bare command found on PATH, or an absolute one that is executable. */
66
+ function commandFound(command, env) {
67
+ if (isAbsolute(command)) {
68
+ try { accessSync(command, constants.X_OK); return true; } catch { return false; }
69
+ }
70
+ if (command.includes('/')) return null; // relative to a cwd Codex picks: unknown
71
+ return (env.PATH ?? '').split(delimiter).some((dir) => {
72
+ try { accessSync(join(dir, command), constants.X_OK); return true; } catch { return false; }
73
+ });
74
+ }
75
+
76
+ /**
77
+ * EVERY SERVER IN THIS MACHINE'S CODEX CONFIG, AS `codex mcp list --json`
78
+ * NAMES IT (2026-10-13) — `[{ name, status, reason }]` for the box's MCP
79
+ * inventory (mcpInventory.mjs). Codex reports no connection state without
80
+ * starting a server, so a server it would start reads `configured`; what
81
+ * this daemon can tell cheaply is said instead: `disabled` (Codex's own
82
+ * `enabled = false`), `needs-auth` (its OAuth status says not logged in),
83
+ * `failed` with the reason when a stdio command is not found or an env
84
+ * variable it reads a token or header from is unset. MEASURED on codex-cli
85
+ * 0.159.2: entries carry `name`, `enabled`, `disabled_reason`, `transport`
86
+ * (`stdio` with `command`; `streamable_http` with `bearer_token_env_var` and
87
+ * `env_http_headers`) and `auth_status` (`unsupported` for both kinds). A
88
+ * reason names a command (its basename) or a variable, never a value, a path
89
+ * or a URL.
90
+ */
91
+ export function codexServerRows(servers, env = process.env) {
92
+ const rows = [];
93
+ for (const s of servers) {
94
+ if (!s || typeof s.name !== 'string' || !nameOk(s.name)) continue;
95
+ const t = s.transport && typeof s.transport === 'object' ? s.transport : {};
96
+ const auth = typeof s.auth_status === 'string' ? s.auth_status : '';
97
+ let status = 'configured';
98
+ let reason = null;
99
+ const unset = [
100
+ ...(typeof t.bearer_token_env_var === 'string' ? [t.bearer_token_env_var] : []),
101
+ ...(t.env_http_headers && typeof t.env_http_headers === 'object' ? Object.values(t.env_http_headers).filter((v) => typeof v === 'string') : []),
102
+ ].filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !env[name]);
103
+ if (s.enabled === false) {
104
+ status = 'disabled';
105
+ reason = typeof s.disabled_reason === 'string' ? s.disabled_reason.slice(0, 200) : null;
106
+ } else if (typeof t.command === 'string' && commandFound(t.command, env) === false) {
107
+ status = 'failed';
108
+ reason = `command ${basename(t.command).slice(0, 60)} not found`;
109
+ } else if (unset.length) {
110
+ status = 'failed';
111
+ reason = `env ${[...new Set(unset)].sort().join(', ')} unset`;
112
+ } else if (['needs_auth', 'not_logged_in', 'notLoggedIn'].includes(auth)) {
113
+ status = 'needs-auth';
114
+ } else if (auth === 'failed') {
115
+ status = 'failed';
116
+ }
117
+ rows.push({ name: s.name, status, reason });
118
+ }
119
+ return rows;
120
+ }
121
+
122
+ function codexOfferings(refresh = false) {
65
123
  if (!cached || refresh || Date.now() - cachedAt >= TTL) {
66
124
  cached = localCodexOfferings();
67
125
  cachedAt = Date.now();
68
126
  }
127
+ return cached;
128
+ }
129
+
130
+ /** This machine's Codex servers for the inventory, or null when the local
131
+ * read failed (unknown). Shares the offerings' five-minute cache. */
132
+ export function codexMcpServers({ refresh = false } = {}) {
133
+ return codexOfferings(refresh).servers ?? null;
134
+ }
135
+
136
+ /** Existing model evidence only: a brief never probes another CLI or vendor. */
137
+ export function knownCodexModels() { return cached?.models ?? []; }
138
+
139
+ export function runtimeOfferings(repoRoot, baseRef, { refresh = false } = {}) {
140
+ codexOfferings(refresh);
69
141
  const out = { rtm: {}, rtc: {}, rts: {}, rtp: {} };
70
142
  if (cached.models) out.rtm.codex = cached.models;
71
143
  if (cached.capabilities) out.rtc.codex = cached.capabilities;
@@ -26,12 +26,14 @@ export function mergeOutward({
26
26
  repoRoot,
27
27
  tmpDir,
28
28
  baseRef,
29
+ baseTip,
29
30
  workingTree,
30
31
  warn,
31
32
  // NETWORK calls only (push, fetch) — timed and non-interactive, so a
32
33
  // stale credential or a half-open connection cannot freeze the ship
33
34
  // path's writer lock. Injectable for tests; defaults to the real one.
34
35
  gitNet = gitNetDefault,
36
+ retryRace = true,
35
37
  }) {
36
38
  const dropTmp = () => {
37
39
  try {
@@ -56,6 +58,7 @@ export function mergeOutward({
56
58
  const base = baseBranchName(baseRef());
57
59
  const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
58
60
  const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
61
+ if (baseTip && before !== baseTip) throw new Error(`base moved to ${before}, but expected ${baseTip}`);
59
62
  let current = null;
60
63
  try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
61
64
  if (current === base) {
@@ -76,7 +79,7 @@ export function mergeOutward({
76
79
  };
77
80
  const attempt = () => {
78
81
  dropTmp();
79
- git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
82
+ git(['worktree', 'add', '--detach', tmpDir, baseTip ?? baseRef()], repoRoot);
80
83
  gitMerge(
81
84
  ['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
82
85
  tmpDir
@@ -110,7 +113,7 @@ export function mergeOutward({
110
113
  * re-pushing a stale merge. The TIP is untouched, so the receipts still
111
114
  * name exactly the same commits.
112
115
  */
113
- if (!isRaceRejection(e)) throw e;
116
+ if (!retryRace || !isRaceRejection(e)) throw e;
114
117
  warn?.('ship: base moved under us — refetching and merging again');
115
118
  try {
116
119
  gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
@@ -188,8 +188,8 @@ export async function resolveStartProject({ dirGiven }) {
188
188
  }
189
189
  // An answered question is consent: adopt it, and BIND it to this repo so
190
190
  // the next start needs no prompt. Repointing is legitimate and said aloud.
191
- if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
192
- console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
191
+ if (repoRoot && picked.repoRoot && !creds.isStoredRepo(picked, repoRoot)) {
192
+ console.log(`note: adding ${repoRoot} to ${creds.projectLabel(picked)}. Run ${terminalCommand('login')} here to register its repository in project settings.`);
193
193
  }
194
194
  adoptStoredCredential(picked);
195
195
  // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
@@ -266,10 +266,10 @@ export async function resolveStartProject({ dirGiven }) {
266
266
  }
267
267
 
268
268
  if (process.argv.includes('--json-events') && dirGiven && CREDENTIAL?.entry) {
269
- const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./credentials.mjs');
269
+ const { detectRepoRoot, listStoredProjects, boundElsewhere, isStoredRepo } = await import('./credentials.mjs');
270
270
  const repo = detectRepoRoot();
271
271
  const entry = CREDENTIAL.entry;
272
- if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
272
+ if (!repo || !isStoredRepo(entry, repo) || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
273
273
  console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
274
274
  process.exit(1);
275
275
  }