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
package/bin/lib/trace.mjs CHANGED
@@ -62,7 +62,8 @@
62
62
  import { THINK_MARKER, scrubNativeText } from './runtimeEvents.mjs';
63
63
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
64
64
  import { MAX_MCP_SERVERS, MCP_NAME_MAX, MCP_STATUSES } from './mcpReportBounds.mjs';
65
- import { SUBAGENT_CAP, SUBAGENT_FIELD_CAPS, SUBAGENT_STATUSES } from './subagentBounds.mjs';
65
+ import { WORKFLOW_AGENT_CAP, WORKFLOW_CAP, OUTSIDE_CAP, OUTSIDE_COMMAND_CAP, OUTSIDE_STATUSES, SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_CLIS, SUBAGENT_FIELD_CAPS,
66
+ SUBAGENT_KEY_MAX, SUBAGENT_RESULT_CAP, SUBAGENT_RUNS_CAP, SUBAGENT_STATUSES } from './subagentBounds.mjs';
66
67
 
67
68
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
68
69
  * the same reason: a turn emits hundreds of entries and nobody is reading them
@@ -111,7 +112,18 @@ function newRunId() {
111
112
  * with no such route, a body the server will never accept). Resolving falsy
112
113
  * keeps them queued for the next flush.
113
114
  */
114
- export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion }) {
115
+ /** One native window a brief or result is scrubbed over before its cap. */
116
+ const NATIVE_WINDOW = 65_536;
117
+ const EFFORT_RE = /^[a-z]{1,24}$/;
118
+ const RUN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
119
+
120
+ /**
121
+ * `cli` is the runtime whose children this turn records. `runsOf(key)` is
122
+ * the agent's subagent ledger (workAgentResume.mjs): the turns a child of
123
+ * that stable key appeared in, this one included; absent, a child's runs are
124
+ * unknown and carry no field.
125
+ */
126
+ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion, cli, runsOf }) {
115
127
  /** Entries not yet delivered. `base` is the ABSOLUTE index of queue[0] in
116
128
  * THIS RUN's stream, so `base + queue.length` is everything this relay has
117
129
  * ever accepted — sent, queued or shed. Absolute for the TURN is the
@@ -124,6 +136,19 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
124
136
  * by the bare-marker collapse in `prose()` and nothing else. */
125
137
  let lastEntry = null;
126
138
  const agentReadings = new Map();
139
+ /** Each child's own steps, sent or shed: its record's `steps`, against
140
+ * which the server says "N earlier not kept" past its own budget. */
141
+ const childSteps = new Map();
142
+ /** Children past the 32, counted and never recorded (bounded: a count). */
143
+ const unrecorded = new Set();
144
+ let notRecorded = 0;
145
+ /** The lead's own entries this run, sent or shed: where a child or an
146
+ * Outside row sits in the lead's thread (its `position`). */
147
+ let leadCount = 0;
148
+ /** The lead's shell-outs to another CLI ("Outside", D23), by call id. */
149
+ const outsideRows = new Map();
150
+ const workflowRows = new Map(), workflowPositions = new Map();
151
+ let extrasPending = false;
127
152
  const mcpSaid = new Set();
128
153
  let dirty = orchestrationVersion === 2;
129
154
  let markerPending = dirty;
@@ -145,6 +170,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
145
170
  const push = (entry) => {
146
171
  if (stopped || !entry) return;
147
172
  lastEntry = entry;
173
+ if (entry.agentId) childSteps.set(entry.agentId, (childSteps.get(entry.agentId) ?? 0) + 1);
174
+ else if (entry.k !== 'agent') leadCount += 1;
148
175
  queue.push(entry);
149
176
  while (queue.length > TRACE_BUFFER) {
150
177
  queue.shift();
@@ -156,7 +183,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
156
183
 
157
184
  const drain = async (deadlineMs) => {
158
185
  const until = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
159
- while (queue.length || markerPending) {
186
+ while (queue.length || markerPending || extrasPending) {
160
187
  if (until && Date.now() > until) {
161
188
  dirty = true;
162
189
  return;
@@ -164,10 +191,17 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
164
191
  const entries = queue.slice(0, TRACE_BATCH);
165
192
  const seq = base;
166
193
  dirty = false;
194
+ // The turn's counts and Outside rows ride beside the stream, whole: they
195
+ // take no seq, so an older server ignoring them sheds no step.
196
+ const extras = extrasPending;
197
+ extrasPending = false;
167
198
  let ok = false;
168
199
  try {
169
200
  ok = (await post({ agentId, turnId, run, seq, entries,
170
201
  ...(orchestrationVersion === 2 ? { orchestrationVersion } : {}),
202
+ ...(extras && notRecorded > 0 ? { notRecorded } : {}),
203
+ ...(extras && outsideRows.size ? { outside: [...outsideRows.values()] } : {}),
204
+ ...(extras && workflowRows.size ? { workflows: [...workflowRows.values()] } : {}),
171
205
  })) !== false;
172
206
  } catch {
173
207
  ok = false;
@@ -176,6 +210,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
176
210
  // Held, at the SAME seq: a retry the server has already seen is
177
211
  // trimmed against its high-water mark rather than doubled.
178
212
  dirty = true;
213
+ extrasPending ||= extras;
179
214
  return;
180
215
  }
181
216
  markerPending = false;
@@ -201,6 +236,30 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
201
236
  // The first report stamps even a silent or immediately failed CLI invocation.
202
237
  schedule();
203
238
  const scoped = (id) => id ? `${run}:${scrubNativeText(id, 96, scrub)}` : undefined;
239
+ /** A child's S4.1 detail, each field present only when its source said it. */
240
+ const cleanKey = v => scrubNativeText(v, SUBAGENT_KEY_MAX, scrub);
241
+ const detailOf = (update) => {
242
+ const out = {};
243
+ const childCli = update.cli ?? cli;
244
+ if (SUBAGENT_CLIS.includes(childCli)) out.cli = childCli;
245
+ if (typeof update.key === 'string' && update.key && update.key.length <= SUBAGENT_KEY_MAX) out.key = scrubNativeText(update.key, SUBAGENT_KEY_MAX, scrub);
246
+ if (update.parent === 'lead') out.parent = 'lead';
247
+ else if (typeof update.parent === 'string' && update.parent && update.parent !== update.id) out.parent = scoped(update.parent);
248
+ if (typeof update.model === 'string' && update.model) out.model = scrubNativeText(update.model, 80, scrub);
249
+ if (typeof update.effort === 'string' && EFFORT_RE.test(update.effort)) out.effort = update.effort;
250
+ // A subagent on another CLI stopped by its CLI's plan limit (E7 S7.5), cleared when it runs again.
251
+ if (typeof update.limited === 'boolean') out.limited = update.limited;
252
+ if (typeof update.parentKey === 'string') out.parentKey = cleanKey(update.parentKey);
253
+ if (typeof update.phase === 'string') out.phase = scrubNativeText(update.phase, 200, scrub);
254
+ for (const k of ['tokens', 'toolCalls']) if (Number.isSafeInteger(update[k]) && update[k] >= 0) out[k] = update[k];
255
+ for (const k of ['startedAt', 'endedAt']) if (typeof update[k] === 'string' && Number.isFinite(Date.parse(update[k]))) out[k] = update[k];
256
+ if (typeof update.result === 'string' && update.result.trim()) {
257
+ const full = scrubNativeText(update.result, NATIVE_WINDOW, scrub, NATIVE_WINDOW);
258
+ out.result = full.slice(0, SUBAGENT_RESULT_CAP);
259
+ out.resultStatus = update.resultStatus === 'truncated' || update.result.length > NATIVE_WINDOW || full.length > SUBAGENT_RESULT_CAP ? 'truncated' : 'complete';
260
+ }
261
+ return out;
262
+ };
204
263
  return {
205
264
  agentId: scoped,
206
265
  /**
@@ -283,11 +342,44 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
283
342
  }
284
343
  if (typeof fields.now === 'string') fields.now = fields.now.replace(/\s+/g, ' ').trim();
285
344
  if (SUBAGENT_STATUSES.includes(update.status)) fields.status = update.status;
345
+ Object.assign(fields, detailOf(update));
286
346
  const previous = agentReadings.get(update.id) ?? {};
287
- if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => previous[k] === v)) return;
288
- if (!agentReadings.has(update.id) && agentReadings.size >= SUBAGENT_CAP) return;
347
+ if (!agentReadings.has(update.id) && fields.kind !== 'workflow' && [...agentReadings.values()].filter(a => a.kind !== 'workflow').length >= SUBAGENT_CAP) {
348
+ // A 33rd child runs; its record does not (D25): the turn says how many.
349
+ if (!unrecorded.has(update.id)) {
350
+ notRecorded += 1;
351
+ if (unrecorded.size < 4_096) unrecorded.add(update.id);
352
+ extrasPending = true; dirty = true; schedule();
353
+ }
354
+ return;
355
+ }
356
+ if (!agentReadings.has(update.id)) {
357
+ // Where the lead stood when the child was first seen, and when.
358
+ fields.position = leadCount;
359
+ fields.startedAt ??= new Date().toISOString();
360
+ }
361
+ const key = fields.key ?? previous.key, title = fields.title ?? previous.title;
362
+ // The ledger learns a child's key (and the names a Codex follow-up may
363
+ // use for it) and answers the turns it appeared in, this one included.
364
+ if (key && (key !== previous.key || title !== previous.title) && typeof runsOf === 'function') {
365
+ let runs = null;
366
+ try { runs = runsOf(key, { title }); } catch { /* the ledger is a readout */ }
367
+ if (Array.isArray(runs)) fields.runs = runs.filter(r => typeof r === 'string' && RUN_ID_RE.test(r)).slice(-SUBAGENT_RUNS_CAP);
368
+ }
369
+ const status = fields.status ?? previous.status;
370
+ if (SETTLED_SUBAGENT_STATUSES.includes(status)) { if (!previous.endedAt) fields.endedAt ??= new Date().toISOString(); }
371
+ const same = (k, v) => k === 'endedAt' ? Boolean(previous.endedAt) : k === 'runs' ? JSON.stringify(previous.runs) === JSON.stringify(v) : previous[k] === v;
372
+ if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => same(k, v))) return;
289
373
  const reading = { ...previous, ...fields };
374
+ // A child running again (a follow-up) has not ended.
375
+ if (!SETTLED_SUBAGENT_STATUSES.includes(status)) delete reading.endedAt;
290
376
  agentReadings.set(update.id, reading);
377
+ if (reading.kind === 'workflow') {
378
+ const siblings = [...agentReadings].filter(([, a]) => a.kind === 'workflow' && a.parentKey === reading.parentKey)
379
+ .sort((a, b) => (b[1].startedAt ?? '').localeCompare(a[1].startedAt ?? ''));
380
+ for (const [id] of siblings.slice(WORKFLOW_AGENT_CAP)) agentReadings.delete(id);
381
+ if (!agentReadings.has(update.id)) return;
382
+ }
291
383
  // A later update repairs metadata whose first report was shed while
292
384
  // offline; a delta alone cannot recover the child's prompt or title.
293
385
  push({ k: 'agent', id: scoped(update.id), ...reading });
@@ -319,6 +411,38 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
319
411
  }
320
412
  if (fresh.length) push({ k: 'note', t: scrub(`The CLI reported MCP servers that are not connected: ${fresh.join(', ')}.`).slice(0, TRACE_PROSE_CAP) });
321
413
  },
414
+ /**
415
+ * ONE SHELL COMMAND THE LEAD RAN THAT STARTS ANOTHER CLI (D23): its row
416
+ * by call id, updated at its result. Shown and never blocked, never a
417
+ * subagent and never counted toward the 32. `row` is outsideCli.mjs's.
418
+ */
419
+ outside(id, row) {
420
+ if (stopped || typeof id !== 'string' || !id || !row) return;
421
+ const previous = outsideRows.get(id);
422
+ if (!previous && outsideRows.size >= OUTSIDE_CAP) return;
423
+ const next = { ...(previous ?? {
424
+ id: scrubNativeText(id, 96, scrub), position: leadCount, startedAt: new Date().toISOString(),
425
+ }) };
426
+ if (typeof row.cli === 'string') next.cli = row.cli;
427
+ if (typeof row.model === 'string' && row.model) next.model = scrubNativeText(row.model, 80, scrub);
428
+ if (typeof row.command === 'string') next.command = scrubNativeText(row.command, OUTSIDE_COMMAND_CAP, scrub);
429
+ if (OUTSIDE_STATUSES.includes(row.status)) next.status = row.status;
430
+ if (next.status && next.status !== 'running' && !next.endedAt) next.endedAt = new Date().toISOString();
431
+ if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
432
+ outsideRows.set(id, next);
433
+ extrasPending = true; dirty = true; schedule();
434
+ },
435
+ workflow(row) {
436
+ if (stopped || !row) return;
437
+ if (row.launcherId && workflowPositions.size < WORKFLOW_CAP && !workflowPositions.has(row.launcherId)) workflowPositions.set(row.launcherId, leadCount);
438
+ if (!row.id || (!workflowRows.has(row.id) && workflowRows.size >= WORKFLOW_CAP)) return;
439
+ const { launcherId, ...next } = row;
440
+ next.position = workflowRows.get(row.id)?.position ?? workflowPositions.get(launcherId) ?? leadCount;
441
+ for (const k of ['id', 'key', 'name', 'description']) if (typeof next[k] === 'string') next[k] = scrubNativeText(next[k], k === 'description' ? 600 : 200, scrub);
442
+ next.phases = (next.phases ?? []).slice(0, 64).map(p => ({ ...p, title: scrubNativeText(p.title, 200, scrub) }));
443
+ workflowRows.set(row.id, next);
444
+ extrasPending = true; dirty = true; schedule();
445
+ },
322
446
  flush,
323
447
  /** No more entries, no more timers. The queue survives so a final flush can
324
448
  * still deliver it. */
@@ -326,8 +450,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
326
450
  if (!stopped && orchestrationVersion === 2) {
327
451
  // Even a quiet child retains its identity after another child's noisy
328
452
  // stream overflowed the relay. These snapshots remain sequence-safe.
329
- for (const [id, reading] of agentReadings) push({ k: 'agent', id: scoped(id), ...reading });
453
+ for (const [id, reading] of agentReadings) {
454
+ const steps = childSteps.get(scoped(id));
455
+ push({ k: 'agent', id: scoped(id), ...reading, ...(steps ? { steps } : {}) });
456
+ }
330
457
  }
458
+ if (!stopped && (notRecorded > 0 || outsideRows.size || workflowRows.size)) extrasPending = true;
331
459
  stopped = true;
332
460
  if (timer) {
333
461
  clearTimeout(timer);
@@ -4,7 +4,7 @@
4
4
  * inside prose or another language's fence stay as the CLI wrote them. */
5
5
  import { jsonObjectClosers } from './modelJson.mjs';
6
6
 
7
- const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
7
+ const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'split', 'nothing']);
8
8
  function declaration(body) {
9
9
  const raw = body.trim();
10
10
  if (!raw.startsWith('{')) return false;
@@ -13,13 +13,13 @@ function declaration(body) {
13
13
  return value && typeof value === 'object' && !Array.isArray(value)
14
14
  && ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
15
15
  || (typeof value.status === 'string'
16
- && (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
16
+ && (statuses.has(value.status) || ['summary', 'progress', 'question', 'split'].some(key => Object.hasOwn(value, key)))));
17
17
  } catch {
18
18
  // A truncated/invalid declaration must not fall through as an answer.
19
19
  // Require a protocol key at the root's opening and the declared status;
20
20
  // arbitrary malformed JSON remains useful diagnostic output.
21
- return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
22
- && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
21
+ return /^\{\s*"(?:status|summary|progress|question|split)"\s*:/.test(raw)
22
+ && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|split|nothing)"/.test(raw);
23
23
  }
24
24
  }
25
25
 
@@ -16,6 +16,7 @@
16
16
  * listed. A malformed entry costs that entry alone.
17
17
  */
18
18
  export const GAP_STATUSES = ['not built', 'partly', 'unverified', 'unowned'];
19
+ export const GAP_DISPOSITIONS = ['required', 'owned', 'optional', 'decision'];
19
20
  export const MAX_GAPS = 40;
20
21
  export const MAX_GAP_ITEM = 300;
21
22
  export const MAX_GAP_WHY = 800;
@@ -32,7 +33,10 @@ export function parseGaps(raw, scrub) {
32
33
  const status = typeof g.status === 'string' ? g.status.trim().toLowerCase() : '';
33
34
  if (!GAP_STATUSES.includes(status)) continue;
34
35
  const why = typeof g.why === 'string' && g.why.trim() ? cut(scrub(g.why.trim()), MAX_GAP_WHY) : null;
35
- out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}) });
36
+ const disposition = GAP_DISPOSITIONS.includes(g.disposition) ? g.disposition : null;
37
+ const ownerTaskId = typeof g.ownerTaskId === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(g.ownerTaskId) ? g.ownerTaskId : null;
38
+ out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}),
39
+ ...(disposition ? { disposition } : {}), ...(ownerTaskId ? { ownerTaskId } : {}) });
36
40
  }
37
41
  return out;
38
42
  }
@@ -41,7 +45,7 @@ export function parseGaps(raw, scrub) {
41
45
  export function gapsMarkdown(gaps) {
42
46
  if (!Array.isArray(gaps)) return null;
43
47
  if (!gaps.length) return '**Gaps against the request:** none reported.';
44
- return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}`)].join('\n');
48
+ return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}${g.disposition ? ` (${g.disposition}${g.ownerTaskId ? `: card ${g.ownerTaskId}` : ''})` : ''}`)].join('\n');
45
49
  }
46
50
 
47
51
  const oneLine = (s) => s.replace(/\s*\n\s*/g, ' ');
@@ -17,6 +17,11 @@
17
17
  * bounds and settles its own records, so only an unowned turn is counted
18
18
  * here; and native transcript reads are readouts, so the drain at close is
19
19
  * bounded and never holds the card's answer.
20
+ *
21
+ * EVERY UPDATE NAMES ITS CLI AND, ONCE KNOWN, ITS KEY (2026-11-06, S4.1):
22
+ * the identity the CLI itself keeps for the child across turns — Claude's
23
+ * native agent id, Codex's child thread id — so a child resumed in a later
24
+ * turn is the same record there (workAgentResume.mjs keeps which turns).
20
25
  */
21
26
 
22
27
  import { handleStreamLine } from './claudeStream.mjs';
@@ -24,35 +29,44 @@ import { createClaudeSubagents } from './claudeSubagents.mjs';
24
29
  import { createClaudeSubagentProgress } from './claudeSubagentProgress.mjs';
25
30
  import { createCodexSubagentReader } from './codexSubagents.mjs';
26
31
  import { humanizeClaudeTool } from './runtimeEvents.mjs';
27
- import { SUBAGENT_CAP } from './subagentBounds.mjs';
32
+ import { SUBAGENT_CAP, SUBAGENT_KEY_MAX } from './subagentBounds.mjs';
33
+
34
+ /** The child's stable identity, `<cli>:<native id>`, or nothing yet. */
35
+ export function subagentKey(runtime, update) {
36
+ const native = runtime === 'codex' ? update.id : runtime === 'claude' ? update.nativeId : null;
37
+ const key = typeof native === 'string' && native ? `${runtime}:${native}` : null;
38
+ return key && key.length <= SUBAGENT_KEY_MAX ? key : null;
39
+ }
28
40
 
29
41
  /**
30
42
  * `sinks` are the turn's own: its console, activity, tool-event and text
31
43
  * sinks, which a Claude child's transcript rows feed as its stream does.
32
44
  */
33
- export function createTurnSubagents({ runtime, cwd, since, env, owned, onSubagent, onSpawnResult, resumeThreadId,
45
+ export function createTurnSubagents({ runtime, cwd, since, env, owned, onSubagent, onWorkflow, onSpawnResult, resumeThreadId, knownSubagents,
34
46
  sinks: { emit, onActivity, onToolEvent, appendText, onDetail } }) {
35
47
  // An orchestrator bounds its own records and settles them at close
36
48
  // (orchestrator.mjs). Counted here by stream id, refused retries used up
37
49
  // the 32 and later children's updates were dropped; the exit sweep
38
50
  // reported 'stopped' off stream state the controller had already settled.
39
51
  const agentStates = new Map();
40
- const reportSubagent = (update) => {
41
- if (!update?.id) return;
42
- if (!owned) {
43
- if (!agentStates.has(update.id) && agentStates.size >= SUBAGENT_CAP) return;
52
+ const reportSubagent = (raw) => {
53
+ if (!raw?.id) return;
54
+ const key = subagentKey(runtime, raw);
55
+ const update = { ...raw, cli: runtime, ...(key ? { key } : {}) };
56
+ // Past the cap a child is still handed on: the trace relay counts it as
57
+ // not recorded (its `notRecorded`), and the sweep does not follow it.
58
+ if (!owned && raw.kind !== 'workflow' && (agentStates.has(update.id) || agentStates.size < SUBAGENT_CAP))
44
59
  agentStates.set(update.id, update.status ?? agentStates.get(update.id) ?? 'running');
45
- }
46
60
  onSubagent?.(update);
47
61
  };
48
- const subagents = createClaudeSubagents(reportSubagent);
62
+ const subagents = createClaudeSubagents(reportSubagent, onWorkflow);
49
63
  const claudeProgress = runtime === 'claude' && onSubagent ? createClaudeSubagentProgress({
50
64
  cwd, since, env,
51
65
  onLine: line => handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText,
52
66
  answerFromResult: true, subagents, onDetail }),
53
67
  }) : null;
54
68
  const codexAgents = runtime === 'codex' && onSubagent ? createCodexSubagentReader({
55
- since, env,
69
+ since, env, knownTargets: knownSubagents ?? null,
56
70
  onSubagent: reportSubagent,
57
71
  onActivity: (activity) => {
58
72
  emit(activity.kind === 'error' ? activity.full ?? activity.label : activity.label + '\n', activity);
@@ -12,6 +12,7 @@ import { procStat } from './procStat.mjs';
12
12
  import { killGroup } from './processGroup.mjs';
13
13
  import { unattendedEnv } from './noWindowEnv.mjs';
14
14
  import { childEnv } from './childEnv.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
15
16
  import { gitRaw } from './git.mjs';
16
17
  import { serveMcpStdio } from './mcpStdio.mjs';
17
18
  import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
@@ -172,8 +173,10 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
172
173
  record.folder = `${ARTIFACT_DIR}/${name}`;
173
174
  record.artifact = `${record.folder}/report.json`;
174
175
  scratch = mkdtempSync(join(tmpdir(), 'flowviant-ui-preview-'));
175
- const env = unattendedEnv(childEnv({ cwd }), { browserHome: scratch });
176
- // No inherited personal home, SDK credentials or browser sessions.
176
+ const environment = readWorktreeEnvironment(wt);
177
+ const env = unattendedEnv({ ...childEnv({ cwd }), ...(environment ? environmentOverlay(environment) : {}) }, { browserHome: scratch });
178
+ // Only explicit scoped bindings join the minimal preview environment;
179
+ // no inherited personal home, SDK credentials or browser sessions.
177
180
  env.HOME = scratch; env.USERPROFILE = scratch; env.XDG_CONFIG_HOME = scratch;
178
181
  env.XDG_DATA_HOME = scratch; env.XDG_CACHE_HOME = scratch;
179
182
  let output = '';
@@ -8,6 +8,7 @@ import { ensureRealSubdir } from './ownedDirs.mjs';
8
8
  import { isSafePath, assertWikiDraftFits } from './vaultDiff.mjs';
9
9
 
10
10
  import { wikiScalars as scalars, protectedWikiPage } from './wikiOwnership.mjs';
11
+ import { SYSTEM_ARCHITECTURE_PATH, readSystemArchitecture } from './architectureReport.mjs';
11
12
 
12
13
  /** Never follow links into an imported vault. A partial read cannot be a draft
13
14
  * basis: fail before the CLI starts rather than infer missing pages as deleted. */
@@ -60,6 +61,14 @@ export function beginWikiDraft(vaultDir) {
60
61
  discard: () => rmSync(dir, { recursive: true, force: true }),
61
62
  publish({ sha, repoFullName }) {
62
63
  const candidate = pages(dir, '', new Map(), true);
64
+ const architecture = candidate.get(SYSTEM_ARCHITECTURE_PATH);
65
+ const oldArchitecture = before.get(SYSTEM_ARCHITECTURE_PATH);
66
+ if (!protectedWikiPage(SYSTEM_ARCHITECTURE_PATH, oldArchitecture ?? architecture ?? '')) {
67
+ const parsed = readSystemArchitecture(architecture ?? '');
68
+ if (parsed.status === 'invalid' || (oldArchitecture && readSystemArchitecture(oldArchitecture).status === 'available' && parsed.status !== 'available')) {
69
+ throw new Error('Architecture report is incomplete or invalid; last good wiki retained');
70
+ }
71
+ }
63
72
  const changes = new Map();
64
73
  for (const [path, text] of candidate) {
65
74
  const old = before.get(path);
package/bin/lib/work.mjs CHANGED
@@ -67,6 +67,8 @@ import { placeTurnLockedByLivePid } from './turnLock.mjs';
67
67
  import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
68
68
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
69
69
  import { trackedRepositorySetup } from './repositorySetup.mjs';
70
+ import { sweepWorktreeEnvironments } from './reviewEnvironment.mjs';
71
+ import { workLanesStopped } from './standDownGate.mjs';
70
72
  import { createRepositoryPlaces } from './repositoryPlaces.mjs';
71
73
  import { createAgentRepositories } from './agentRepositories.mjs';
72
74
  import { createWorkProcesses } from './workProcesses.mjs';
@@ -395,6 +397,7 @@ export function createWorkManager({
395
397
  // The check and the pre-review are built with only what each one uses; the
396
398
  // review entry beat is handed the two and owns only their order.
397
399
  const { runCheck } = createWorkAgentCheck({
400
+ baseRef,
398
401
  repoRoot,
399
402
  postBestEffort,
400
403
  workChildren,
@@ -461,6 +464,23 @@ export function createWorkManager({
461
464
  sweepMergedSessionBranch,
462
465
  });
463
466
 
467
+ let environmentSweep = null, lastEnvironmentSweep = 0;
468
+ const reconcileEnvironments = () => {
469
+ if (environmentSweep || workLanesStopped() || Date.now() - lastEnvironmentSweep < 60_000) return;
470
+ lastEnvironmentSweep = Date.now();
471
+ environmentSweep = (async () => {
472
+ const roots = [...new Set(repositories?.list().map(r => r.repoRoot).filter(Boolean) ?? [repoRoot])];
473
+ for (const root of roots) {
474
+ if (workLanesStopped()) break;
475
+ const result = await sweepWorktreeEnvironments(root, {
476
+ onSpawn: ch => { workChildren.set(ch, null); groupKillChildren.add(ch); },
477
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); },
478
+ });
479
+ for (const failure of result.errors) warn(envScrub(`Environment ${failure.id}: ${failure.error}`));
480
+ }
481
+ })().catch(error => warn(envScrub(error.message))).finally(() => { environmentSweep = null; });
482
+ };
483
+
464
484
  const { processKillJobs } = createWorkProcesses({
465
485
  placeDir,
466
486
  reportPlaceWorktrees,
@@ -498,7 +518,7 @@ export function createWorkManager({
498
518
  workChildren.size > 0 ||
499
519
  workAnswering.size > 0 ||
500
520
  pendingWorkReports.size > 0 ||
501
- pendingShipReports.size > 0 || cleanupBusy();
521
+ pendingShipReports.size > 0 || cleanupBusy() || environmentSweep !== null;
502
522
 
503
523
  return {
504
524
  flushWorkReports,
@@ -514,6 +534,7 @@ export function createWorkManager({
514
534
  retirePreviews,
515
535
  shutdownPreviews,
516
536
  retireWorkSessions,
537
+ reconcileEnvironments,
517
538
  reportWorktrees,
518
539
  reconcileIdleArtifacts,
519
540
  reconcileLocalCleanup,
@@ -21,6 +21,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
21
21
  import { agentBrowserHome, openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
23
  import { workLanesStopped } from './standDownGate.mjs';
24
+ import { setupRepository } from './repositorySetup.mjs';
25
+ import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
26
+ import { holdWorktreeEnvironment } from './reviewEnvironment.mjs';
24
27
 
25
28
  /**
26
29
  * CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
@@ -135,6 +138,7 @@ export function readConfiguredCheck(root) {
135
138
 
136
139
  export function createWorkAgentCheck({
137
140
  repoRoot,
141
+ baseRef = () => 'HEAD',
138
142
  postBestEffort,
139
143
  workChildren,
140
144
  groupKillChildren,
@@ -184,6 +188,24 @@ export function createWorkAgentCheck({
184
188
  await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
185
189
  return { status: 'none' };
186
190
  }
191
+ let environment = null, releaseEnvironment = () => {};
192
+ try {
193
+ const root = repository?.repoRoot ?? repoRoot;
194
+ const snapshot = readEnvironmentRecipe(root, repository?.baseRef ?? baseRef());
195
+ if (snapshot) {
196
+ // A combined merge has a different worktree/database from its input
197
+ // agents. Migrate its CURRENT integrated source before its checks.
198
+ environment = await setupRepository(root, wt, { snapshot,
199
+ onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren.add(ch); },
200
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); } });
201
+ releaseEnvironment = holdWorktreeEnvironment(environment);
202
+ }
203
+ } catch (error) {
204
+ const text = envScrub(error.message);
205
+ await postCheck({ agentId, status: 'failed', output: text, ...(headSha ? { headSha } : {}) });
206
+ return { status: 'failed', text };
207
+ }
208
+ try {
187
209
  const cleared = clearNonArtifacts(wt);
188
210
  if (cleared.length)
189
211
  warn(
@@ -241,7 +263,7 @@ export function createWorkAgentCheck({
241
263
  shell: true,
242
264
  detached: true,
243
265
  stdio: ['ignore', 'pipe', 'pipe'],
244
- env: checkEnv(process.env, browser.dir),
266
+ env: checkEnv(environment?.env ?? process.env, browser.dir),
245
267
  });
246
268
  /**
247
269
  * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
@@ -330,6 +352,7 @@ export function createWorkAgentCheck({
330
352
  ...(headSha ? { headSha } : {}),
331
353
  });
332
354
  return out;
355
+ } finally { releaseEnvironment(); }
333
356
  };
334
357
 
335
358
  return { runCheck };
@@ -25,6 +25,7 @@
25
25
  import { existsSync, writeFileSync } from 'node:fs';
26
26
  import { CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
27
27
  import { pinId, readPinnedId } from './resumeMarker.mjs';
28
+ import { subagentLedger } from './subagentLedger.mjs';
28
29
 
29
30
  /**
30
31
  * One agent turn's resume. `sessionMetaPath` is the lane's (a path in the
@@ -32,9 +33,11 @@ import { pinId, readPinnedId } from './resumeMarker.mjs';
32
33
  * `resumeThreadId`, the kept `codexHome`, and what the run tells it as the
33
34
  * CLI speaks: `sawSession` (Claude's init), `sawThread` (Codex's
34
35
  * `thread.started`), `forgetThread` (a lost resume retried fresh), `threadNow`
35
- * and, once the turn is over, `persist`.
36
+ * and, once the turn is over, `persist`. `subagents` is the agent's subagent
37
+ * ledger for this turn (subagentLedger.mjs): which turns each child appeared
38
+ * in, written with the markers.
36
39
  */
37
- export function agentTurnResume({ rt, wt, agentId, sessionMetaPath }) {
40
+ export function agentTurnResume({ rt, wt, agentId, sessionMetaPath, turnId }) {
38
41
  // ONE AGENT IS ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly
39
42
  // right here — the ambiguity that forced per-tab session pinning in the
40
43
  // Workbench (many tabs, one place) cannot arise. The marker is what
@@ -86,8 +89,13 @@ export function agentTurnResume({ rt, wt, agentId, sessionMetaPath }) {
86
89
  const codexAgentHome =
87
90
  rt === 'codex' ? sessionMetaPath(wt, 'flowviant-agent-codex-home', agentId) : null;
88
91
 
92
+ // Its children's turns, kept beside the markers (a child resumes only where
93
+ // its conversation does), per agent and per CLI: keys never cross CLIs.
94
+ const subagents = subagentLedger(sessionMetaPath(wt, `flowviant-agent-subagents-${rt}`, agentId), turnId);
95
+
89
96
  return {
90
97
  resume,
98
+ subagents,
91
99
  resumeThreadId: codexResumeId || claudeResumeId || undefined,
92
100
  codexHome: codexAgentHome,
93
101
  /** Claude's session id from its init, pinned at once (a Claude turn only). */
@@ -116,6 +124,7 @@ export function agentTurnResume({ rt, wt, agentId, sessionMetaPath }) {
116
124
  // id learned mid-turn is only true once the turn that learned it ends.
117
125
  // Best-effort — the next turn runs fresh.
118
126
  pinId(codexThreadMarker, CODEX_THREAD_RE, seenThreadId);
127
+ subagents.persist();
119
128
  },
120
129
  };
121
130
  }
@@ -28,6 +28,7 @@ import { machineMcpServers } from './claudeMachineMcp.mjs';
28
28
  import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
29
29
  import { VIDEO_SERVER } from './videoRender.mjs';
30
30
  import { BROWSER_SERVER } from './managedBrowser.mjs';
31
+ import { RUNTIME_REVIEW_SERVER } from './runtimeReview.mjs';
31
32
 
32
33
  /** The servers the daemon itself mounts on a card turn, by the names the
33
34
  * agent calls them: a multi-repository card's add_repository, an
@@ -35,7 +36,7 @@ import { BROWSER_SERVER } from './managedBrowser.mjs';
35
36
  * render_video (2026-10-26), and a code turn's managed browser (2026-10-30).
36
37
  * Never a project server's, whatever the base
37
38
  * `.mcp.json` names. */
38
- export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER });
39
+ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER, runtime: RUNTIME_REVIEW_SERVER });
39
40
 
40
41
  /**
41
42
  * The card turn's tools, prepared, and what it is told of them.
@@ -48,13 +49,13 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
48
49
  * from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
49
50
  * needs one, and the keys the runner takes the mount as.
50
51
  */
51
- export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
+ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, runtimeConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
53
  // A turn reads the base ref afresh. Changes the agent has made to its
53
54
  // own worktree cannot silently change the next turn's tools.
54
55
  const source = sources.find((r) => r.wt === wt);
55
- const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef());
56
+ const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef(), env);
56
57
  for (const other of sources.filter((r) => r !== source)) {
57
- const base = readBaseTools(other.repoRoot, other.baseRef);
58
+ const base = readBaseTools(other.repoRoot, other.baseRef, env);
58
59
  snapshot.instructions.push(...[...base.instructions, ...base.skillFiles].map((file) => ({ ...file, path: `repositories/${other.folder}/${file.path}` })));
59
60
  }
60
61
  const projectTools = prepareAgentTools(snapshot, rt, env, wt, { codexHome, fenced: posture !== 'build', turnServers: {
@@ -62,6 +63,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
62
63
  ...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
63
64
  ...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
64
65
  ...(posture === 'build' && browserConfig ? { [CARD_TURN_SERVERS.browser]: browserConfig } : {}),
66
+ ...(posture === 'build' && runtimeConfig ? { [CARD_TURN_SERVERS.runtime]: runtimeConfig } : {}),
65
67
  // A video card's render tool rides its fenced posture (videoRender.mjs).
66
68
  ...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
67
69
  } });