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.
Files changed (65) hide show
  1. package/README.md +80 -1
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +48 -0
  6. package/bin/lib/agentPlan.mjs +14 -2
  7. package/bin/lib/agentTaskTypes.mjs +11 -7
  8. package/bin/lib/artifactContracts.mjs +8 -0
  9. package/bin/lib/artifactIteration.mjs +18 -0
  10. package/bin/lib/artifactReporter.mjs +16 -3
  11. package/bin/lib/claudePosture.mjs +21 -0
  12. package/bin/lib/claudeStream.mjs +25 -4
  13. package/bin/lib/codexResearch.mjs +35 -5
  14. package/bin/lib/commandSpecs.mjs +8 -5
  15. package/bin/lib/daemonJournal.mjs +102 -0
  16. package/bin/lib/daemonLogging.mjs +22 -18
  17. package/bin/lib/fleet.mjs +16 -6
  18. package/bin/lib/fleetReports.mjs +12 -5
  19. package/bin/lib/hooks/orchestrator.mjs +3 -1
  20. package/bin/lib/logReplay.mjs +133 -0
  21. package/bin/lib/nativeAgentTools.mjs +20 -3
  22. package/bin/lib/observedLines.mjs +63 -0
  23. package/bin/lib/orchestrator.mjs +40 -7
  24. package/bin/lib/orchestratorNative.mjs +133 -3
  25. package/bin/lib/orchestratorPrompt.mjs +51 -4
  26. package/bin/lib/postureTools.mjs +14 -6
  27. package/bin/lib/printable.mjs +1 -1
  28. package/bin/lib/prompts.mjs +122 -7
  29. package/bin/lib/repositoryDelivery.mjs +1 -1
  30. package/bin/lib/repositoryMerge.mjs +1 -1
  31. package/bin/lib/researchHandoff.mjs +144 -0
  32. package/bin/lib/researchHandoffText.mjs +3 -0
  33. package/bin/lib/researchKnowledge.mjs +60 -0
  34. package/bin/lib/researchRestoration.mjs +38 -0
  35. package/bin/lib/resultInputs.mjs +83 -0
  36. package/bin/lib/runTurn.mjs +76 -43
  37. package/bin/lib/runtimeClaude.mjs +9 -4
  38. package/bin/lib/runtimeCodex.mjs +51 -22
  39. package/bin/lib/runtimeEvents.mjs +17 -7
  40. package/bin/lib/testFindings.mjs +60 -0
  41. package/bin/lib/testFindingsContract.mjs +2 -0
  42. package/bin/lib/turnDiagnostics.mjs +2 -1
  43. package/bin/lib/turnOutput.mjs +63 -0
  44. package/bin/lib/turnRecord.mjs +11 -0
  45. package/bin/lib/turnSubagents.mjs +13 -3
  46. package/bin/lib/ui.mjs +2 -2
  47. package/bin/lib/uplinkScrub.mjs +49 -0
  48. package/bin/lib/views.mjs +49 -58
  49. package/bin/lib/wikiRunner.mjs +2 -2
  50. package/bin/lib/work.mjs +2 -2
  51. package/bin/lib/workActivity.mjs +65 -0
  52. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  53. package/bin/lib/workAgentMerges.mjs +57 -52
  54. package/bin/lib/workAgentPlans.mjs +76 -27
  55. package/bin/lib/workAgentPrecheck.mjs +1 -1
  56. package/bin/lib/workAgentTurnExecution.mjs +55 -9
  57. package/bin/lib/workAgentTurnOutcome.mjs +12 -3
  58. package/bin/lib/workAgentTurnReports.mjs +17 -3
  59. package/bin/lib/workAgentTurnShape.mjs +8 -5
  60. package/bin/lib/workAgentTurns.mjs +8 -6
  61. package/bin/lib/workIntake.mjs +8 -2
  62. package/bin/lib/workMergeBatch.mjs +7 -1
  63. package/bin/lib/workPlanRecovery.mjs +50 -0
  64. package/bin/lib/workSessionTurns.mjs +7 -2
  65. 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
+ }
@@ -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
- 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. Return your work to it; it owns the card’s final commit and JSON.`;
126
- 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.' : ''}`,
127
- updatedInput: withChildContract(event, spawn, childContract) };
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, and a Claude child sent to the background. */
33
- export const withChildContract = (event, spawn, contract) => ({ ...spawn.input, ...(isBackgroundSpawnTool(event.tool_name) ? { run_in_background: true } : {}),
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
- export const ORCHESTRATOR_CONTRACT = `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
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
- Work yourself when the card spans one area. Only split work spanning several areas into native in-process subagents by kind (Backend/UI/Tests…). This is authorization to use those native agents on this card. Give each a concrete title, prompt and kind, and post short updates of your own. 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.
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 code-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 fenced/older Codex turns, the note remains for the next run; do not claim it was already read. No new model is involved.`;
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
+ }
@@ -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
- Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.`;
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 children
89
- * (ORCHESTRATOR_CONTRACT), which the build note then does not contradict.
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('\nDelegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.', orchestrated ? '' : '\nDelegate only when the person explicitly requests it.')} ` : '';
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
- return `TOOLS ON THIS CARD. You have ${row.has}. You have ${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.
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
  }
@@ -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
- * (runTurn's `emit`, before the daemon's coloured prefix is added). The one
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.