flowviant 0.125.7 → 0.125.12
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 +80 -1
- package/bin/cli.mjs +7 -1
- package/bin/lib/activityFeed.mjs +69 -0
- package/bin/lib/agentMergeReports.mjs +46 -0
- package/bin/lib/agentMergeResolution.mjs +48 -0
- package/bin/lib/agentPlan.mjs +14 -2
- package/bin/lib/agentTaskTypes.mjs +11 -7
- package/bin/lib/artifactContracts.mjs +8 -0
- package/bin/lib/artifactIteration.mjs +18 -0
- package/bin/lib/artifactReporter.mjs +16 -3
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +25 -4
- package/bin/lib/codexResearch.mjs +35 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- package/bin/lib/daemonJournal.mjs +102 -0
- package/bin/lib/daemonLogging.mjs +22 -18
- package/bin/lib/fleet.mjs +16 -6
- package/bin/lib/fleetReports.mjs +12 -5
- package/bin/lib/hooks/orchestrator.mjs +3 -1
- package/bin/lib/logReplay.mjs +133 -0
- package/bin/lib/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +40 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +51 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/prompts.mjs +122 -7
- package/bin/lib/repositoryDelivery.mjs +1 -1
- package/bin/lib/repositoryMerge.mjs +1 -1
- package/bin/lib/researchHandoff.mjs +144 -0
- package/bin/lib/researchHandoffText.mjs +3 -0
- package/bin/lib/researchKnowledge.mjs +60 -0
- package/bin/lib/researchRestoration.mjs +38 -0
- package/bin/lib/resultInputs.mjs +83 -0
- package/bin/lib/runTurn.mjs +76 -43
- package/bin/lib/runtimeClaude.mjs +9 -4
- package/bin/lib/runtimeCodex.mjs +51 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/testFindings.mjs +60 -0
- package/bin/lib/testFindingsContract.mjs +2 -0
- package/bin/lib/turnDiagnostics.mjs +2 -1
- package/bin/lib/turnOutput.mjs +63 -0
- package/bin/lib/turnRecord.mjs +11 -0
- package/bin/lib/turnSubagents.mjs +13 -3
- package/bin/lib/ui.mjs +2 -2
- package/bin/lib/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +2 -2
- package/bin/lib/workActivity.mjs +65 -0
- package/bin/lib/workAgentBriefRunner.mjs +2 -2
- package/bin/lib/workAgentMerges.mjs +57 -52
- package/bin/lib/workAgentPlans.mjs +76 -27
- package/bin/lib/workAgentPrecheck.mjs +1 -1
- package/bin/lib/workAgentTurnExecution.mjs +55 -9
- package/bin/lib/workAgentTurnOutcome.mjs +12 -3
- package/bin/lib/workAgentTurnReports.mjs +17 -3
- package/bin/lib/workAgentTurnShape.mjs +8 -5
- package/bin/lib/workAgentTurns.mjs +8 -6
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workSessionTurns.mjs +7 -2
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Complete pipe lines, with the time their first chunk was observed.
|
|
2
|
+
* Pending text stays below 64 Ki characters in memory. Large protocol/error
|
|
3
|
+
* lines spill to an anonymous private file, so neither a long JSON event nor
|
|
4
|
+
* an unterminated stderr line needs truncation or invented line breaks.
|
|
5
|
+
* The file is unlinked immediately: close, crash and kill leave no log behind.
|
|
6
|
+
*/
|
|
7
|
+
import { closeSync, mkdtempSync, openSync, readFileSync, rmdirSync, unlinkSync, writeSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const PARTIAL_LINE_CHARS = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
export function createObservedLines(onLine, { now = Date.now } = {}) {
|
|
14
|
+
let parts = [], size = 0, observedAt = null, fd = null, offset = 0;
|
|
15
|
+
const clear = () => {
|
|
16
|
+
if (fd !== null) closeSync(fd);
|
|
17
|
+
parts = []; size = 0; observedAt = null; fd = null; offset = 0;
|
|
18
|
+
};
|
|
19
|
+
const write = (text) => {
|
|
20
|
+
const bytes = Buffer.from(text);
|
|
21
|
+
let written = 0;
|
|
22
|
+
while (written < bytes.length)
|
|
23
|
+
written += writeSync(fd, bytes, written, bytes.length - written, offset + written);
|
|
24
|
+
offset += written;
|
|
25
|
+
};
|
|
26
|
+
const append = (text) => {
|
|
27
|
+
if (!text) return;
|
|
28
|
+
if (observedAt === null) observedAt = now();
|
|
29
|
+
if (fd === null && size + text.length > PARTIAL_LINE_CHARS) {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-line-'));
|
|
31
|
+
const path = join(dir, 'partial');
|
|
32
|
+
try { fd = openSync(path, 'wx+', 0o600); }
|
|
33
|
+
finally {
|
|
34
|
+
try { unlinkSync(path); } finally { rmdirSync(dir); }
|
|
35
|
+
}
|
|
36
|
+
for (const part of parts) write(part);
|
|
37
|
+
parts = [];
|
|
38
|
+
}
|
|
39
|
+
if (fd === null) parts.push(text);
|
|
40
|
+
else write(text);
|
|
41
|
+
size += text.length;
|
|
42
|
+
};
|
|
43
|
+
const flush = () => {
|
|
44
|
+
const at = observedAt ?? now();
|
|
45
|
+
try {
|
|
46
|
+
// Positioned writes leave the descriptor's read position at zero.
|
|
47
|
+
onLine(fd === null ? parts.join('') : readFileSync(fd, 'utf8'), at);
|
|
48
|
+
} finally { clear(); }
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
push(text) {
|
|
52
|
+
let start = 0, nl;
|
|
53
|
+
while ((nl = text.indexOf('\n', start)) >= 0) {
|
|
54
|
+
append(text.slice(start, nl));
|
|
55
|
+
flush();
|
|
56
|
+
start = nl + 1;
|
|
57
|
+
}
|
|
58
|
+
append(text.slice(start));
|
|
59
|
+
},
|
|
60
|
+
end() { if (size) flush(); },
|
|
61
|
+
discard: clear,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** A run-scoped loopback capability. Only this daemon holds fleet credentials;
|
|
2
2
|
* native tools and MCP clients receive a random capability for this one run. */
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { fleetRequest } from './fleetPost.mjs';
|
|
5
6
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
6
7
|
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
@@ -10,11 +11,14 @@ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs'
|
|
|
10
11
|
import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
|
|
11
12
|
import { SAFE_POINT_AFTER_EDIT as AFTER_EDIT } from './orchestratorProtocol.mjs';
|
|
12
13
|
import { MACHINE_MESSAGE_ROUTE_STATES, MESSAGE_ROUTE_FIELD_MAX, OPEN_MESSAGE_ROUTE_STATES, ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
13
|
-
import { isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
14
|
-
import { followupTarget, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
|
|
14
|
+
import { isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
15
|
+
import { childBrain, childChoice, followupTarget, readOnlyShell, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
|
|
15
16
|
import { SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
|
|
16
17
|
|
|
17
18
|
const WAIT = 'Waiting for a free slot on the machine';
|
|
19
|
+
// A research card's children read inside its fence; the orchestrator alone writes and delivers.
|
|
20
|
+
const READ_ONLY_CHILD = ' This is a research card: you only read. Write no file, edit nothing and commit nothing; return your findings, their sources and what you could not verify.';
|
|
21
|
+
const READ_ONLY_REFUSAL = 'This research card’s children only read: no file writes. Return your findings to the orchestrator, which alone writes and delivers the card.';
|
|
18
22
|
const HELD = 'At the checkpoint for a message';
|
|
19
23
|
const terminal = status => SETTLED_SUBAGENT_STATUSES.includes(status);
|
|
20
24
|
const filled = s => typeof s === 'string' && s.trim().length > 0;
|
|
@@ -22,8 +26,23 @@ const filled = s => typeof s === 'string' && s.trim().length > 0;
|
|
|
22
26
|
const pointOf = s => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ').replace(/^["'“‘`]+|["'”’`.!?;:,…]+$/g, '');
|
|
23
27
|
const text = (s, n) => scrubNativeText(s, n, scrub);
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `children` is what this run's children may do and run at (2026-10-19):
|
|
31
|
+
* `access` 'edit' on a code card, 'read' on a research card (the gate refuses
|
|
32
|
+
* a child's write, and the hook does too while this controller is down), and
|
|
33
|
+
* the card's `model` and `effort` as the parent runs them, which a spawn that
|
|
34
|
+
* names neither inherits explicitly (orchestratorNative.mjs `childBrain`);
|
|
35
|
+
* on Codex, only for a model in `catalog`, the measured spawnable models.
|
|
36
|
+
*/
|
|
25
37
|
export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => false, taken = () => {},
|
|
26
|
-
interval = 500, hold = 60_000, clock = Date.now, scrubText = text } = {}) {
|
|
38
|
+
interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {} } = {}) {
|
|
39
|
+
const access = children.access === 'read' ? 'read' : 'edit',
|
|
40
|
+
card = { model: children.model ?? null, effort: children.effort ?? null, catalog: Array.isArray(children.catalog) ? children.catalog : [] };
|
|
41
|
+
// A Codex research child's shell runs in a read-only bubblewrap view
|
|
42
|
+
// (orchestratorNative.mjs `readOnlyShell`); without one it is refused.
|
|
43
|
+
// Claude's research shell is the posture's guarded readers already.
|
|
44
|
+
const bwrap = runtime === 'codex' && access === 'read'
|
|
45
|
+
? (children.bwrap !== undefined ? children.bwrap : ['/usr/bin/bwrap', '/bin/bwrap'].find(path => existsSync(path)) ?? null) : null;
|
|
27
46
|
if (typeof admit !== 'function' || typeof admit.reserve !== 'function') throw new Error('This run has no project admission pool.');
|
|
28
47
|
// Minted before the server exists, so the scrubber knows it from the start.
|
|
29
48
|
const capability = mintHeldCapability();
|
|
@@ -111,6 +130,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
111
130
|
const spawn = readSpawn(event);
|
|
112
131
|
if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
|
|
113
132
|
const { callId, brief } = spawn;
|
|
133
|
+
// Above the card's model or effort is refused before any record or slot.
|
|
134
|
+
const chosen = childBrain(event, spawn, card);
|
|
135
|
+
if (chosen.deny) return { deny: chosen.deny };
|
|
114
136
|
const resumed = spawn.resume !== null && records.has(resolve(spawn.resume)) ? resolve(spawn.resume) : null;
|
|
115
137
|
const id = resumed ?? claim(spawn)?.id ?? resolve(callId);
|
|
116
138
|
aliases.set(callId, id); calls.set(callId, id);
|
|
@@ -122,9 +144,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
122
144
|
// Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
|
|
123
145
|
for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
|
|
124
146
|
start(a, a.title);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
// A cheaper model or lower effort than the card's is relayed on the child's
|
|
148
|
+
// stream, and the parent is asked to say why in its own update.
|
|
149
|
+
const choice = childChoice(event, spawn, card);
|
|
150
|
+
if (choice) trace.prose('note', `Runs on ${choice}.`, id);
|
|
151
|
+
const childContract = `\n\nCard orchestrator agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with agentId=${id}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`;
|
|
152
|
+
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.${spawn.relocated ? ' Its isolation and cwd were removed: children share this card’s worktree on this machine.' : ''}${choice ? ` This child runs on ${choice}: name that choice and why in your next update.` : ''}`,
|
|
153
|
+
updatedInput: withChildContract(event, spawn, childContract + (access === 'read' ? READ_ONLY_CHILD : ''), chosen.brain) };
|
|
128
154
|
}
|
|
129
155
|
// Native readings never revive a settled record or start a waiting spawn
|
|
130
156
|
// or follow-up (Codex records a refused call's start anyway); only an
|
|
@@ -245,9 +271,14 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
245
271
|
}
|
|
246
272
|
if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
|
|
247
273
|
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
274
|
+
if (child && access === 'read' && isChildWriteTool(ev.tool_name)) return { deny: READ_ONLY_REFUSAL };
|
|
248
275
|
if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
|
|
249
276
|
if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
|
|
250
277
|
const point = checkpoint(ev); if (point.deny) return point;
|
|
278
|
+
if (child && access === 'read' && runtime === 'codex' && isShellTool(ev.tool_name)) {
|
|
279
|
+
const fenced = readOnlyShell(ev, bwrap);
|
|
280
|
+
return fenced ? { updatedInput: fenced } : { deny: `${READ_ONLY_REFUSAL} ${bwrap ? 'Its shell could not be fenced read-only: give the command as text.' : 'This machine has no bubblewrap to keep a child’s shell read-only, so a child here searches the web; the orchestrator reads files.'}` };
|
|
281
|
+
}
|
|
251
282
|
if (isPersonQuestionTool(ev.tool_name))
|
|
252
283
|
return { deny: 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
|
|
253
284
|
}
|
|
@@ -319,8 +350,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
319
350
|
error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
|
|
320
351
|
})); }
|
|
321
352
|
};
|
|
322
|
-
const { server, env, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
|
|
353
|
+
const { server, env: heldEnv, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
|
|
323
354
|
capability, isClosed: () => closed, respond });
|
|
355
|
+
// The hook reads it to refuse a research child's write while this is down.
|
|
356
|
+
const env = { ...heldEnv, FLOWVIANT_ORCHESTRATOR_CHILDREN: access };
|
|
324
357
|
const timer = setInterval(() => {
|
|
325
358
|
if (closed || polling) return;
|
|
326
359
|
polling = true;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Words the model reads stay in orchestrator.mjs, and so do its refusals:
|
|
11
11
|
* these return fields, or null.
|
|
12
12
|
*/
|
|
13
|
-
import { isBackgroundSpawnTool, isClaudeFollowupTool } from './nativeAgentTools.mjs';
|
|
13
|
+
import { isBackgroundSpawnTool, isClaudeFollowupTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
14
14
|
|
|
15
15
|
/** A spawn's prompt and the fields a waiting record is matched and named by,
|
|
16
16
|
* or null when the gate cannot hold it (no call id or no text prompt). */
|
|
@@ -29,10 +29,140 @@ export function readSpawn(event) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The spawn's input as the gate rewrites it: the contract appended to its
|
|
32
|
-
* prompt,
|
|
33
|
-
|
|
32
|
+
* prompt, a Claude child sent to the background, and the child's model and
|
|
33
|
+
* effort (`brain`, from `childBrain`) where the spawn named none. */
|
|
34
|
+
export const withChildContract = (event, spawn, contract, brain = {}) => ({ ...spawn.input, ...brain, ...(isBackgroundSpawnTool(event.tool_name) ? { run_in_background: true } : {}),
|
|
34
35
|
...(spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
|
|
35
36
|
|
|
37
|
+
/** Both CLIs' effort ladder, lowest first (Codex stops at xhigh). */
|
|
38
|
+
export const EFFORT_LADDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
39
|
+
const effortRank = effort => EFFORT_LADDER.indexOf(String(effort ?? '').toLowerCase());
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* CLAUDE'S CHILD MODEL IS AN ALIAS (measured on Claude Code 2.1.303, offline).
|
|
43
|
+
* The Agent tool's `model` input is the enum below; a full id fails its schema
|
|
44
|
+
* whether the model or a hook's `updatedInput` wrote it, so the gate passes
|
|
45
|
+
* the card model's family. An alias of the parent's own family keeps the
|
|
46
|
+
* parent's exact id (claude-opus-4-8 stays claude-opus-4-8); any other alias
|
|
47
|
+
* resolves to the CLI's current version of that family.
|
|
48
|
+
*/
|
|
49
|
+
export const CLAUDE_CHILD_MODELS = Object.freeze(['sonnet', 'opus', 'haiku', 'fable']);
|
|
50
|
+
/** The families whose order is known here, cheapest first. Fable's place
|
|
51
|
+
* beside them is not measured, so the gate does not rank it. */
|
|
52
|
+
const CLAUDE_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
|
|
53
|
+
export const claudeFamily = model => String(model ?? '').toLowerCase().match(/(?:^|[^a-z])(sonnet|opus|haiku|fable)(?=[^a-z]|$)/)?.[1] ?? null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* THE CHILD'S MODEL AND EFFORT (2026-10-19): `{ brain }` — the fields the gate
|
|
57
|
+
* adds to the spawn — or `{ deny }` when the spawn chose above the card.
|
|
58
|
+
*
|
|
59
|
+
* `card` is `{ model, effort }` as the parent runs: Claude's pinned model or
|
|
60
|
+
* the daemon's MODEL pin, the job's effort or null. A child defaults to the
|
|
61
|
+
* card's model and effort, so the gate names them where the spawn named
|
|
62
|
+
* neither; that beats the person's CLAUDE_CODE_SUBAGENT_MODEL and an agent
|
|
63
|
+
* definition's own model (measured: the spawn input wins), so a child never
|
|
64
|
+
* runs on the CLI's global default. The effort ceiling is the card's, or with
|
|
65
|
+
* none pinned the level the hook payload says the caller runs at (Claude
|
|
66
|
+
* 2.1.303 sends `effort: { level }` on every hook). A model is refused only
|
|
67
|
+
* where both families sit on the known ladder and the child's is above.
|
|
68
|
+
*/
|
|
69
|
+
export function childBrain(event, spawn, card = {}) {
|
|
70
|
+
if (!isBackgroundSpawnTool(event.tool_name)) return isSpawnTool(event.tool_name) ? codexChildBrain(event, spawn, card) : { brain: {} };
|
|
71
|
+
const named = spawn.input, ceiling = card.effort ?? event.effort?.level ?? null;
|
|
72
|
+
if (typeof named.effort === 'string' && ceiling && effortRank(ceiling) >= 0 && effortRank(named.effort) > effortRank(ceiling))
|
|
73
|
+
return { deny: `Choose an effort at or below this card's (${ceiling}) for a child, or leave it out to use the card's.` };
|
|
74
|
+
const family = claudeFamily(card.model), chosen = claudeFamily(named.model);
|
|
75
|
+
if (chosen && family && CLAUDE_TIERS.includes(chosen) && CLAUDE_TIERS.includes(family) && CLAUDE_TIERS.indexOf(chosen) > CLAUDE_TIERS.indexOf(family))
|
|
76
|
+
return { deny: `Choose this card's model (${family}) or a cheaper one for a child, or leave it out to use the card's.` };
|
|
77
|
+
const brain = {};
|
|
78
|
+
if (named.model === undefined && family && CLAUDE_CHILD_MODELS.includes(family)) brain.model = family;
|
|
79
|
+
const effort = card.effort ?? null;
|
|
80
|
+
if (named.effort === undefined && effortRank(effort) >= 0) brain.effort = effort;
|
|
81
|
+
return { brain };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* CODEX'S CHILD (measured on codex-cli 0.163.0, offline, and in its source,
|
|
86
|
+
* core/src/agent/child_config.rs). A child starts from the parent turn's live
|
|
87
|
+
* model and effort; a spawn's `model`/`reasoning_effort` replace them, else
|
|
88
|
+
* the person's `agents.default_subagent_model`/`_reasoning_effort`; a role
|
|
89
|
+
* (`agent_type`) applies after both, and a model named without an effort runs
|
|
90
|
+
* at that model's own default effort. The gate's `updatedInput` is honoured
|
|
91
|
+
* for both fields (V1 and V2). So the card's PINNED model and effort are named
|
|
92
|
+
* where the spawn named none and the catalog holds the model — the person's
|
|
93
|
+
* subagent default never replaces them — and an unpinned card keeps the
|
|
94
|
+
* native inheritance. Codex's catalog admits any catalog model, so only the
|
|
95
|
+
* effort has a ceiling here.
|
|
96
|
+
*/
|
|
97
|
+
function codexChildBrain(event, spawn, card) {
|
|
98
|
+
const named = spawn.input;
|
|
99
|
+
if (typeof named.reasoning_effort === 'string' && effortRank(card.effort) >= 0 && effortRank(named.reasoning_effort) > effortRank(card.effort))
|
|
100
|
+
return { deny: `Choose a reasoning_effort at or below this card's (${card.effort}) for a child, or leave it out to use the card's.` };
|
|
101
|
+
// spawn_agent takes only a catalog model, and an effort only for one: real
|
|
102
|
+
// 0.163 refused "Unknown model `gpt-5.5-codex` for spawn_agent" and, with no
|
|
103
|
+
// model named under an uncatalogued parent, "Reasoning effort `low` is not
|
|
104
|
+
// supported for model …" — while the child of such a parent inherits it
|
|
105
|
+
// fine. So the gate names them only where the measured catalog
|
|
106
|
+
// (`card.catalog`, models_cache.json's listed rows) holds the model the
|
|
107
|
+
// child would run; anywhere else the native inheritance stands.
|
|
108
|
+
const runs = typeof named.model === 'string' ? named.model : card.model ?? (typeof event.model === 'string' ? event.model : null);
|
|
109
|
+
const listed = (card.catalog ?? []).find(m => m?.slug === runs);
|
|
110
|
+
if (!listed) return { brain: {} };
|
|
111
|
+
const brain = {};
|
|
112
|
+
if (named.model === undefined && card.model === runs) brain.model = card.model;
|
|
113
|
+
if (named.reasoning_effort === undefined && effortRank(card.effort) >= 0 && (!Array.isArray(listed.efforts) || !listed.efforts.length || listed.efforts.includes(card.effort)))
|
|
114
|
+
brain.reasoning_effort = card.effort;
|
|
115
|
+
return { brain };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* WHAT THE ORCHESTRATOR CHOSE BELOW THE CARD, in words, or null when the
|
|
120
|
+
* child runs at the card's model and effort (or the gate cannot tell). The
|
|
121
|
+
* gate relays it on the child's stream and asks the parent to say why
|
|
122
|
+
* (2026-10-19: measured on a real Opus card, the parent picked sonnet at low
|
|
123
|
+
* effort for a simple child and never said so).
|
|
124
|
+
*/
|
|
125
|
+
export function childChoice(event, spawn, card = {}) {
|
|
126
|
+
const at = (m, e) => `${m}${e ? ` at ${e} effort` : ''}`;
|
|
127
|
+
const named = spawn.input;
|
|
128
|
+
if (!isBackgroundSpawnTool(event.tool_name)) {
|
|
129
|
+
if (!isSpawnTool(event.tool_name)) return null;
|
|
130
|
+
// Codex's root hook names the parent's live model (measured on 0.163).
|
|
131
|
+
const cardModel = card.model ?? (typeof event.model === 'string' ? event.model : null), cardEffort = card.effort ?? null;
|
|
132
|
+
const model = typeof named.model === 'string' && cardModel && named.model !== cardModel;
|
|
133
|
+
const effort = typeof named.reasoning_effort === 'string' && cardEffort && named.reasoning_effort !== cardEffort;
|
|
134
|
+
if (!model && !effort) return null;
|
|
135
|
+
return `${at(model ? named.model : cardModel, effort ? named.reasoning_effort : model && !named.reasoning_effort ? 'its own default' : cardEffort)}, where the card runs ${at(cardModel, cardEffort)}`;
|
|
136
|
+
}
|
|
137
|
+
const family = claudeFamily(card.model), chosen = claudeFamily(named.model);
|
|
138
|
+
const cardEffort = card.effort ?? event.effort?.level ?? null;
|
|
139
|
+
const model = chosen && family && chosen !== family, effort = typeof named.effort === 'string' && cardEffort && named.effort !== cardEffort;
|
|
140
|
+
if (!model && !effort) return null;
|
|
141
|
+
return `${at(model ? chosen : family ?? card.model, effort ? named.effort : cardEffort)}, where the card runs ${at(card.model ?? family, cardEffort)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const shellQuote = s => `'${String(s).replaceAll("'", `'\\''`)}'`;
|
|
145
|
+
/**
|
|
146
|
+
* A CODEX RESEARCH CHILD'S SHELL, READ-ONLY (2026-10-19). The child inherits
|
|
147
|
+
* the research fence (0.160.0 child_config.rs), which writes the copy's
|
|
148
|
+
* artifacts and scratch: measured on 0.163, a child's `echo x >
|
|
149
|
+
* .flowviant/artifacts/x.md` landed. Its command (`Bash`, `{ command }` on
|
|
150
|
+
* the hook) runs instead inside a nested bubblewrap view of that same fence
|
|
151
|
+
* with every path read-only and a private, discarded scratch for
|
|
152
|
+
* here-documents. Measured the same way, on V1, V2 code mode and a grandchild:
|
|
153
|
+
* redirects, python, cp, mkdir, ln and mv into the artifacts and the scratch
|
|
154
|
+
* all failed "Read-only file system", while reads, here-documents and web
|
|
155
|
+
* search worked. Never a tmpfs over /tmp (the copy may live there), and no
|
|
156
|
+
* --unshare-net (refused there; the fence has no network anyway). Null when
|
|
157
|
+
* the event names no absolute cwd or no command: the gate then refuses.
|
|
158
|
+
*/
|
|
159
|
+
export function readOnlyShell(event, bwrap) {
|
|
160
|
+
const input = event.tool_input ?? {}, cwd = event.cwd;
|
|
161
|
+
if (!bwrap || typeof input.command !== 'string' || typeof cwd !== 'string' || !cwd.startsWith('/')) return null; // research is Linux/WSL only
|
|
162
|
+
const scratch = `${cwd}/.flowviant/tmp`;
|
|
163
|
+
return { ...input, command: `${bwrap} --ro-bind / / --dev /dev --tmpfs ${shellQuote(scratch)} --setenv TMPDIR ${shellQuote(scratch)} --die-with-parent -- /bin/bash -c ${shellQuote(input.command)}` };
|
|
164
|
+
}
|
|
165
|
+
|
|
36
166
|
/** Every native id a spawn or follow-up's receipt names, and whether it says
|
|
37
167
|
* the call failed. */
|
|
38
168
|
export function spawnReceipt(event) {
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
+
import { TESTS_SCOPE } from './testFindingsContract.mjs';
|
|
1
2
|
import { SAFE_POINT_AFTER_EDIT } from './orchestratorProtocol.mjs';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* THE POSTURES A MARKED CARD IS ORCHESTRATED IN, and what their children may
|
|
6
|
+
* do there (2026-10-19). A code card's children edit the shared worktree; a
|
|
7
|
+
* research card's only read inside the research fence, and the gate refuses
|
|
8
|
+
* their writes (orchestrator.mjs). Design, model, image and deck cards keep
|
|
9
|
+
* delegation off. The server marks the same kinds (shared
|
|
10
|
+
* `orchestrationMinFor`).
|
|
11
|
+
*/
|
|
12
|
+
export const ORCHESTRATED_POSTURES = Object.freeze({ build: 'edit', research: 'read' });
|
|
13
|
+
export const orchestratesPosture = (posture) => Object.hasOwn(ORCHESTRATED_POSTURES, posture);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SPLIT WITHOUT BEING ASKED (2026-10-19, the owner: the page "looks the same
|
|
17
|
+
* because the agents are not intuitively doing orchestration"). The 2026-10-15
|
|
18
|
+
* contract leaned solo ("Work yourself when the card spans one area. Only
|
|
19
|
+
* split work spanning several areas…"), and a model reads that as permission
|
|
20
|
+
* to never split. The rule is now the independence of the parts, with the
|
|
21
|
+
* shapes they take named, and dependent parts kept in order.
|
|
22
|
+
*/
|
|
23
|
+
const SPLIT = `SPLIT INDEPENDENT WORK INTO NATIVE SUBAGENTS WITHOUT BEING ASKED. Before your first edit, list the card's parts in a short update. When two or more parts can proceed without waiting on each other, spawn one native in-process subagent for each independent part but one, and do that one yourself meanwhile. This holds however small the parts are; the person does not have to request it, and this contract is the authorization. Independent parts look like: several selected findings, separate files or areas (server and daemon, API and UI), tests written beside the code they cover, independent questions to investigate. A part that needs another part's result runs after that prerequisite, by you or by a child you start then. Stay solo only when the card has no independent parts, and if you keep an independent part yourself, say why in your update. Give each child a concrete title, prompt and kind (Backend/UI/Tests/Research…). Do not create extra branches or Flowviant agents. Use a native agent type that can call card_orchestrator MCP (Claude general-purpose); keep the work yourself if none can. Never give a child isolation or a different cwd: children share this card's worktree on this machine. After spawning, use describe_agent to report the kind and title you chose when its native role is generic.`;
|
|
24
|
+
|
|
25
|
+
const RESEARCH = `THIS IS A RESEARCH CARD, SO EVERY CHILD ONLY READS. A child works inside this card's research fence: the same read fence, web policy and working directory as you. It writes no file, edits nothing and commits nothing, and the gate refuses a child's write. Give each child one question or one set of sources, and ask it to return its findings with their sources (links or repository paths) and what it could not verify. You alone deliver the card: merge the children's findings, resolve or state their conflicts, write any requested document yourself, and keep the research contract's own stops, including an investigation's numbered-findings question and its wait for the person's selection.`;
|
|
26
|
+
|
|
27
|
+
/** Each CLI's own spelling of a child's model and effort, as measured
|
|
28
|
+
* (docs/agent-orchestration.md, "Child model and effort"). */
|
|
29
|
+
const CHILD_SPELLING = {
|
|
30
|
+
claude: 'On Claude Code, name a cheaper model with the Agent tool’s own model input (sonnet or haiku) and a lower effort with its effort input.',
|
|
31
|
+
codex: 'On Codex, name a cheaper model with spawn_agent’s model input and a lower effort with its reasoning_effort input.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** What the card runs at, as the model is told it: the pin, or the CLI's own
|
|
35
|
+
* default where nobody pinned one (never invented). */
|
|
36
|
+
const atCard = (model, effort) =>
|
|
37
|
+
`This card runs on ${model ? model : 'its CLI’s default model'} at ${effort ? `${effort} effort` : 'its CLI’s default effort'}.`;
|
|
38
|
+
|
|
39
|
+
const childBrain = ({ runtime, model, effort }) => `CHILD MODEL AND EFFORT. ${atCard(model, effort)} By default every child runs at the card's model and effort${model || effort ? '; the gate names them when your spawn names neither, so a child never falls back to the CLI\'s own global default' : ', which it inherits from this run when your spawn names neither'}. For a simple, well-bounded child — a search, a sweep of file reads, a mechanical edit — you may choose a cheaper model or a lower effort. Keep the card's model and effort for hard or judgment-heavy work. Never choose a model or effort above the card's, and only models of this card's own CLI; the gate refuses an effort above the card's. ${CHILD_SPELLING[runtime] ?? ''} When you choose anything other than the card's model or effort for a child, name the choice and why in your update.`.replace(' ', ' ');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The orchestrator's contract for one run: its posture's split and child
|
|
43
|
+
* rules, the card's own model and effort, then the gate, routing and child
|
|
44
|
+
* prompt rules every orchestrated card shares.
|
|
45
|
+
*/
|
|
46
|
+
export function orchestratorContract({ posture = 'build', taskType = null, runtime = 'claude', model = null, effort = null } = {}) {
|
|
47
|
+
const reads = ORCHESTRATED_POSTURES[posture] === 'read';
|
|
48
|
+
return `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
|
|
4
49
|
You are the model the person chose and remain responsible for the card and its final JSON.
|
|
5
|
-
|
|
50
|
+
${SPLIT}${taskType === 'tests' ? `\n${TESTS_SCOPE} Give every child this Tests scope explicitly.` : ''}${reads ? `\n${RESEARCH}` : ''}
|
|
51
|
+
${childBrain({ runtime, model, effort })}
|
|
6
52
|
|
|
7
53
|
The daemon gates every native spawn against this project's Turns at once pool, shared with its other turns. Claude children run in the background so you remain available for messages and their questions; Codex spawns are already asynchronous. A refused spawn with a waiting id has not started. Keep working yourself; do not wait for it. When notified of a free slot, retry its native spawn (the same or a reworded prompt) or its native follow-up; the slot is held for about a minute, then offered again later. Never bypass this gate with another CLI process. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
|
|
8
54
|
On Claude you may end your turn while native children run: Claude keeps this process alive and wakes you on each child's completion and on the person's notes. Follow-ups and answers to a Claude child go through SendMessage to its agent id. Write the card's final JSON only after every child has finished or been stopped; your last answer is the card's answer.
|
|
@@ -15,8 +61,9 @@ The card_orchestrator MCP tools report routing and child questions. A live perso
|
|
|
15
61
|
- Only after native delivery at that checkpoint: handed with that agent id/kind/safePoint. This appends "From {orchestrator} …" to its stream. Never report a handoff before doing it.
|
|
16
62
|
Use the tools' agent record id when one is supplied by a native spawn gate. Keep a later native id with that record.
|
|
17
63
|
|
|
18
|
-
All child prompts must say: share this worktree, preserve others' edits, return questions to the orchestrator with agent_question, read its answer before continuing, never escalate directly to the person, and do not commit independently of the card's orchestrator. Answer child questions from the card, code and earlier notes with answer_agent when possible. If only the person can decide, mark escalate with answer_agent, then use the existing blocked JSON question card naming the asking child. Leave that child asked until answered. When the person answers in a resumed run, call answer_agent with the earlier agentId, nativeAgentId, kind/title and question from your conversation plus their answer; then give it to that same child through a native follow-up. Never guess a missing identity.
|
|
64
|
+
All child prompts must say: share this worktree, preserve others' edits, return questions to the orchestrator with agent_question, read its answer before continuing, never escalate directly to the person, and ${reads ? 'write no file and commit nothing: return findings to the orchestrator' : 'do not commit independently of the card\'s orchestrator'}. Answer child questions from the card, code and earlier notes with answer_agent when possible. If only the person can decide, mark escalate with answer_agent, then use the existing blocked JSON question card naming the asking child. Leave that child asked until answered. When the person answers in a resumed run, call answer_agent with the earlier agentId, nativeAgentId, kind/title and question from your conversation plus their answer; then give it to that same child through a native follow-up. Never guess a missing identity.
|
|
19
65
|
|
|
20
66
|
Urgent is the person's explicit exception. The daemon uses the whole-turn interrupt/resume fallback when a child cannot be targeted reliably; the same stored note becomes the resumed prompt and reads "interrupted the run". Stop ends the whole run. Do not keep working or hand off notes after Stop.
|
|
21
67
|
|
|
22
|
-
Claude receives notes through its native live stdin. Codex exec cannot accept stdin mid-turn: on supported
|
|
68
|
+
Claude receives notes through its native live stdin. Codex exec cannot accept stdin mid-turn: on supported card CLIs, the daemon injects pending notes through the next native tool hook boundary in the same turn. During a tool-free answer or on older Codex turns, the note remains for the next run; do not claim it was already read. No new model is involved.`;
|
|
69
|
+
}
|
package/bin/lib/postureTools.mjs
CHANGED
|
@@ -16,6 +16,9 @@ const POSTURES = {
|
|
|
16
16
|
claude: {
|
|
17
17
|
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
18
|
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
|
+
// An orchestrated research card (2026-10-19): its children read under
|
|
20
|
+
// CARD ORCHESTRATOR, so the note does not withhold delegation.
|
|
21
|
+
orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
22
|
},
|
|
20
23
|
codex: {
|
|
21
24
|
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
@@ -40,6 +43,10 @@ const POSTURES = {
|
|
|
40
43
|
},
|
|
41
44
|
};
|
|
42
45
|
|
|
46
|
+
/** A fenced turn's delegation sentence. An orchestrated research card drops it
|
|
47
|
+
* (2026-10-19): its contract authorizes read-only children. */
|
|
48
|
+
const NO_DELEGATION = 'Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.';
|
|
49
|
+
|
|
43
50
|
/** Configuration supplies tools; a skill supplies instructions, not a tool. */
|
|
44
51
|
const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not which supporting capabilities
|
|
45
52
|
may be used. Use available tools needed for the person's request, including
|
|
@@ -56,7 +63,7 @@ requires the person's explicit choice first; never default to OPENAI_API_KEY.
|
|
|
56
63
|
Use personal connectors only for the requested work; sending messages or other
|
|
57
64
|
external writes requires explicit authorization. Never open the person's browser
|
|
58
65
|
or desktop; headless inspection must stay within the granted filesystem fence.
|
|
59
|
-
|
|
66
|
+
${NO_DELEGATION}`;
|
|
60
67
|
|
|
61
68
|
const list = (names) => names.join(', ');
|
|
62
69
|
|
|
@@ -85,8 +92,8 @@ const named = (runtime, names) => (Array.isArray(names) && names.length
|
|
|
85
92
|
* `[{ name, reason, source }]` for the servers it could not mount — `source`
|
|
86
93
|
* 'project' (the base `.mcp.json`) or 'machine' — and, with `partial: true`,
|
|
87
94
|
* a machine server mounted with a limit (tools the person set to ask).
|
|
88
|
-
* `orchestrated` a code card whose contract authorizes native
|
|
89
|
-
* (
|
|
95
|
+
* `orchestrated` a code or research card whose contract authorizes native
|
|
96
|
+
* children (orchestratorPrompt.mjs), which the note then does not contradict.
|
|
90
97
|
*
|
|
91
98
|
* EACH SENTENCE STANDS ALONE (2026-10-17). "The base branch’s .mcp.json lists
|
|
92
99
|
* no MCP server. So are the MCP servers …" read as the machine's servers
|
|
@@ -109,7 +116,7 @@ export function postureToolsNote({ posture, runtime, mounted = [], machine = tru
|
|
|
109
116
|
const missing = (rows, where) => (rows.length ? ` ${where}, but not mounted here: ${why(rows)}.` : '');
|
|
110
117
|
const instead = replaced.length ? ` Listed in the base branch’s .mcp.json but not mountable from it, so mounted from this machine’s configuration instead: ${why(replaced)}.` : '';
|
|
111
118
|
const base = `${missing(fromProject, 'Listed in the base branch’s .mcp.json')}${instead}`;
|
|
112
|
-
if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration.${named(runtime, machineNames)}${base}${DEFERRED[runtime] ?? ''} ${SUPPORTING_CAPABILITIES.replace(
|
|
119
|
+
if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration.${named(runtime, machineNames)}${base}${DEFERRED[runtime] ?? ''} ${SUPPORTING_CAPABILITIES.replace(`\n${NO_DELEGATION}`, orchestrated ? '' : '\nDelegate only when the person explicitly requests it.')} ` : '';
|
|
113
120
|
const project = mounted.length
|
|
114
121
|
? `The project’s MCP servers from the base branch’s .mcp.json are mounted: ${list(mounted)}.`
|
|
115
122
|
: fromProject.length || replaced.length
|
|
@@ -127,8 +134,9 @@ export function postureToolsNote({ posture, runtime, mounted = [], machine = tru
|
|
|
127
134
|
: ` ${Machine} are not mounted on this turn.`;
|
|
128
135
|
const limits = limited.length ? ` In ${MACHINE[runtime]}, mounted with a limit on this turn: ${why(limited)}.` : '';
|
|
129
136
|
const worktree = machine ? ' is not mounted from that file (a machine server of the same name still is);' : ' is not mounted;';
|
|
130
|
-
|
|
137
|
+
const delegates = orchestrated && posture === 'research';
|
|
138
|
+
return `TOOLS ON THIS CARD. You have ${row.has}. You have ${delegates ? row.orchestratedLacks ?? row.lacks : row.lacks}. ${project}${mine}${base}${missing(fromMachine, `In ${MACHINE[runtime]}`)}${limits}${machine ? DEFERRED[runtime] ?? '' : ''} A server added to .mcp.json only in this worktree${worktree} the base branch’s file is the one that counts.
|
|
131
139
|
There is no Flowviant setting that adds a tool, so never invent one. A missing
|
|
132
140
|
server can be configured in the base branch's .mcp.json or the machine's CLI.
|
|
133
|
-
${SUPPORTING_CAPABILITIES}`;
|
|
141
|
+
${delegates ? SUPPORTING_CAPABILITIES.replace(`\n${NO_DELEGATION}`, '') : SUPPORTING_CAPABILITIES}`;
|
|
134
142
|
}
|
package/bin/lib/printable.mjs
CHANGED
|
@@ -88,7 +88,7 @@ export function printableLine(raw, maxCodePoints) {
|
|
|
88
88
|
* would have to keep SGR — and cannot tell the daemon's from an attacker's.
|
|
89
89
|
* Text is cleaned where it ENTERS instead: server strings at their intake
|
|
90
90
|
* (`printable`), and CLI/model output at the one sink that writes it raw
|
|
91
|
-
* (
|
|
91
|
+
* (turnOutput.mjs's `emit`, before the daemon's coloured prefix is added). The one
|
|
92
92
|
* other reader is `flowviant logs` (views.mjs), which replays a log file that
|
|
93
93
|
* an older daemon may have written raw — so a replay loses the log's colours
|
|
94
94
|
* along with anybody else's sequences.
|