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.
- package/README.md +24 -1
- package/bin/cli.mjs +5 -0
- package/bin/lib/agentFiles.mjs +1 -1
- package/bin/lib/agentPrerequisiteContract.mjs +8 -0
- package/bin/lib/agentTaskKinds.mjs +4 -4
- package/bin/lib/artifactContracts.mjs +15 -11
- package/bin/lib/artifactPreview.mjs +2 -2
- package/bin/lib/captureAuthority.mjs +82 -0
- package/bin/lib/claudePosture.mjs +9 -0
- package/bin/lib/codexResearch.mjs +12 -1
- package/bin/lib/hooks/orchestrator.mjs +1 -1
- package/bin/lib/intermediateArtifacts.mjs +228 -0
- package/bin/lib/mcpStdio.mjs +11 -3
- package/bin/lib/modelExport.mjs +19 -0
- package/bin/lib/modelExportProtocol.mjs +80 -0
- package/bin/lib/nativeAgentTools.mjs +4 -1
- package/bin/lib/orchestrator.mjs +46 -20
- package/bin/lib/orchestratorClient.mjs +2 -2
- package/bin/lib/orchestratorPrompt.mjs +70 -0
- package/bin/lib/pinnedCapture.mjs +35 -7
- package/bin/lib/prompts.mjs +10 -3
- package/bin/lib/readOnlyRun.mjs +172 -0
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeClaude.mjs +6 -5
- package/bin/lib/runtimeCodex.mjs +4 -4
- package/bin/lib/turnProfile.mjs +1 -0
- package/bin/lib/work.mjs +5 -5
- package/bin/lib/workAgentMerges.mjs +8 -8
- package/bin/lib/workAgentPlans.mjs +69 -43
- package/bin/lib/workAgentReview.mjs +24 -1
- package/bin/lib/workAgentTurnExecution.mjs +29 -6
- package/bin/lib/workAgentTurnOutcome.mjs +15 -3
- package/bin/lib/workAgentTurnShape.mjs +6 -5
- package/bin/lib/workAgentTurns.mjs +4 -0
- package/bin/lib/workAttachments.mjs +4 -2
- package/bin/lib/workCappedTurn.mjs +4 -0
- package/bin/lib/workLiveMessages.mjs +2 -2
- package/bin/lib/workNarration.mjs +3 -2
- package/bin/lib/workPlanRecovery.mjs +2 -21
- package/bin/lib/workSessionTurns.mjs +109 -22
- package/bin/lib/workToolLog.mjs +3 -0
- 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
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -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
|
|
197
|
-
const
|
|
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
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
package/bin/lib/turnProfile.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
const check = await
|
|
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 {
|
|
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)
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// than through a tool, which
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|