flowviant 0.125.29 → 0.125.31

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 (72) hide show
  1. package/README.md +364 -0
  2. package/bin/cli.mjs +23 -0
  3. package/bin/lib/agentPlan.mjs +8 -0
  4. package/bin/lib/agentQueueContext.mjs +6 -0
  5. package/bin/lib/agentSplit.mjs +90 -0
  6. package/bin/lib/architectureContract.mjs +57 -0
  7. package/bin/lib/architectureReport.mjs +38 -0
  8. package/bin/lib/artifactContracts.mjs +21 -2
  9. package/bin/lib/claudeInput.mjs +1 -1
  10. package/bin/lib/claudeSubagents.mjs +46 -6
  11. package/bin/lib/claudeWorkflows.mjs +94 -0
  12. package/bin/lib/cliLimit.mjs +5 -0
  13. package/bin/lib/codexInput.mjs +191 -0
  14. package/bin/lib/codexSubagentEvents.mjs +3 -1
  15. package/bin/lib/codexSubagents.mjs +34 -9
  16. package/bin/lib/conversationOutput.mjs +14 -0
  17. package/bin/lib/crossCliChild.mjs +327 -0
  18. package/bin/lib/crossCliLaunch.mjs +92 -0
  19. package/bin/lib/crossCliPosture.mjs +55 -0
  20. package/bin/lib/crossCliResearch.mjs +19 -0
  21. package/bin/lib/environmentProcess.mjs +44 -0
  22. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  23. package/bin/lib/fleet.mjs +2 -0
  24. package/bin/lib/linuxDesktopBridge.mjs +62 -0
  25. package/bin/lib/managedBrowser.mjs +50 -10
  26. package/bin/lib/mergeBatch.mjs +1 -1
  27. package/bin/lib/nativeAgentTools.mjs +12 -5
  28. package/bin/lib/noWindowEnv.mjs +4 -2
  29. package/bin/lib/orchestrator.mjs +24 -7
  30. package/bin/lib/orchestratorClient.mjs +8 -2
  31. package/bin/lib/orchestratorPrompt.mjs +13 -5
  32. package/bin/lib/orchestratorProtocol.mjs +18 -0
  33. package/bin/lib/outsideCli.mjs +124 -0
  34. package/bin/lib/projectToolRuntimes.mjs +4 -2
  35. package/bin/lib/projectTools.mjs +2 -2
  36. package/bin/lib/prompts.mjs +7 -5
  37. package/bin/lib/repositoryPlaces.mjs +1 -1
  38. package/bin/lib/repositorySetup.mjs +15 -24
  39. package/bin/lib/reviewBridge.mjs +110 -0
  40. package/bin/lib/reviewDatabase.mjs +112 -0
  41. package/bin/lib/reviewDesktop.mjs +153 -0
  42. package/bin/lib/reviewEnvironment.mjs +286 -0
  43. package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
  44. package/bin/lib/reviewEnvironmentMain.mjs +38 -0
  45. package/bin/lib/reviewImage.mjs +58 -0
  46. package/bin/lib/reviewMcpClient.mjs +69 -0
  47. package/bin/lib/reviewProcess.mjs +51 -0
  48. package/bin/lib/reviewTargets.mjs +50 -0
  49. package/bin/lib/robloxReview.mjs +234 -0
  50. package/bin/lib/robloxStudioBridge.mjs +68 -0
  51. package/bin/lib/runTurn.mjs +30 -8
  52. package/bin/lib/runtimeClaude.mjs +7 -3
  53. package/bin/lib/runtimeCodex.mjs +12 -6
  54. package/bin/lib/runtimeLimits.mjs +59 -1
  55. package/bin/lib/runtimeReview.mjs +185 -0
  56. package/bin/lib/splitGate.mjs +115 -0
  57. package/bin/lib/subagentBounds.mjs +26 -0
  58. package/bin/lib/subagentLedger.mjs +110 -0
  59. package/bin/lib/trace.mjs +134 -6
  60. package/bin/lib/turnDeclaration.mjs +4 -4
  61. package/bin/lib/turnGaps.mjs +6 -2
  62. package/bin/lib/turnSubagents.mjs +23 -9
  63. package/bin/lib/uiReview.mjs +5 -2
  64. package/bin/lib/wikiDraft.mjs +9 -0
  65. package/bin/lib/work.mjs +22 -1
  66. package/bin/lib/workAgentCheck.mjs +24 -1
  67. package/bin/lib/workAgentResume.mjs +11 -2
  68. package/bin/lib/workAgentTools.mjs +6 -4
  69. package/bin/lib/workAgentTurnExecution.mjs +77 -9
  70. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  71. package/bin/lib/workAgentTurnReports.mjs +5 -2
  72. package/package.json +1 -1
@@ -8,7 +8,9 @@
8
8
  * plan window is not Flowviant's capacity — it is the VENDOR measuring its own
9
9
  * plan, and this file only carries that measurement to Project settings ›
10
10
  * Machines, where it reads as "used", never "left" or "free". Nothing here
11
- * decides, gates, parks or schedules anything off these numbers.
11
+ * decides, gates or parks anything off these numbers; the one other relay is
12
+ * a hit limit's own reset, carried on the park (`limitResetAt`, 2026-11-06)
13
+ * so the server retries when the CLI said, not on a guess.
12
14
  *
13
15
  * TWO SOURCES, ONE SHAPE:
14
16
  *
@@ -135,6 +137,8 @@ export function recordClaudeRateLimit(info, now = Date.now()) {
135
137
  capWindows(state.windows);
136
138
  state.status = CLAUDE_STATUSES.has(info.status) ? info.status : 'other';
137
139
  state.at = at;
140
+ // The binding window's reset rides the event itself beside a rejection.
141
+ state.rejectedResetsAt = state.status === 'rejected' ? isoFromStamp(info.resetsAt) : null;
138
142
  }
139
143
 
140
144
  /**
@@ -370,6 +374,60 @@ export function runtimeLimitsReport({ detected, plan, now = Date.now() } = {}) {
370
374
  return Object.keys(out).length ? fitRuntimeLimits(out) : null;
371
375
  }
372
376
 
377
+ /** A park's reset is relayed only past now and within this (the longest plan
378
+ * window is a week); the server applies the same bound (`PARK_RESET_MAX_MS`). */
379
+ export const PARK_RESET_MAX_MS = 8 * 24 * 60 * 60_000;
380
+ /** A rejection older than this is not the one this turn hit. */
381
+ const LIMIT_READING_FRESH_MS = 30 * 60_000;
382
+
383
+ /** An ISO reset after `now` and within the bound, or null. */
384
+ export function boundedResetAt(iso, now = Date.now()) {
385
+ const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
386
+ return Number.isFinite(t) && t > now && t - now <= PARK_RESET_MAX_MS ? new Date(t).toISOString() : null;
387
+ }
388
+
389
+ /** The latest reset among windows the CLI measured as full (≥ 100% used):
390
+ * the account carries on only once every full window has reset. */
391
+ const fullWindowsReset = (windows) => {
392
+ let latest = null;
393
+ for (const w of windows?.values?.() ?? []) {
394
+ if (w.usedPct != null && w.usedPct >= 100 && w.resetsAt && (!latest || w.resetsAt > latest)) latest = w.resetsAt;
395
+ }
396
+ return latest;
397
+ };
398
+
399
+ /**
400
+ * WHEN THE LIMIT THIS TURN HIT RESETS, IN THE CLI'S OWN MEASURE (2026-11-06) —
401
+ * ISO, or null. Relayed on the park (`/fleet/agent-parked` `resetsAt`) so the
402
+ * server retries then instead of after its fixed cooldown.
403
+ *
404
+ * Only a REJECTION read fresh: Claude's `rate_limit_event` with status
405
+ * `rejected` (its own `resetsAt`, else a full window's), or a Codex rollout
406
+ * naming the limit reached — read now, off this turn's thread under the
407
+ * agent's CODEX_HOME (`env`), because the after-close read in runTurn.mjs is
408
+ * not awaited — with a full window's `resets_at`. Anything unmeasured, stale,
409
+ * past or past the bound is null, and the server's cooldown stands. Never
410
+ * throws.
411
+ */
412
+ export async function limitResetAt(runtime, { threadId, env, now = Date.now() } = {}) {
413
+ try {
414
+ const fresh = (st) => st?.status === 'rejected' && now - Date.parse(st.at) <= LIMIT_READING_FRESH_MS;
415
+ if (runtime === 'claude' || !runtime) {
416
+ if (!fresh(claudeState)) return null;
417
+ return boundedResetAt(claudeState.rejectedResetsAt ?? fullWindowsReset(claudeState.windows), now);
418
+ }
419
+ if (runtime === 'codex') {
420
+ // The agent's own home first, then the person's (its sessions link there).
421
+ if (threadId && !(await learnCodexLimits(threadId, { env, now })) && env) await learnCodexLimits(threadId, { now });
422
+ if (!fresh(codexState)) return null;
423
+ return boundedResetAt(fullWindowsReset(codexState.windows), now);
424
+ }
425
+ return null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
373
431
  /** Test-only: forget everything learned, the plan memo and the path cache. */
374
432
  export function resetRuntimeLimitsForTest() {
375
433
  claudeState = null;
@@ -0,0 +1,185 @@
1
+ /** One model-independent review tool over reusable execution/surface adapters. */
2
+ import { createServer } from 'node:net';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { writeFileSync } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
7
+ import { serveMcpStdio } from './mcpStdio.mjs';
8
+ import { readReviewTargets, reviewCwd, commandOnPath } from './reviewTargets.mjs';
9
+ import { startReviewProcess } from './reviewProcess.mjs';
10
+ import { desktopSupport } from './reviewDesktop.mjs';
11
+ import { openLinuxDesktop } from './linuxDesktopBridge.mjs';
12
+ import { openBridgePage } from './reviewBridge.mjs';
13
+ import { openRobloxStudio } from './robloxStudioBridge.mjs';
14
+ import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
16
+ import { childEnv } from './childEnv.mjs';
17
+ import { measureListeners } from './listeners.mjs';
18
+ import { resolveBrowser } from './shot.mjs';
19
+ import { reviewSourceState, sameReviewSource } from './uiReview.mjs';
20
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
21
+ import { ensureArtifactDir, ARTIFACT_DIR } from './artifacts.mjs';
22
+ import { learnExtraSecrets, forgetExtraSecrets, scrub } from './uplinkScrub.mjs';
23
+
24
+ export const RUNTIME_REVIEW_SERVER = 'card_runtime';
25
+ const TOOLS = [
26
+ { name: 'discover', description: 'List reviewed command, browser, desktop, Roblox Studio and engine review targets and measured prerequisites. Discovery starts no application.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
27
+ { name: 'run', description: 'Run a named reviewed target. Command checks return logs and source-bound evidence; interactive targets open the SAME Live app frame and human control handoff. Never treats a build or sync server as gameplay.', inputSchema: { type: 'object', properties: { target: { type: 'string' }, repository: { type: 'string' } }, required: ['target'], additionalProperties: false } },
28
+ { name: 'observe', description: 'Capture and retain the current shared runtime surface. Screenshot capture is an observation, not a passing test.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
29
+ { name: 'act', description: 'Interact with the current surface using bounded click, type, key, scroll or browser navigation verbs. Human control pauses model interactions.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'type', 'key', 'scroll', 'navigate', 'snapshot'] }, x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, path: { type: 'string' }, selector: { type: 'string' }, deltaY: { type: 'number' } }, required: ['type'], additionalProperties: false } },
30
+ { name: 'handoff', description: 'Hold this turn for human private sign-in or native app testing. Waits without model polling and resumes the same conversation.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', maxLength: 300 } }, required: ['kind', 'reason'], additionalProperties: false } },
31
+ { name: 'stop', description: 'Stop the runtime processes owned by this tool. A person’s Stop prevents reopening for the turn.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
32
+ ];
33
+ const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
34
+ const availablePort = () => new Promise((resolve, reject) => {
35
+ const server = createServer(); server.once('error', reject);
36
+ server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
37
+ });
38
+
39
+ export async function createRuntimeReview({ wt, repoRoot, baseRef, sources = [], browser, signal, onChild,
40
+ openDesktop = openLinuxDesktop, openBridge = openBridgePage, openRoblox = openRobloxStudio, desktopProbe = desktopSupport, browserProbe = resolveBrowser }) {
41
+ const catalog = (sources.length ? sources : [{ wt, repoRoot, baseRef }]).map(source => ({ ...readReviewTargets(source.repoRoot, source.baseRef),
42
+ wt: source.wt, name: source.name ?? basename(source.repoRoot), id: source.id }));
43
+ if (catalog.every(source => Object.keys(source.targets).length === 0)) return null;
44
+ const controller = new AbortController(), reports = [], commands = new Set();
45
+ let closed = false, busy = false, current = null, app = null;
46
+ const abort = () => { void close(); };
47
+ const environmentFor = source => readWorktreeEnvironment(source.wt)?.env ?? process.env;
48
+ function support(target, env) {
49
+ if (target.platforms && !target.platforms.includes(process.platform)) return { available: false, reason: `This target requires ${target.platforms.join(' or ')}; this machine is ${process.platform}.` };
50
+ if (target.adapter === 'browser') {
51
+ const found = browserProbe();
52
+ return browser && found && !found.viaWindows ? { available: true } : { available: false, reason: 'No native managed Chromium browser is available on this machine.' };
53
+ }
54
+ if (target.adapter === 'desktop') return browser ? desktopProbe(env) : { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
55
+ if (target.adapter === 'roblox') {
56
+ const found = browserProbe();
57
+ return browser && process.platform === 'linux' && commandOnPath('flock', env) && found && !found.viaWindows ? { available: true } : { available: false, reason: 'Studio viewport review needs a Linux/WSL daemon, flock and native headless Chromium. Studio/MCP runs on the Windows host.' };
58
+ }
59
+ if (target.adapter === 'bridge' && !browser) return { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
60
+ return { available: true };
61
+ }
62
+ const stopApp = async () => { const previous = app; app = null; await previous?.close(); };
63
+ async function close() {
64
+ if (closed) return;
65
+ closed = true; signal?.removeEventListener('abort', abort); controller.abort();
66
+ if (current) await browser?.close();
67
+ await Promise.all([stopApp(), ...[...commands].map(command => command.close())]);
68
+ held.server.close(); held.server.closeAllConnections(); forgetExtraSecrets(secretKey);
69
+ }
70
+ const record = (source, target, name, before, result) => {
71
+ const after = reviewSourceState(source.wt);
72
+ const report = { target: name, repository: source.name, adapter: target.adapter, ...result,
73
+ before, after, sourceStable: sameReviewSource(before, after) };
74
+ reports.push({ source, report });
75
+ if (ensureArtifactDir(wt)) {
76
+ const path = `runtime-review-${randomUUID()}.md`;
77
+ const body = `# Runtime review: ${source.name}/${name}\n\n${scrub(JSON.stringify(report, null, 2))}\n`;
78
+ writeFileSync(join(wt, ARTIFACT_DIR, path), body, { flag: 'wx', mode: 0o600 });
79
+ report.artifact = `${ARTIFACT_DIR}/${path}`;
80
+ }
81
+ return report;
82
+ };
83
+ const call = async (name, input = {}) => {
84
+ if (closed || controller.signal.aborted) throw new Error('This review turn has ended.');
85
+ if (name === 'discover') return text({ repositories: catalog.map(source => ({ repository: source.name, targets: Object.entries(source.targets).map(([target, config]) => ({ target, adapter: config.adapter,
86
+ ...support(config, environmentFor(source)), command: config.command })) })),
87
+ instruction: 'Use the repository’s existing workflows when no recipe is configured. A missing runtime leaves those checks unverified; code editing can continue. A Roblox/Rojo sync server is not gameplay.' });
88
+ if (busy) throw new Error('Another runtime review operation is finishing.');
89
+ busy = true;
90
+ try {
91
+ if (name === 'observe') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act({ type: 'snapshot' }); }
92
+ if (name === 'act') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act(input); }
93
+ if (name === 'handoff') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.handoff(input); }
94
+ if (name === 'stop') { browser?.stopSurface(); await stopApp(); current = null; return text({ stopped: true }); }
95
+ if (name !== 'run') throw new Error('Unknown runtime tool.');
96
+ const source = input.repository == null ? catalog.find(source => source.wt === wt) : catalog.find(source => source.name === input.repository || source.id === input.repository);
97
+ const target = source && Object.hasOwn(source.targets, input.target) ? source.targets[input.target] : null;
98
+ if (!target) throw new Error('That reviewed target does not exist. Call discover and use a listed repository/target.');
99
+ const cwd = reviewCwd(source.wt, target.cwd), env = environmentFor(source), measured = support(target, env);
100
+ if (!measured.available) throw new Error(measured.reason);
101
+ if (!commandOnPath(target.command[0], env) && !target.command[0].startsWith('./')) throw new Error(`Review command ${target.command[0]} is not installed on this machine.`);
102
+ const scoped = readWorktreeEnvironment(source.wt);
103
+ const interactiveEnv = { ...childEnv({ cwd }), ...Object.fromEntries((target.env ?? []).filter(key => env[key] != null).map(key => [key, env[key]])), ...(scoped ? environmentOverlay(scoped) : {}) };
104
+ const before = reviewSourceState(source.wt), options = { cwd, env: target.adapter === 'command' ? env : interactiveEnv, onChild, signal: controller.signal, timeoutMs: target.timeoutSeconds * 1000 };
105
+ if (target.adapter === 'command') {
106
+ const running = startReviewProcess(target.command, options);
107
+ commands.add(running);
108
+ let result;
109
+ try { result = await running.done; } finally { commands.delete(running); }
110
+ const report = record(source, target, input.target, before, { status: !result.error && result.code === 0 ? 'passed' : 'failed', exitCode: result.code, output: result.output, ...(result.error ? { error: result.error } : {}) });
111
+ return text({ ...report, output: result.output.slice(-4000), ...(result.output.length > 4000 ? { outputTruncated: true } : {}) });
112
+ }
113
+ browser.assertControl();
114
+ const key = `${source.wt}:${input.target}`;
115
+ if (target.adapter === 'browser') {
116
+ try {
117
+ if (current?.key !== key || !app?.running()) {
118
+ await stopApp();
119
+ const port = target.port || await availablePort();
120
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) throw new Error('That worktree port is already running. Use card_browser for the existing server, or choose a different review port.');
121
+ app = startReviewProcess(target.command.map(arg => arg.replaceAll('${PORT}', String(port))), options);
122
+ current = { key, port };
123
+ for (let n = 0; n < 100; n++) {
124
+ if (controller.signal.aborted) throw new Error('The review turn ended during startup.');
125
+ if (!app.running()) throw new Error(app.result()?.error ?? `The review app exited. ${app.output()}`);
126
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) break;
127
+ if (n === 99) throw new Error('No listener was measured for this review target.');
128
+ await new Promise(resolve => setTimeout(resolve, 100));
129
+ }
130
+ }
131
+ return await browser.open({ port: current.port, hostname: target.hostname, scheme: target.scheme, path: target.path ?? '/', fixture: target.fixture }, source.wt);
132
+ }
133
+ catch (error) { await stopApp(); current = null; throw error; }
134
+ }
135
+ const result = await browser.openSurface({ key, target: input.target, surface: target.adapter === 'desktop' ? 'desktop' : 'engine',
136
+ open: async surface => {
137
+ await stopApp(); current = { key };
138
+ if (target.adapter === 'desktop') return openDesktop({ ...options, ...surface, command: target.command, support: measured });
139
+ const command = reviewedProviderCommand({ root: source.root, commit: source.commit, recipe: { database: { command: target.command } } });
140
+ if (target.adapter === 'roblox') return openRoblox({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
141
+ return openBridge({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
142
+ } });
143
+ record(source, target, input.target, before, { status: 'observed', note: 'Interactive runtime opened. Captures and input do not establish passing gameplay tests.' });
144
+ return result;
145
+ } catch (error) { throw new Error(reviewDiagnostic(error.message)); }
146
+ finally { busy = false; }
147
+ };
148
+ const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_RUNTIME_REVIEW', verb: '__runtime-review-mcp', requestTimeoutMs: 615_000, isClosed: () => closed,
149
+ respond: async (req, res) => {
150
+ try {
151
+ const chunks = []; let size = 0;
152
+ for await (const bytes of req) { size += bytes.length; if (size > 16_000) throw new Error('Runtime request exceeds its byte bound.'); chunks.push(bytes); }
153
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
154
+ if (!TOOLS.some(tool => tool.name === input.name)) throw new Error('Unknown runtime tool.');
155
+ const result = await call(input.name, input.input);
156
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
157
+ } catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [{ type: 'text', text: reviewDiagnostic(error.message) }] })); }
158
+ } });
159
+ const secretKey = `runtime-review:${held.capability.slice(0, 12)}`;
160
+ learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_RUNTIME_REVIEW_CAPABILITY', value: held.capability }]);
161
+ signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) await close();
162
+ return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env, toolTimeoutSec: 650 }, close,
163
+ instructions: `Use card_runtime.discover to find reviewed command, browser, desktop or engine targets. Run a named target with card_runtime.run; never treat code compilation or a sync server as a visual/gameplay test. Desktop targets use a private virtual display, never the personal desktop. Native/engine screenshots use the SAME Live app frame and handoff controls as browser review. Human control pauses model observations/input; call handoff before asking for sign-in or manual testing and finish this invocation without polling. Missing capabilities are skipped checks, not passing tests or reasons to stop code work. Keep external services on scoped test bindings. Observe and retain actual captures before delivery. For a runner without MCP, this turn also provides the private shell helper argv ${JSON.stringify([process.execPath, ...heldToolArgv('__runtime-review')])}; pass a JSON request file {name,input}.`,
164
+ finalize() {
165
+ return reports.map(({ source, report }) => `Runtime review ${report.repository}/${report.target}: ${report.status}${report.status === 'observed' ? ' (not a passing gameplay test)' : ''}. ${report.sourceStable && sameReviewSource(report.after, reviewSourceState(source.wt)) ? 'Source matches final tracked/nonignored state.' : 'STALE: source changed or could not be measured; this does not verify final source.'}${report.artifact ? ` Evidence: ${report.artifact}.` : ''}`).join('\n');
166
+ },
167
+ };
168
+ }
169
+
170
+ async function requestRuntime(input) {
171
+ const response = await fetch(process.env.FLOWVIANT_RUNTIME_REVIEW_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_RUNTIME_REVIEW_CAPABILITY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(615_000) });
172
+ if (!response.ok) throw new Error('The held runtime turn has ended.');
173
+ return response.json();
174
+ }
175
+ export async function runtimeReviewMain(mcp = false) {
176
+ if (mcp) return serveMcpStdio({ serverInfo: { name: RUNTIME_REVIEW_SERVER, version: '1.0.0' }, tools: TOOLS, rawResult: true, call: (name, input) => requestRuntime({ name, input }) });
177
+ try {
178
+ const { readFileSync } = await import('node:fs');
179
+ const result = await requestRuntime(JSON.parse(readFileSync(process.argv[3], 'utf8')));
180
+ // Shell tools are text transports. Never flood a model context with image
181
+ // base64; observe retains a local artifact that its image tool can inspect.
182
+ const content = result.content?.map(block => block.type === 'image' ? { type: 'text', text: 'Image shown in Live app. Use observe to retain it, then open the reported artifact with an available image tool. Capture alone does not establish visual inspection.' } : block);
183
+ process.stdout.write(JSON.stringify({ ...result, ...(content ? { content } : {}) }) + '\n'); process.exitCode = result.isError ? 1 : 0;
184
+ } catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
185
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * THE PERSON'S YES BEFORE CHILDREN EDIT FILES (2026-11-06, D13/D28–D31, E5).
3
+ *
4
+ * A card turn's job carries `subagents` (`SubagentSplitJob`, shared
5
+ * subagentSplit.ts, `DAEMON_SUBAGENT_SPLIT_MIN`) from a server that asks:
6
+ * - `ask`: a child that edits files waits for an approved split. The gate
7
+ * refuses its spawn in words that tell the model to propose the split
8
+ * (agentSplit.mjs) and stop. A read-only lookup on the agent's own CLI
9
+ * starts without asking (D13).
10
+ * - `approved`: this turn is the person's "Use N subagents".
11
+ * - `always`: the agent's Always switch (D29). It only skips this question;
12
+ * Claude's own workflow/ultracode modes stay separate (D14).
13
+ * ABSENT IS THE OLD SERVER, and the old behaviour stands: nothing is held and
14
+ * nothing is proposed. A research card's children only read already (the
15
+ * gate fences them), so nothing there waits either.
16
+ *
17
+ * WHETHER A CHILD EDITS, per CLI, conservatively — unknown is editing:
18
+ * - Claude's Agent/Task: by `subagent_type`. Only the built-in read-only
19
+ * types (Explore, Plan: no Edit/Write/NotebookEdit tools) read; general-
20
+ * purpose, a project's own type, or none named, edit. The gate also refuses
21
+ * such a child's file-write tools (orchestrator.mjs), since a type is the
22
+ * model's word; its shell is not fenced.
23
+ * - Codex's spawn_agent: by a declared read-only sandbox on the spawn, which
24
+ * no measured Codex input carries today, so every Codex spawn waits.
25
+ * Pure: no record, slot or request is read here.
26
+ */
27
+ import { isBackgroundSpawnTool, isSpawnTool } from './nativeAgentTools.mjs';
28
+ import { CLAUDE_CHILD_MODELS, claudeFamily } from './orchestratorNative.mjs';
29
+ import { SPLIT_SOLO_LABEL } from './agentSplit.mjs';
30
+
31
+ export const SPLIT_MODES = Object.freeze(['ask', 'approved', 'always']);
32
+ /** Claude Code's built-in subagent types whose tools exclude file writes. */
33
+ export const CLAUDE_READ_ONLY_TYPES = Object.freeze(['Explore', 'Plan']);
34
+ const READ_ONLY_SANDBOX = /^read[-_ ]?only$/i;
35
+
36
+ /** The job's split field, or null when absent or unreadable (the old path). */
37
+ export function splitPolicy(job) {
38
+ const s = job?.subagents;
39
+ if (!s || typeof s !== 'object' || !SPLIT_MODES.includes(s.mode)) return null;
40
+ const str = v => typeof v === 'string' && v.trim() ? v.trim() : null;
41
+ return { mode: s.mode, runtime: str(s.runtime), model: str(s.model), effort: str(s.effort) };
42
+ }
43
+
44
+ /** Whether this spawn's child may edit files; true whenever it cannot tell. */
45
+ export function childEdits(event, input = event?.tool_input ?? {}) {
46
+ if (isBackgroundSpawnTool(event?.tool_name)) return !CLAUDE_READ_ONLY_TYPES.includes(input.subagent_type);
47
+ if (isSpawnTool(event?.tool_name)) return ![input.sandbox, input.sandbox_mode, input.sandbox_policy].some(v => typeof v === 'string' && READ_ONLY_SANDBOX.test(v));
48
+ return true;
49
+ }
50
+
51
+ export const splitRefusal = runtime => `This child may edit files, and the person has not approved a split, so it was not started. Propose the split instead: end your turn with the split JSON (status "split") naming each part, the paths it touches and whether it edits files, then stop and wait for the person's answer. ${runtime === 'codex' ? 'On Codex every spawn waits for that answer; do read-only lookups yourself.' : 'A read-only lookup starts without asking: spawn it with the Explore or Plan agent type.'} Or do this part yourself.`;
52
+
53
+ /** The deny words for a spawn the split holds, or null when it may start. */
54
+ export function splitHold(policy, event, spawn, { runtime, access }) {
55
+ if (!policy || policy.mode !== 'ask' || access === 'read') return null;
56
+ return childEdits(event, spawn?.input) ? splitRefusal(runtime) : null;
57
+ }
58
+
59
+ /** Whether the gate admitted this spawn as a read-only lookup (ask mode). */
60
+ export const readOnlyLookup = (policy, event, spawn, access) => Boolean(policy && policy.mode === 'ask' && access !== 'read' && !childEdits(event, spawn?.input));
61
+
62
+ /**
63
+ * The children's model and effort under an approved or Always split: the
64
+ * person's batch choice, where they named one, over the card's. On Claude the
65
+ * Agent tool takes only a family alias; a choice outside the known families
66
+ * is not honourable there, and the card's model stands (`unhonoured`).
67
+ */
68
+ export function splitBrain(policy, runtime, card = {}) {
69
+ if (!policy || policy.mode === 'ask') return { brain: {}, unhonoured: [] };
70
+ // A batch on another CLI (E7) chose that CLI's model: native children keep the card's.
71
+ if (policy.runtime && policy.runtime !== runtime) return { brain: {}, unhonoured: [] };
72
+ const brain = {}, unhonoured = [];
73
+ if (policy.model) {
74
+ if (runtime === 'claude' && !CLAUDE_CHILD_MODELS.includes(claudeFamily(policy.model))) unhonoured.push(`Claude's Agent tool names only ${CLAUDE_CHILD_MODELS.join(', ')}, so children run on ${card.model ?? 'the card’s model'}, not ${policy.model}`);
75
+ else brain.model = policy.model;
76
+ }
77
+ if (policy.effort) brain.effort = policy.effort;
78
+ return { brain, unhonoured };
79
+ }
80
+
81
+ const choiceWords = (policy, runtime, card) => {
82
+ const { brain, unhonoured } = splitBrain(policy, runtime, card);
83
+ const model = brain.model ?? card.model, effort = brain.effort ?? card.effort;
84
+ const at = `${model ?? 'the card’s model'}${effort ? ` at ${effort} effort` : ''}`;
85
+ const codex = runtime === 'codex' && brain.model ? ' On Codex a model outside this machine’s catalog cannot be named for a child; it then inherits the card’s.' : '';
86
+ return `Run every child on ${at}: the person chose it for the whole batch, so name no other model or effort for a child.${unhonoured.length ? ` ${unhonoured.join('. ')}.` : ''}${codex}`;
87
+ };
88
+
89
+ const CLI_LABEL = { claude: 'Claude Code', codex: 'Codex' };
90
+
91
+ /**
92
+ * A BATCH ON ANOTHER CLI (2026-11-06, D21/D30, E7): the person chose the other
93
+ * CLI for every child, so each part starts with card_orchestrator's
94
+ * start_cli_agent instead of a native spawn — when this machine has that CLI
95
+ * ready (`crossCli`, the CLIs the controller offers); otherwise the parts are
96
+ * the lead's own, and it says why.
97
+ */
98
+ const otherCliWords = (policy, crossCli) => {
99
+ const cli = policy.runtime, name = CLI_LABEL[cli] ?? cli;
100
+ if (!crossCli.includes(cli)) return `The person chose ${name} for the children, and ${name} is not ready on this machine, so no child can start on it: do the parts yourself, and say so in your update.`;
101
+ const at = [policy.model, policy.effort && `${policy.effort} effort`].filter(Boolean).join(' at ');
102
+ return `Run every child on ${name}${at ? `, ${at}` : ''}: the person chose it for the whole batch. Start each part with card_orchestrator.start_cli_agent (cli "${cli}", a title, its kind and the whole brief, naming no other model or effort), never with a native spawn, then read each with cli_agent_result until it is done, stopped or limited. A limited child hit ${name}'s own plan limit: its words say so; retry it later with followup_cli_agent or do its part yourself.`;
103
+ };
104
+
105
+ /** The words this run's split policy adds to the orchestrator's contract. */
106
+ export function splitContract(policy, { runtime = 'claude', model = null, effort = null, crossCli = [] } = {}) {
107
+ if (!policy) return '';
108
+ const card = { model, effort };
109
+ const batch = () => policy.runtime && policy.runtime !== runtime ? otherCliWords(policy, crossCli) : choiceWords(policy, runtime, card);
110
+ if (policy.mode === 'approved') return `THE PERSON APPROVED YOUR SPLIT for this turn: start the parts you proposed ${policy.runtime && policy.runtime !== runtime ? 'now' : 'as native subagents now, as above'}. ${batch()}`;
111
+ if (policy.mode === 'always') return `SPLITS ARE PRE-APPROVED ON THIS AGENT (its Always use subagents switch): split independent parts as above without proposing first. ${batch()} This switch starts no workflow or other mode of the CLI; it only skips the person's question.`;
112
+ return `SUBAGENTS THAT EDIT FILES NEED THE PERSON'S YES. For children that edit files this replaces "this contract is the authorization" above. When parts of the card's work can run side by side and would edit files, do not spawn them: write any code they share first, as above, then propose the split and stop. End your turn with this JSON as your final answer, in place of delivered or blocked:
113
+ {"status":"split","split":{"reason":"one or two sentences on why these parts can run side by side","parts":[{"title":"Port gizmo shaders","touches":["src/gizmo.frag","src/gizmo.ts"],"edits":true},{"title":"Audit textures","touches":["assets/textures/**"],"edits":false}]},"progress":"…"}
114
+ Name every part you would give a child (at most 12), the paths or areas it touches (at most 8), and whether it edits files or only reads. The person answers "Use N subagents" — your next turn then says the split is approved and which model and effort the children run on — or "${SPLIT_SOLO_LABEL}", which means: do every part yourself with no child that edits files, and do not propose this split again. ${runtime === 'codex' ? 'On Codex the gate cannot tell a reading child from an editing one, so every spawn waits for that answer: do lookups yourself.' : 'A read-only lookup needs no approval: spawn it with the Explore or Plan agent type; the gate holds every other spawn.'} Children already running keep going: write the proposal as your final answer once they finish. A split is not a question for blocked JSON.`;
115
+ }
@@ -13,8 +13,34 @@
13
13
  * shared `SUBAGENT_STATUSES`. */
14
14
 
15
15
  export const SUBAGENT_CAP = 32;
16
+ export const WORKFLOW_AGENT_CAP = 200;
17
+ export const WORKFLOW_CAP = 32;
16
18
  /** In the order the trace relay sends them. */
17
19
  export const SUBAGENT_FIELD_CAPS = Object.freeze({ kind: 80, title: 200, now: 400, brief: 24_000 });
18
20
  export const SUBAGENT_STATUSES = Object.freeze(['running', 'waiting', 'asked', 'stopped', 'done']);
19
21
  /** A child that has finished or been stopped: the controller revives neither. */
20
22
  export const SETTLED_SUBAGENT_STATUSES = Object.freeze(['done', 'stopped']);
23
+
24
+ /** WHAT EACH CHILD'S RECORD CARRIES BEYOND ITS DESCRIPTOR (2026-11-06, S4.1),
25
+ * and its own step budget. Before this a child was id/kind/title/brief/now
26
+ * and a status, its steps shared the turn's 400 with the lead and every
27
+ * sibling, and a 33rd child vanished without a count. The server keeps the
28
+ * same numbers (`agentTraceAgents.ts`, check-app-parity rule 38):
29
+ * · `SUBAGENT_STEP_CAP` — the newest steps each child keeps of its own,
30
+ * past which its oldest are said as "N earlier not kept";
31
+ * · `SUBAGENT_RESULT_CAP` — its final message, as its CLI handed it back;
32
+ * · `SUBAGENT_RUNS_CAP` — the turns one resumed child is remembered in;
33
+ * · `SUBAGENT_KEY_MAX` — its stable identity across turns;
34
+ * · `SUBAGENT_CLIS` — the CLIs whose children are recorded (a CLI outside
35
+ * the list is unknown and the field is absent).
36
+ * `OUTSIDE_*` bounds the lead's shell-outs to another CLI ("Outside" rows,
37
+ * D23): shown, never blocked, never counted toward the 32. */
38
+ export const SUBAGENT_STEP_CAP = 120;
39
+ export const SUBAGENT_RESULT_CAP = 8_000;
40
+ export const SUBAGENT_RUNS_CAP = 16;
41
+ export const SUBAGENT_KEY_MAX = 160;
42
+ export const SUBAGENT_CLIS = Object.freeze(['claude', 'codex']);
43
+ export const OUTSIDE_CAP = 32;
44
+ export const OUTSIDE_COMMAND_CAP = 600;
45
+ export const OUTSIDE_CLIS = Object.freeze(['claude', 'codex', 'gemini']);
46
+ export const OUTSIDE_STATUSES = Object.freeze(['running', 'done', 'failed']);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * WHICH TURNS EACH OF AN AGENT'S SUBAGENTS APPEARED IN (2026-11-06, S4.1,
3
+ * D26). A child resumed in a later turn — Claude's `Agent({ resume })` of
4
+ * its native agent id, a Codex follow-up to its child thread — is the same
5
+ * child, and the chat shows it once ("continued in run 3"). Each turn's
6
+ * trace keeps its own bounded record; this ledger is how that record knows
7
+ * the earlier turns, by the stable `key` turnSubagents.mjs gives it.
8
+ *
9
+ * Kept beside the agent's resume markers, in the worktree's private git dir
10
+ * (workAgentResume.mjs): a child can only be resumed where its conversation
11
+ * is, so this is exactly as durable as the resume it describes. Codex
12
+ * children also keep the task paths they answered to, so a follow-up by
13
+ * path reaches its thread in a later turn (codexSubagents.mjs
14
+ * `knownTargets`).
15
+ *
16
+ * BEST EFFORT BOTH WAYS, like the markers: unreadable is unknown (runs
17
+ * carry no field), unwritable costs the next turn its history.
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { CLAUDE_SESSION_RE, CODEX_THREAD_RE, CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
21
+ import { SUBAGENT_KEY_MAX, SUBAGENT_RUNS_CAP } from './subagentBounds.mjs';
22
+
23
+ /** Children remembered per agent; the oldest go first. */
24
+ export const LEDGER_KEYS = 128;
25
+ const LEDGER_BYTES = 256 * 1024;
26
+ const NAMES = 4;
27
+ const TURN_RE = /^[A-Za-z0-9_-]{1,64}$/;
28
+
29
+ function readLedger(path) {
30
+ if (!path || !existsSync(path)) return new Map();
31
+ try {
32
+ const raw = readFileSync(path, 'utf8');
33
+ if (raw.length > LEDGER_BYTES) return null;
34
+ const v = JSON.parse(raw);
35
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
36
+ const out = new Map();
37
+ for (const [key, entry] of Object.entries(v).slice(-LEDGER_KEYS)) {
38
+ if (key.length > SUBAGENT_KEY_MAX || !entry || typeof entry !== 'object') continue;
39
+ const r = Array.isArray(entry.r) ? entry.r.filter(t => typeof t === 'string' && TURN_RE.test(t)).slice(-SUBAGENT_RUNS_CAP) : [];
40
+ const n = Array.isArray(entry.n) ? entry.n.filter(s => typeof s === 'string' && s.length <= 160).slice(-NAMES) : [];
41
+ const c = entry.c;
42
+ const valid = c && ['codex', 'claude'].includes(c.cli) &&
43
+ (c.cli === 'codex' ? CODEX_THREAD_RE : CLAUDE_SESSION_RE).test(c.native ?? '') &&
44
+ key === `${c.cli}:${c.native}` && typeof c.wt === 'string' && c.wt.startsWith('/') && c.wt.length <= 4096;
45
+ out.set(key, { r, n, ...(valid ? { c } : {}) });
46
+ }
47
+ return out;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** One turn's view of the agent's ledger at `path` (null: no git dir). */
54
+ export function subagentLedger(path, turnId) {
55
+ const ledger = path ? readLedger(path) : null;
56
+ let dirty = false;
57
+ return {
58
+ /** Null when the ledger is unknown: the trace then sends no runs. */
59
+ runsOf: ledger && TURN_RE.test(turnId ?? '') ? (key, { title } = {}) => {
60
+ if (typeof key !== 'string' || !key || key.length > SUBAGENT_KEY_MAX) return null;
61
+ const entry = ledger.get(key) ?? { r: [], n: [] };
62
+ if (!entry.r.includes(turnId)) { entry.r = [...entry.r, turnId].slice(-SUBAGENT_RUNS_CAP); dirty = true; }
63
+ if (typeof title === 'string' && title && title.length <= 160 && !entry.n.includes(title)) { entry.n = [...entry.n, title].slice(-NAMES); dirty = true; }
64
+ // Most recently seen last, so the bound sheds the longest unheard.
65
+ ledger.delete(key); ledger.set(key, entry);
66
+ while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
67
+ return [...entry.r];
68
+ } : null,
69
+ /** A Codex child's earlier names → its thread, for this turn's reader. */
70
+ codexTargets() {
71
+ const out = new Map();
72
+ for (const [key, { n }] of ledger ?? []) {
73
+ const thread = key.startsWith('codex:') ? key.slice(6) : '';
74
+ if (!CODEX_CHILD_THREAD_RE.test(thread)) continue;
75
+ for (const name of n) out.set(name, thread);
76
+ }
77
+ return out;
78
+ },
79
+ /** Cross-CLI identity is measured at init, never reconstructed from a title. */
80
+ rememberCli(key, child) {
81
+ if (!ledger || !key || key.length > SUBAGENT_KEY_MAX) return;
82
+ const entry = ledger.get(key) ?? { r: [], n: [] };
83
+ entry.c = { ...child, id: entry.c?.id ?? child.id };
84
+ ledger.delete(key); ledger.set(key, entry);
85
+ while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
86
+ dirty = true;
87
+ this.persist();
88
+ },
89
+ cliChild(id, wt) {
90
+ for (const [key, entry] of ledger ?? []) {
91
+ if (entry.c?.wt === wt && (key === id || entry.c.id === id)) return { ...entry.c, key };
92
+ }
93
+ return null;
94
+ },
95
+ persist() {
96
+ if (!dirty || !path || !ledger) return;
97
+ try {
98
+ let data = JSON.stringify(Object.fromEntries(ledger));
99
+ while (Buffer.byteLength(data) > LEDGER_BYTES && ledger.size) {
100
+ ledger.delete(ledger.keys().next().value);
101
+ data = JSON.stringify(Object.fromEntries(ledger));
102
+ }
103
+ writeFileSync(path, data);
104
+ dirty = false;
105
+ } catch {
106
+ /* the next turn's children carry no history */
107
+ }
108
+ },
109
+ };
110
+ }