flowviant 0.125.12 → 0.125.15

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 (42) hide show
  1. package/README.md +24 -1
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/agentFiles.mjs +1 -1
  4. package/bin/lib/agentPrerequisiteContract.mjs +8 -0
  5. package/bin/lib/agentTaskKinds.mjs +4 -4
  6. package/bin/lib/artifactContracts.mjs +15 -11
  7. package/bin/lib/artifactPreview.mjs +2 -2
  8. package/bin/lib/captureAuthority.mjs +82 -0
  9. package/bin/lib/claudePosture.mjs +9 -0
  10. package/bin/lib/codexResearch.mjs +12 -1
  11. package/bin/lib/hooks/orchestrator.mjs +1 -1
  12. package/bin/lib/intermediateArtifacts.mjs +228 -0
  13. package/bin/lib/mcpStdio.mjs +11 -3
  14. package/bin/lib/modelExport.mjs +19 -0
  15. package/bin/lib/modelExportProtocol.mjs +80 -0
  16. package/bin/lib/nativeAgentTools.mjs +4 -1
  17. package/bin/lib/orchestrator.mjs +46 -20
  18. package/bin/lib/orchestratorClient.mjs +2 -2
  19. package/bin/lib/orchestratorPrompt.mjs +70 -0
  20. package/bin/lib/pinnedCapture.mjs +35 -7
  21. package/bin/lib/prompts.mjs +10 -3
  22. package/bin/lib/readOnlyRun.mjs +172 -0
  23. package/bin/lib/runTurn.mjs +1 -0
  24. package/bin/lib/runtimeClaude.mjs +6 -5
  25. package/bin/lib/runtimeCodex.mjs +4 -4
  26. package/bin/lib/turnProfile.mjs +1 -0
  27. package/bin/lib/work.mjs +5 -5
  28. package/bin/lib/workAgentMerges.mjs +8 -8
  29. package/bin/lib/workAgentPlans.mjs +69 -43
  30. package/bin/lib/workAgentReview.mjs +24 -1
  31. package/bin/lib/workAgentTurnExecution.mjs +29 -6
  32. package/bin/lib/workAgentTurnOutcome.mjs +15 -3
  33. package/bin/lib/workAgentTurnShape.mjs +6 -5
  34. package/bin/lib/workAgentTurns.mjs +4 -0
  35. package/bin/lib/workAttachments.mjs +4 -2
  36. package/bin/lib/workCappedTurn.mjs +4 -0
  37. package/bin/lib/workLiveMessages.mjs +2 -2
  38. package/bin/lib/workNarration.mjs +3 -2
  39. package/bin/lib/workPlanRecovery.mjs +2 -21
  40. package/bin/lib/workSessionTurns.mjs +109 -22
  41. package/bin/lib/workToolLog.mjs +3 -0
  42. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ /** Native investigation coordination for a held read-only attempt. No card,
2
+ * board token, project config or personal MCP server enters this capability.
3
+ * Capture can reuse the same lifecycle with its own attempt identity. */
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createOrchestrator } from './orchestrator.mjs';
9
+ import { claudeOrchestrationSafe } from './orchestratorHooks.mjs';
10
+ import { codexSubagentsSafe } from './runtimeCodex.mjs';
11
+ import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
12
+ import { SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
13
+ import { runCappedTurn } from './workCappedTurn.mjs';
14
+ import { killGroup } from './processGroup.mjs';
15
+ import { scrub, scrubCut } from './uplinkScrub.mjs';
16
+ import { toml } from './codexToml.mjs';
17
+ import { heldToolArgv } from './heldTool.mjs';
18
+ import { fleetRequest } from './fleetPost.mjs';
19
+ import { DAEMON_INSTANCE } from './config.mjs';
20
+
21
+ /** Transport failure is unknown, never a fabricated Stop. Only a measured
22
+ * holder/attempt mismatch cancels a root and its read-only descendants. */
23
+ export async function watchReadOnlyAttempt(url, job, request = fleetRequest) {
24
+ const controller = new AbortController();
25
+ let busy = false, closed = false;
26
+ const check = async () => {
27
+ if (busy || closed) return;
28
+ busy = true;
29
+ try {
30
+ const r = await request(url, { body: { id: job.id, attempt: job.attempt, instance: DAEMON_INSTANCE }, timeoutMs: 15_000 });
31
+ if (!closed && r.ok && r.json?.data?.active === false) controller.abort();
32
+ } catch { /* an unanswered holder read is unknown */
33
+ } finally { busy = false; }
34
+ };
35
+ await check();
36
+ const timer = setInterval(check, 5_000);
37
+ timer.unref?.();
38
+ return { signal: controller.signal, check, stop: () => { closed = true; clearInterval(timer); } };
39
+ }
40
+
41
+ export const readOnlyCoordinationSafe = (runtime, version) => runtime === 'claude'
42
+ ? claudeOrchestrationSafe(version) : runtime === 'codex' && codexSubagentsSafe(version);
43
+
44
+ function coordinationMount(runtime, controller, rootTools) {
45
+ const servers = { [ORCHESTRATOR_SERVER]: controller.config,
46
+ ...(rootTools ? { flowviant: { ...controller.config, args: heldToolArgv('__capture-mcp') } } : {}) };
47
+ if (runtime === 'codex') {
48
+ return { args: Object.entries(servers).flatMap(([name, config]) => {
49
+ const key = `mcp_servers.${name}`;
50
+ return ['-c', `${key}.command=${toml(config.command)}`,
51
+ '-c', `${key}.args=${JSON.stringify(config.args)}`,
52
+ '-c', `${key}.env_vars=${JSON.stringify(Object.keys(controller.env))}`,
53
+ '-c', `${key}.default_tools_approval_mode="approve"`];
54
+ }), cleanup() {} };
55
+ }
56
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-read-coordination-'));
57
+ try {
58
+ const path = join(dir, 'mcp.json');
59
+ writeFileSync(path, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
60
+ return { args: ['--strict-mcp-config', '--mcp-config', path], cleanup: () => rmSync(dir, { recursive: true, force: true }) };
61
+ } catch (error) { rmSync(dir, { recursive: true, force: true }); throw error; }
62
+ }
63
+
64
+ /** The root is counted by runCappedTurn's workChildren registration; native
65
+ * children retain reservations in the SAME admit pool until measured stopped.
66
+ * Each invocation mints a fresh capability, including diagnosis → retry.
67
+ * Late callbacks cannot repaint a replacement consult's activity/evidence. */
68
+ export async function createReadOnlyCoordination({ enabled = false, identity, runtime, version, admit, children = {}, signal,
69
+ contract = '', createController = createOrchestrator, onUnavailable = () => {}, rootTools = null }) {
70
+ if (!enabled || signal?.aborted || !readOnlyCoordinationSafe(runtime, version)) return null;
71
+ if (!identity || !['plan', 'capture'].includes(identity.scope) || !identity.id ||
72
+ !Number.isSafeInteger(identity.attempt) || identity.attempt < 0) {
73
+ onUnavailable(new Error('Read-only coordination needs a held attempt identity.'));
74
+ return null;
75
+ }
76
+ let controller = null, mount = null, active = true;
77
+ let root = null, killed = false;
78
+ const kill = child => { if (killed || !child) return; killed = true; killGroup(child); };
79
+ const agents = new Map();
80
+ const nonce = randomUUID();
81
+ const label = native => {
82
+ const id = controller?.resolve(native) ?? native;
83
+ const a = agents.get(id);
84
+ return scrubCut(a?.title || a?.kind || id, 120);
85
+ };
86
+ const trace = {
87
+ agent(update) {
88
+ if (!active || signal?.aborted || !agents.has(update.id) && agents.size >= SUBAGENT_CAP) return;
89
+ const prior = agents.get(update.id) ?? {};
90
+ const row = { ...prior, id: update.id };
91
+ for (const [key, cap] of Object.entries(SUBAGENT_FIELD_CAPS)) if (typeof update[key] === 'string') row[key] = scrubCut(update[key], cap);
92
+ if (update.status) row.status = update.status;
93
+ agents.set(update.id, row);
94
+ },
95
+ // Questions and native choice notes remain inside the CLI coordination;
96
+ // only observed CLI activity reaches the existing progress line below.
97
+ prose() {}, handoff() {}, flush: async () => {},
98
+ };
99
+ try {
100
+ controller = await createController({ runtime, readOnlyRun: identity, signal, trace, admit, rootTools,
101
+ children: { ...children, access: 'read' } });
102
+ mount = coordinationMount(runtime, controller, rootTools);
103
+ } catch (error) {
104
+ await controller?.close(true); rootTools?.close();
105
+ onUnavailable(error);
106
+ return null;
107
+ }
108
+ return {
109
+ kill,
110
+ wrap(turnArgs) {
111
+ if (turnArgs.profile !== (rootTools ? 'plan' : 'consult') || turnArgs.mcpConfig || turnArgs.mcpArgs?.length ||
112
+ turnArgs.projectMcpServers?.length || turnArgs.machineMcp || turnArgs.mcpEnv?.FLOWVIANT_MCP_TOKEN)
113
+ throw new TypeError('Read-only coordination requires a fenced turn without external MCP credentials.');
114
+ return { ...turnArgs, orchestration: true, readOnlyCoordination: true,
115
+ system: [turnArgs.system, contract].filter(Boolean).join('\n\n'),
116
+ ...(runtime === 'claude' ? { claudeVersion: version } : { codexVersion: version }),
117
+ mcpArgs: mount.args, mcpEnv: controller.env,
118
+ onSpawn: child => { root = child; turnArgs.onSpawn?.(child); },
119
+ onInit: init => { if (active && !signal?.aborted) { controller.root(init.sessionId); turnArgs.onInit?.(init); } },
120
+ onThreadId: id => { if (active && !signal?.aborted) { controller.root(id); turnArgs.onThreadId?.(id); } },
121
+ ...Object.fromEntries(['onAnswer', 'onUsage', 'onModel', 'onContext', 'onCliFailure', 'onExit'].map(key =>
122
+ [key, (...args) => { if (active && !signal?.aborted) turnArgs[key]?.(...args); }])),
123
+ onSubagent: update => { if (active && !signal?.aborted) controller.observe(update); },
124
+ onSpawnResult: result => { if (active && !signal?.aborted) controller.spawnResult(result); },
125
+ onActivity: activity => {
126
+ if (!active || signal?.aborted) return;
127
+ if (!activity?.agentId) { turnArgs.onActivity?.(activity); return; }
128
+ controller.attributed(activity.agentId, id => {
129
+ if (active && !signal?.aborted && activity.label) turnArgs.onActivity?.({ ...activity, label: `${label(id)}: ${activity.label}` });
130
+ });
131
+ },
132
+ onToolEvent: (name, input, meta = {}) => {
133
+ if (!active || signal?.aborted) return;
134
+ const push = id => turnArgs.onToolEvent?.(name, input, { ...meta,
135
+ // Runtime call ids can repeat across children and fresh consults.
136
+ // A citation in final prose never creates a reader observation.
137
+ ...(meta.id ? { id: createHash('sha256').update(`${nonce}\0${id ?? 'root'}\0${meta.id}`).digest('hex') } : {}),
138
+ ...(id ? { agentId: id, agentTitle: label(id) } : {}),
139
+ });
140
+ if (meta.agentId) controller.attributed(meta.agentId, push); else push(null);
141
+ },
142
+ };
143
+ },
144
+ async close(stopped = false) {
145
+ active = false; kill(root);
146
+ try { await controller.close(stopped || Boolean(signal?.aborted)); }
147
+ finally { mount.cleanup(); }
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Scratch planning retains its capped, transcript-removing lifecycle.
153
+ * Capture consumes the same coordination factory around its own resumable
154
+ * conversation lifecycle and keeps its transcript. */
155
+ export async function runReadOnlyConsult({ ...options }) {
156
+ const { turnArgs } = options;
157
+ if (turnArgs.profile !== 'consult' || turnArgs.mcpConfig || turnArgs.mcpArgs?.length ||
158
+ turnArgs.projectMcpServers?.length || turnArgs.machineMcp || turnArgs.mcpEnv?.FLOWVIANT_MCP_TOKEN) {
159
+ options.releaseSlot?.();
160
+ throw new TypeError('Read-only coordination requires a consult without board, staging or external MCP credentials.');
161
+ }
162
+ const coordination = await createReadOnlyCoordination({ ...options, runtime: turnArgs.runtime });
163
+ if (!coordination) return runCappedTurn(options);
164
+ try {
165
+ // The cap composes onSpawn/onInit itself; adapt its wrapper callbacks
166
+ // beside turnArgs so transcript and root-count bookkeeping stay there.
167
+ const wrapped = coordination.wrap({ ...turnArgs, onSpawn: options.onSpawn, onInit: options.onInit });
168
+ const { onSpawn, onInit, ...args } = wrapped;
169
+ const result = await runCappedTurn({ ...options, turnArgs: args, onSpawn, onInit, killChild: coordination.kill });
170
+ return { ...result, out: scrub(result.out) };
171
+ } finally { await coordination.close(); }
172
+ }
@@ -197,6 +197,7 @@ function runPlainTurn(opts) {
197
197
  const args = rt.args({
198
198
  cardTurn: opts.cardTurn === true,
199
199
  orchestration: opts.orchestration === true,
200
+ readOnlyCoordination: opts.readOnlyCoordination === true,
200
201
  // The lane's own measurement when it decided to orchestrate, so the
201
202
  // hooks and the controller cannot disagree (workAgentTurnExecution.mjs).
202
203
  codexVersion: opts.codexVersion ?? measuredRuntimeVersion('codex')?.version,
@@ -16,9 +16,9 @@ import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
17
  import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
18
  import { machineMcpServers } from './claudeMachineMcp.mjs';
19
- import { claudePermFor, withMachineMcp, withNativeChildren } from './claudePosture.mjs';
19
+ import { claudePermFor, withMachineMcp, withNativeChildren, withReadOnlyCoordination } from './claudePosture.mjs';
20
20
  import { withSettings } from './claudeSettingsArg.mjs';
21
- import { orchestratorHooks } from './orchestratorHooks.mjs';
21
+ import { orchestratorHooks, claudeOrchestrationSafe } from './orchestratorHooks.mjs';
22
22
  import { orchestratesPosture } from './orchestratorPrompt.mjs';
23
23
  import { versionAtLeast } from './versionOrder.mjs';
24
24
 
@@ -123,7 +123,7 @@ export const CLAUDE_RUNTIME = {
123
123
  * strongest form of it available anywhere: `--append-system-prompt` sits
124
124
  * above the conversation rather than inside it.
125
125
  */
126
- args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, claudeVersion = null }) {
126
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null }) {
127
127
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
128
128
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
129
129
  // with no vault, or one no rule can name, has no list — refused here in
@@ -193,8 +193,9 @@ export const CLAUDE_RUNTIME = {
193
193
  const fenced = profile !== 'build';
194
194
  // THE GATE RIDES A CODE OR RESEARCH CARD (2026-10-19, orchestratorPrompt.mjs);
195
195
  // a research card's list then admits the children its fence denied.
196
- const gated = orchestration && orchestratesPosture(profile);
197
- const perm = gated && fenced ? withNativeChildren(postureList) : postureList;
196
+ const readCoordinator = orchestration && readOnlyCoordination && ['consult', 'plan'].includes(profile) && claudeOrchestrationSafe(claudeVersion);
197
+ const gated = readCoordinator || orchestration && orchestratesPosture(profile);
198
+ const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList) : postureList;
198
199
  if (fenced) a.push(...NO_SETTING_SOURCES);
199
200
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
200
201
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -281,7 +281,7 @@ export const CODEX_RUNTIME = {
281
281
  * limit even when its argv plus environment is far below ARG_MAX. Both exec
282
282
  * and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
283
283
  */
284
- args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false }) {
284
+ args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
285
285
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
286
286
  // agy moves) — codex has no adoptable store wired yet. Reaching here
287
287
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -314,7 +314,7 @@ export const CODEX_RUNTIME = {
314
314
  const fenced = codexIgnoresUserConfig({ profile, vaultDir });
315
315
  // THE GATE RIDES A CODE OR RESEARCH CARD on a CLI whose children inherit
316
316
  // the parent's sandbox (2026-10-19, orchestratorPrompt.mjs).
317
- const gated = orchestration && orchestratesPosture(fenced ? profile : 'build') && codexSubagentsSafe(codexVersion);
317
+ const gated = orchestration && (orchestratesPosture(fenced ? profile : 'build') || readOnlyCoordination && ['consult', 'plan'].includes(profile)) && codexSubagentsSafe(codexVersion);
318
318
  if (fenced) {
319
319
  a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
320
320
  } else {
@@ -466,7 +466,7 @@ const CODEX_FENCES = {
466
466
  // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
467
467
  // shape from Claude's verb allowlist, which is the whole reason `profile` is
468
468
  // a promise rather than a flag list. See codexProjectRead.mjs.
469
- consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
469
+ consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ subagents: opts.subagents, codexVersion: opts.codexVersion })],
470
470
  // A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
471
471
  // the writes it makes go through the control plane, not through this box —
472
472
  // and for the same reason: its prompt is steered by anything a project
@@ -475,7 +475,7 @@ const CODEX_FENCES = {
475
475
  // turn's scoped token. The plan token has the planning tools; a capture
476
476
  // token has only staging and transcript tools. The server refuses
477
477
  // everything outside that scope.
478
- plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
478
+ plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ subagents: opts.subagents, codexVersion: opts.codexVersion })],
479
479
  wiki: ({ vaultDir, codexVersion }) => codexWikiFence(vaultDir, codexVersion),
480
480
  // AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
481
481
  // `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
@@ -105,5 +105,6 @@ export function resolveTurnProfile(opts = {}) {
105
105
  if (typeof name !== 'string' || !Object.hasOwn(TURN_PROFILES, name)) {
106
106
  return { error: `unknown turn profile '${String(name).slice(0, 40)}'` };
107
107
  }
108
+ if (opts.readOnlyCoordination === true && !['consult', 'plan'].includes(name)) return { error: 'read-only coordination requires a consult or capture profile' };
108
109
  return { name, ...TURN_PROFILES[name] };
109
110
  }
package/bin/lib/work.mjs CHANGED
@@ -109,7 +109,7 @@ export function createWorkManager({
109
109
  baseDir,
110
110
  getBaseRef,
111
111
  getMcpUrl,
112
- getLeaseTtl,
112
+ getLeaseTtl, exportModel,
113
113
  /** "The repo picture changed — look again." See the caller in fleet.mjs. */
114
114
  onRepoChanged = () => {},
115
115
  /**
@@ -367,7 +367,7 @@ export function createWorkManager({
367
367
  sessionMetaPath,
368
368
  workChildren,
369
369
  });
370
- const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
370
+ const { runReviewEntry, runMergeCheck } = createWorkAgentReview({ runCheck, runPrecheck });
371
371
  const processAgentRecoveryJobs = createAgentRecovery({ baseDir, repoRoot, fetchPublishedBranch: fetchPublishedBranchAsync, inPlace, placeWtFor, reportSessionWorktree });
372
372
  const { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
373
373
  agentRepositories, REJECT_RETRY_MS,
@@ -382,8 +382,8 @@ export function createWorkManager({
382
382
  getArtifactsAccepted,
383
383
  noteSessionGroup,
384
384
  reportSessionWorktree,
385
- artifacts,
386
- runReviewEntry,
385
+ artifacts, exportModel,
386
+ runReviewEntry, runMergeCheck,
387
387
  publishAgentBranch, stopAgentPublishing,
388
388
  admit,
389
389
  workChildren,
@@ -398,7 +398,7 @@ export function createWorkManager({
398
398
  baseDir,
399
399
  gitNet,
400
400
  baseRef,
401
- runReviewEntry,
401
+ runMergeCheck,
402
402
  onRepoChanged,
403
403
  landed,
404
404
  agentRemoteAt,
@@ -43,7 +43,7 @@ export function createWorkAgentMerges({
43
43
  baseDir,
44
44
  gitNet,
45
45
  baseRef,
46
- runReviewEntry,
46
+ runMergeCheck,
47
47
  onRepoChanged,
48
48
  landed,
49
49
  agentRemoteAt,
@@ -157,7 +157,7 @@ export function createWorkAgentMerges({
157
157
  if (job.repositoryMerge) {
158
158
  if (!repositoryPlaces) throw new Error('Project repository worktrees are unavailable.');
159
159
  const result = await mergeRepositoryPlace({ job, entries: repositoryPlaces.entries(place), baseDir,
160
- check: (r) => runReviewEntry(agentId, r.wt, job.agentName, r),
160
+ check: (r) => runMergeCheck(agentId, r.wt, r),
161
161
  progress: async (states, retry) => {
162
162
  const response = await fleetRequest(fleetEndpoint('agent-merge-progress', FLEET_URL), {
163
163
  body: { agentId, instance: DAEMON_INSTANCE, states, retry } });
@@ -195,12 +195,12 @@ export function createWorkAgentMerges({
195
195
  });
196
196
  return;
197
197
  }
198
- // The branch changed, so the previous check — and the previous
199
- // pre-review — answered about a different tree. Re-read it before
200
- // anything merges: a failed merge sends the agent BACK to review, which
201
- // is a review-entry beat like any other, and a stale reading standing
202
- // over a rebased branch is exactly what `precheckSha` exists to void.
203
- const check = await runReviewEntry(agentId, wt, job.agentName);
198
+ // The branch changed, so the previous check answered about a different
199
+ // tree. Re-run it before anything merges: a failed check sends the
200
+ // agent BACK to review. The CHECK ALONE (`runMergeCheck`): the earlier
201
+ // pre-review now reads as stale by `precheckSha`, and a fresh one would
202
+ // hold this approved merge behind a model call (2026-10-21).
203
+ const check = await runMergeCheck(agentId, wt);
204
204
  if (check?.status === 'failed') {
205
205
  await report({ agentId, ok: false, detail: check.text || check.output || 'The repository check failed after folding base.' });
206
206
  return;
@@ -6,7 +6,7 @@ import { discoveryGuide } from './discoveryContract.mjs';
6
6
  import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
7
7
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
8
8
  import { createToolLog } from './workToolLog.mjs';
9
- import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
9
+ import { FLEET_URL, DAEMON_INSTANCE, MODEL } from './config.mjs';
10
10
  import { measureAgentFootprint, liveAgentContext } from './agentFootprint.mjs';
11
11
  import { parseProposal } from './agentPlan.mjs';
12
12
  import { c, note } from './ui.mjs';
@@ -14,9 +14,11 @@ import { runTurn as defaultRunTurn } from './runTurn.mjs';
14
14
  import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF, withProjectContext, REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
15
15
  import { scrub as envScrub } from './uplinkScrub.mjs';
16
16
  import { canRun, runtimeById, runtimeLabel } from './runtimes.mjs';
17
- import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
17
+ import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor, measuredRuntimeVersion } from './runtimeDetection.mjs';
18
18
  import { brainFor } from './workBrain.mjs';
19
- import { runCappedTurn } from './workCappedTurn.mjs';
19
+ import { runReadOnlyConsult } from './readOnlyRun.mjs';
20
+ import { planningOrchestratorContract } from './orchestratorPrompt.mjs';
21
+ import { knownCodexModels } from './runtimeOfferings.mjs';
20
22
  import { fleetEndpoint } from './fleetWire.mjs';
21
23
  import { claimFleetLease, fleetHeaders } from './fleetPost.mjs';
22
24
  import { REPO_PLACE } from './workPlaces.mjs';
@@ -37,6 +39,8 @@ export function createWorkAgentPlans({
37
39
  pickRuntimeFor = defaultPickRuntimeFor,
38
40
  detectRuntimes = defaultDetectRuntimes,
39
41
  authStatus = readClaudeAuthStatus,
42
+ runtimeVersion = id => measuredRuntimeVersion(id)?.version,
43
+ runConsult = runReadOnlyConsult,
40
44
  }) {
41
45
  const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
42
46
  const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
@@ -55,10 +59,10 @@ export function createWorkAgentPlans({
55
59
  // the CLI path-validates itself, and the `git` readers, which it does not —
56
60
  // their path fence is the read guard's refusal (hooks/readGuard.mjs), of
57
61
  // `--no-index` and of any git argument aimed off the checkout — and no
58
- // Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
59
- // turn has no control plane to reach even if the repository it reads tries
60
- // to steer it. The proposal comes back as the turn's final message rather
61
- // than through a tool, which is exactly what lets that permission set be
62
+ // Write, no Edit, no mkdir, no rm). Compatible held attempts may receive
63
+ // only a local read-only coordination capability. No board/staging token,
64
+ // project/personal server or extra network access is supplied. The proposal
65
+ // comes back as the turn's final message rather than through a tool, which lets that permission set be
62
66
  // this narrow.
63
67
  //
64
68
  // It takes the checkout's place lock as a READER, beside the operator's own
@@ -287,49 +291,71 @@ export function createWorkAgentPlans({
287
291
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
288
292
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
289
293
  discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
290
- const consult = async (prompt, system) => await runCappedTurn({
291
- runTurn,
292
- signal: control?.signal,
293
- capMs: PLAN_TURN_TIMEOUT_MS,
294
- workChildren,
295
- releaseSlot,
296
- transcriptCwd: repoRoot,
297
- turnArgs: {
298
- diagnosticPlanId: id,
299
- prompt,
300
- system,
301
- knowledgeDir,
302
- // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
303
- // passed empty, so there is no control plane on this turn at all.
304
- profile: 'consult',
305
- cwd: repoRoot,
306
- runtime: rt,
307
- ...brainFor(job, rt),
308
- answerFromResult: true,
309
- work: planWork(job),
310
- // The CLI's own tail, forwarded to the press. Same rule as an agent
311
- // turn's: throttled, overwritten rather than appended, and never
312
- // awaited by the turn.
313
- onActivity: (a) => {
314
- if (a?.label) narrate(String(a.label));
294
+ const brain = brainFor(job, rt);
295
+ let consults = 0;
296
+ const consult = async (prompt, system, diagnosing = false) => {
297
+ let rootSlot = releaseSlot;
298
+ if (consults++ > 0) {
299
+ // Diagnosis ended its CLI and released its children. A fresh
300
+ // paid consult must ask the pool again, like every other root.
301
+ while (admit('churn')) {
302
+ if (control?.signal.aborted) return { out: '', wedged: false };
303
+ await new Promise(resolve => setTimeout(resolve, 1_000));
304
+ }
305
+ rootSlot = admit.reserve();
306
+ }
307
+ return await runConsult({
308
+ enabled: job.readOnlyOrchestrationVersion === 1 && job.recoveryEnabled === true,
309
+ identity: { scope: 'plan', id, attempt: job.attempt },
310
+ version: runtimeVersion(rt),
311
+ admit,
312
+ children: { ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null),
313
+ ...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
314
+ contract: planningOrchestratorContract({ diagnosis: diagnosing, runtime: rt, ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null) }),
315
+ onUnavailable: error => say(`Native investigation coordination unavailable: ${error.message}`),
316
+ runTurn,
317
+ signal: control?.signal,
318
+ capMs: PLAN_TURN_TIMEOUT_MS,
319
+ workChildren,
320
+ releaseSlot: rootSlot,
321
+ transcriptCwd: repoRoot,
322
+ turnArgs: {
323
+ diagnosticPlanId: id,
324
+ prompt,
325
+ system,
326
+ knowledgeDir,
327
+ // The consult fence stays intact. runReadOnlyConsult may mount
328
+ // only the attempt's local coordination tool, never a board token.
329
+ profile: 'consult',
330
+ cwd: repoRoot,
331
+ runtime: rt,
332
+ ...brain,
333
+ answerFromResult: true,
334
+ work: planWork(job),
335
+ // The CLI's own tail, forwarded to the press. Same rule as an agent
336
+ // turn's: throttled, overwritten rather than appended, and never
337
+ // awaited by the turn.
338
+ onActivity: (a) => {
339
+ if (a?.label) narrate(String(a.label));
340
+ },
341
+ onToolEvent: discovery.pushToolEvent,
342
+ // Tagged with the CLI that counted it; one turn, so the sum is the
343
+ // one report (turnUsage.mjs keeps it null-safe).
344
+ onUsage: (u) => {
345
+ usage = addUsage(usage, { ...u, runtime: rt });
346
+ },
315
347
  },
316
- onToolEvent: discovery.pushToolEvent,
317
- // Tagged with the CLI that counted it; one turn, so the sum is the
318
- // one report (turnUsage.mjs keeps it null-safe).
319
- onUsage: (u) => {
320
- usage = addUsage(usage, { ...u, runtime: rt });
348
+ onSpawn: () => {
349
+ say(`${runtimeLabel(rt)} started on this machine`);
321
350
  },
322
- },
323
- onSpawn: () => {
324
- say(`${runtimeLabel(rt)} started on this machine`);
325
- },
326
- });
351
+ });
352
+ };
327
353
  let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
328
354
  if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
329
355
  const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
330
356
  if (job.recovery) {
331
357
  say(`Diagnosing the failed Plan: ${job.recovery.reason}`);
332
- const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'));
358
+ const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'), true);
333
359
  if (control?.signal.aborted) return;
334
360
  if (diagnostic.wedged) { wedged = true; return; }
335
361
  const result = parsePlanDiagnosis(diagnostic.out);
@@ -19,6 +19,8 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
19
19
  * `runCheck` directly; a second thing to run at the same moment is a second
20
20
  * thing three call sites can forget.
21
21
  *
22
+ * THE MERGE PATH IS NOT A REVIEW ENTRY (2026-10-21) — see `runMergeCheck`.
23
+ *
22
24
  * THE CHECK FIRST, ALWAYS. It is a local command whose answer the board wants
23
25
  * on the row immediately; the pre-review is a model call that may take
24
26
  * minutes. Ordering them the other way would put a label behind a label.
@@ -42,5 +44,26 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
42
44
  return result;
43
45
  };
44
46
 
45
- return { runReviewEntry };
47
+ /**
48
+ * THE CHECK ALONE, for the merge path (2026-10-21): the stale fold before an
49
+ * approved merge, each repository of a coordinated merge, and a merge
50
+ * resolve's settle. The check gates there — a failed one sends the agent
51
+ * back to Review — so it still runs. The pre-review does not: it labels and
52
+ * never blocks, yet run here it sat inside the place's writer lock ahead of
53
+ * the push and held an approved merge for up to its five-minute cap. One
54
+ * merge measured 13:40 check → 13:43 resolve settled → 13:43 check → 13:48
55
+ * landed, both waits a pre-review, the second wedged and posting nothing.
56
+ * A person already approved this work; a reading of it nobody is waiting on
57
+ * cannot be what the landing waits for. A merge that fails back to Review
58
+ * keeps its earlier reading, which the server marks stale by `precheckSha`.
59
+ */
60
+ const runMergeCheck = async (agentId, wt, repository) => {
61
+ try {
62
+ return (await runCheck(agentId, wt, repository)) ?? { status: 'unknown' };
63
+ } catch {
64
+ return { status: 'unknown' };
65
+ }
66
+ };
67
+
68
+ return { runReviewEntry, runMergeCheck };
46
69
  }