flowviant 0.125.21 → 0.125.22

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.
@@ -70,6 +70,24 @@ export function stashCard(path, taskId, spec) {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * WHAT THE AGENT SAID IT DID NOT DELIVER on a card (2026-10-25, turnGaps.mjs),
75
+ * kept beside the spec so the pre-review can read the request, the diff and
76
+ * the agent's own account of what is missing together. Same append rule; the
77
+ * reader keeps the last declaration per card.
78
+ */
79
+ export function stashGaps(path, taskId, gaps) {
80
+ if (!path || !Array.isArray(gaps)) return false;
81
+ const id = String(taskId ?? '').trim();
82
+ if (!id) return false;
83
+ try {
84
+ appendFileSync(path, JSON.stringify({ taskId: id.slice(0, 64), gaps }) + '\n');
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
73
91
  /**
74
92
  * What this box holds, newest spec per card, in the order the cards were worked.
75
93
  *
@@ -89,6 +107,7 @@ export function readStash(path) {
89
107
  * order stays the order the cards were first handed out — which is the order
90
108
  * the branch was built in, and the order a reviewer reads them in. */
91
109
  const byId = new Map();
110
+ const gapsById = new Map();
92
111
  for (const line of raw.split('\n')) {
93
112
  if (!line.trim()) continue;
94
113
  let v;
@@ -99,10 +118,14 @@ export function readStash(path) {
99
118
  }
100
119
  if (!v || typeof v !== 'object') continue;
101
120
  const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
121
+ if (taskId && Array.isArray(v.gaps)) {
122
+ gapsById.set(taskId, v.gaps);
123
+ continue;
124
+ }
102
125
  const prompt = typeof v.prompt === 'string' ? v.prompt : '';
103
126
  if (!taskId || !prompt.trim()) continue;
104
127
  byId.set(taskId, prompt);
105
128
  }
106
- const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
129
+ const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt, ...(gapsById.has(taskId) ? { gaps: gapsById.get(taskId) } : {}) }));
107
130
  return all;
108
131
  }
@@ -35,6 +35,7 @@ import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
35
35
  import { blockedAnswer } from './agentAsk.mjs';
36
36
  import { isResearchUse } from './researchUse.mjs';
37
37
  import { candidateObjects } from './modelJson.mjs';
38
+ import { parseGaps } from './turnGaps.mjs';
38
39
 
39
40
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
40
41
  * the server caps these again at its own boundary. */
@@ -272,6 +273,8 @@ export function parseTurnResult(text, scrub = envScrub) {
272
273
  ...(typeof v.automation === 'string' && v.automation.trim()
273
274
  ? { automation: scrub(v.automation.trim()) } : {}),
274
275
  ...(parseTestFindings(v.testFindings, scrub) ? { testFindings: parseTestFindings(v.testFindings, scrub) } : {}),
276
+ // What it says it did not deliver (turnGaps.mjs): absent is unknown.
277
+ ...(parseGaps(v.gaps, scrub) ? { gaps: parseGaps(v.gaps, scrub) } : {}),
275
278
  ...progressOf(v, scrub),
276
279
  raised: Array.isArray(v.raised)
277
280
  ? v.raised
@@ -14,6 +14,9 @@ an order alone is not a declared dependency. requirements.status=missing is
14
14
  unavailable, never an empty brief. Discussions carry their existing omission
15
15
  counts and may be unavailable; do not treat a shortened discussion as full
16
16
  requirements. The brief and criteria marked full are supplied without clipping.
17
+ sourceChats are the person's exact words in the New task chat these cards were
18
+ planned in, up to each card's landing; a brief is the planner's paraphrase of
19
+ them, and links, references and file lists there are part of the request.
17
20
  Human messages are exact recorded words across this agent, with the preceding
18
21
  question when present. taskId on a message is where it was recorded, not an
19
22
  implicit scope restriction. target.kind=agent explicitly addresses the whole
@@ -32,7 +35,9 @@ relying on an excluded decision; do not recover it from older native memory.
32
35
  Latest outcomes and progress are reported summaries, not complete specifications,
33
36
  proof that checks passed, human acceptance or evidence that bytes exist locally.
34
37
  Stored outputs name exact ids/turns/hashes, not a successful local read. Retained
35
- result selections preserve their exact scope. Use the active turn's measured
38
+ result selections preserve their exact scope. localInputs lists the retained
39
+ paths that exist in this worktree this turn; any other retained path named in
40
+ the snapshot does not. Use the active turn's measured
36
41
  input manifest and prerequisite receipts before relying on files; never replace
37
42
  or regenerate missing selected inputs or assume queue knowledge discovers them.
38
43
  Only execute the active turn's assigned work. Upcoming cards are context, not
@@ -44,6 +49,20 @@ only relevant prior findings/decisions/input identities, not the entire queue.
44
49
  ${fence('AGENT QUEUE SNAPSHOT', context)}`;
45
50
  }
46
51
 
52
+ /** THE CARD'S SOURCE CHAT, as words the pre-review reads beside its spec
53
+ * (2026-10-25): the stash is per card and the snapshot is gone by review
54
+ * entry, so the person's request is kept with the spec it shaped. Empty when
55
+ * the card landed from no chat (or the server sent no source chats). */
56
+ export function sourceChatText(context, taskId) {
57
+ const chats = (context?.sourceChats?.chats ?? []).filter((c) => Array.isArray(c.taskIds) && c.taskIds.includes(taskId));
58
+ if (!chats.length) return '';
59
+ const said = chats.flatMap((c) => [
60
+ ...(c.messages ?? []).map((m) => `${m.answering ? `(answering: ${m.answering})\n` : ''}${m.body}`),
61
+ ...(c.omitted ? [`(${c.omitted} more message${c.omitted === 1 ? '' : 's'} omitted)`] : []),
62
+ ]);
63
+ return `\n\nTHE PERSON'S OWN WORDS (the New task chat this card was planned in; the brief above paraphrases them):\n${said.join('\n\n')}`;
64
+ }
65
+
47
66
  /** The gate supplies active identity/fences, not upcoming work to a child.
48
67
  * Relevant findings still travel with the root's focused assignment. */
49
68
  export function agentChildScope(job) {
@@ -82,6 +82,49 @@ is missing, so you lead:
82
82
  outside decision — stop and name exactly that dependency; a skip never
83
83
  supplies it.`;
84
84
 
85
+ /**
86
+ * THE WHOLE REQUEST, NOT ONLY THE BRIEF (2026-10-25) — every agent contract
87
+ * closes on it, before HELP_THEM_DECIDE.
88
+ *
89
+ * Eighteen merged runs were audited and most delivered part of what was asked
90
+ * while every check passed: a Codex planner could not open a Claude Design
91
+ * file and wrote "use only the described controls as scope" into the brief;
92
+ * the Claude agent, which could open it, never tried; an agent recast the
93
+ * design states between its cards as "decisions"; another satisfied "measure
94
+ * the waits" with a timer it wrote. The brief is a paraphrase, the person's
95
+ * words now ride the queue snapshot (`sourceChats`), a reference the agent
96
+ * can open is the agent's to open, and the delivery says what is missing
97
+ * (`gaps`, turnGaps.mjs) instead of leaving the person to find it.
98
+ */
99
+ export const WHOLE_REQUEST = `THE WHOLE REQUEST, NOT ONLY THE BRIEF:
100
+ - A brief is a planner's paraphrase. When the queue snapshot carries
101
+ "sourceChats", those are the person's exact words from the chat this card
102
+ was planned in: read them before you start. Links, design references, file
103
+ lists and pasted text there are part of the request.
104
+ - A REFERENCE YOU CAN OPEN, YOU OPEN. Read every design or reference that the
105
+ brief, the source chat, the card's discussion or the person's messages link
106
+ to before you build against it. A claude.ai/design link is read with a
107
+ DesignSync tool where your CLI offers one: load it (ToolSearch
108
+ "select:DesignSync"), then get_file the named file and its imports,
109
+ read-only, never its write methods. A brief that says a reference "could not be opened", "is
110
+ unavailable" or "use only X as scope" describes the planner's access, not
111
+ yours: try it yourself. If you still cannot read it, name it in "gaps" and
112
+ build nothing that depends on guessing what it contains.
113
+ - COVER THE REQUEST, THEN SAY WHAT IS MISSING. Before you deliver, walk the
114
+ request: the source chat's words, every frame, state and rule a reference
115
+ you read shows or states, the brief and each "done when". Add "gaps" to the
116
+ delivered JSON object, one entry per item this card was meant to deliver
117
+ that you did not build ("not built"), built in part ("partly") or did not
118
+ check against the real thing ("unverified"), and per part of the request
119
+ that NO card in this run owns ("unowned"), so the person can file it:
120
+ "gaps": [{ "item": "what is missing", "status": "not built | partly | unverified | unowned", "why": "one sentence" }]
121
+ "gaps": [] says you walked the request and nothing is missing. Never recast
122
+ a gap as a decision: a choice you made in place of the request is a gap
123
+ ("partly") with its reason.
124
+ - A "reproduce", "measure" or "verify" step is done only against the real
125
+ thing. A mock, timer or fixture you wrote yourself does not satisfy it:
126
+ list it as "unverified".`;
127
+
85
128
  /**
86
129
  * Rule 3, rule 4 and the final JSON a file-handing contract ends with.
87
130
  * `ask` is rule 4's body after its number (the decision only a person can
@@ -119,6 +162,8 @@ or, if you are stopping to ask:
119
162
  "progress" is REQUIRED on both shapes and REPLACES the previous one whole —
120
163
  past tense, plain sentences, no card ids.
121
164
 
165
+ ${WHOLE_REQUEST}
166
+
122
167
  ${HELP_THEM_DECIDE}`;
123
168
 
124
169
  /**
@@ -11,6 +11,7 @@ import { CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
11
11
  import { followJsonl } from './jsonlTail.mjs';
12
12
  import { codexCollabUpdates, codexSpawnResult, statusOf } from './codexSubagentEvents.mjs';
13
13
  import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
14
+ import { isSealedMessage } from './orchestratorNative.mjs';
14
15
  import { SUBAGENT_CAP } from './subagentBounds.mjs';
15
16
 
16
17
  const CODEX = NATIVE_AGENT_TOOLS.codex;
@@ -84,7 +85,7 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
84
85
  if (!COLLAB_CALLS.includes(name)) return;
85
86
  let args;
86
87
  try { args = JSON.parse(p.arguments); } catch { return; }
87
- calls.set(p.call_id, { name, args });
88
+ calls.set(p.call_id, { name, args, namespace: p.namespace });
88
89
  if (calls.size > 128) calls.delete(calls.keys().next().value);
89
90
  const id = targetId(args.target ?? args.id);
90
91
  if (id && name !== 'spawn_agent') {
@@ -122,7 +123,10 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
122
123
  const title = args.task_name ?? args.description ?? output.agent_nickname ?? '';
123
124
  if (args.task_name) rememberTarget(args.task_name, id);
124
125
  if (typeof path === 'string') rememberTarget(path, id);
125
- report({ id, kind: args.agent_type ?? '', title, brief: args.message ?? '', status: 'running', now: '' });
126
+ // A V2 message is ciphertext (orchestratorNative.mjs `isSealedMessage`):
127
+ // its words are unknown, so the child carries no brief rather than that.
128
+ const sealed = isSealedMessage(call.namespace ? `${call.namespace}.${call.name}` : call.name, args.message);
129
+ report({ id, kind: args.agent_type ?? '', title, ...(sealed ? {} : { brief: args.message ?? '' }), status: 'running', now: '' });
126
130
  return;
127
131
  }
128
132
  if (owner !== root && ev.type === 'response_item' && p.type === 'custom_tool_call') {
@@ -21,17 +21,22 @@ export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLE
21
21
  // A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
22
22
  // the same credential, the same shape, the same caps.
23
23
  const libraryBase = fleetEndpoint('library', fleetUrl);
24
- return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
24
+ // THE CAP IS THE FILE'S (2026-10-25): the sync holds a kept model under
25
+ // models/ or designs/ to 32 MiB (`knowledgeMaxFor`) and hands that cap here;
26
+ // this default alone turned every 20–32 MiB GLB into the over-cap sentinel,
27
+ // a failed hash on every poll, so a map the Library kept never reached an
28
+ // agent.
29
+ return async (id, { library = false, preview = false, fileIndex = null, maxBytes: cap = maxBytes } = {}) => {
25
30
  const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
26
31
  const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
27
32
  headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
28
33
  signal: AbortSignal.timeout(60_000),
29
34
  });
30
35
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
31
- if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
36
+ if (Number(res.headers.get('content-length') ?? '0') > cap) {
32
37
  // Returned rather than thrown: over the cap is a REFUSAL, permanent for
33
38
  // this rev, not a failure the backoff should keep retrying.
34
- return Buffer.alloc(maxBytes + 1);
39
+ return Buffer.alloc(cap + 1);
35
40
  }
36
41
  return Buffer.from(await res.arrayBuffer());
37
42
  };
@@ -217,7 +217,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
217
217
  sha256: f.sha256,
218
218
  bytes: f.bytes,
219
219
  maxBytes: knowledgeMaxFor(f.name, maxBytes),
220
- fetch: () => fetchFile(f.id),
220
+ fetch: () => fetchFile(f.id, { maxBytes: knowledgeMaxFor(f.name, maxBytes) }),
221
221
  });
222
222
  // Stale or failed clears `ok` and is retried (a stale file is better than
223
223
  // a missing one until the retry lands); a refusal is listed as one.
@@ -299,7 +299,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
299
299
  sha256: it.sha256,
300
300
  bytes: it.bytes,
301
301
  maxBytes: itemMax,
302
- fetch: () => fetchFile(it.id, { library: true }),
302
+ fetch: () => fetchFile(it.id, { library: true, maxBytes: itemMax }),
303
303
  beforeWrite: () => ensureLibraryParents(dir, it.path),
304
304
  });
305
305
  if (recordSyncState(result, entryState, it.path) === 'refused') {
@@ -317,7 +317,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
317
317
  sha256: it.preview.sha256,
318
318
  maxBytes: 2 * 1024 * 1024,
319
319
  oversizeFails: true,
320
- fetch: () => fetchFile(it.id, { library: true, preview: true }),
320
+ fetch: () => fetchFile(it.id, { library: true, preview: true, maxBytes: 2 * 1024 * 1024 }),
321
321
  beforeWrite: () => ensureLibraryParents(dir, it.previewPath),
322
322
  });
323
323
  recordSyncState(result, previewState, it.previewPath);
@@ -335,7 +335,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
335
335
  sha256: String(f.sha256),
336
336
  bytes: f.bytes,
337
337
  maxBytes: knowledgeMaxFor(f.path, maxBytes),
338
- fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index }),
338
+ fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index, maxBytes: knowledgeMaxFor(f.path, maxBytes) }),
339
339
  beforeWrite: () => ensureLibraryParents(dir, f.path),
340
340
  });
341
341
  if (recordSyncState(result, fileState, f.path) === 'refused') wanted[sub].delete(rel);
@@ -58,7 +58,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
58
58
  // wakes never evicts a note input() accepted.
59
59
  const inbox = new Map(), wakes = new Map();
60
60
  const pending = new Map(), steps = new Map();
61
- let closed = false, polling = false, rootSession = null, offerAt = 0;
61
+ // Whether this run admitted a sealed (Codex V2) spawn or follow-up, whose
62
+ // child reads its contract at SubagentStart or its first tool instead.
63
+ let closed = false, polling = false, rootSession = null, offerAt = 0, sealedRun = false;
62
64
  const identity = readOnlyRun ? { ...readOnlyRun, instance: DAEMON_INSTANCE } : { scope: 'agent', turnId, instance: DAEMON_INSTANCE };
63
65
  const ended = () => closed || signal?.aborted;
64
66
  const resolve = id => aliases.get(id) ?? id;
@@ -123,6 +125,19 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
123
125
  return held.find(a => a.brief === brief) ?? held.find(a => title && a.title === title) ?? held.find(a => kind && a.kind === kind)
124
126
  ?? held[0] ?? open.find(a => a.brief === brief) ?? open.find(a => title && a.title === title);
125
127
  };
128
+ // A child's contract, naming its record by its id (the words ride its
129
+ // prompt) or, with no id, a sealed Codex V2 child's record by its task
130
+ // name: the plain `Task name: /root/<name>` header Codex puts on its task
131
+ // message, which resolves through the spawn's task_name alias (`named`).
132
+ // The start hook cannot say which spawn a child came from; its name can.
133
+ const childContract = (id = null) => {
134
+ const ref = id ?? 'your task name: the "Task name:" line of your task message, such as /root/backend',
135
+ arg = id ? `agentId=${id}` : 'agentId set to that task name';
136
+ return (readOnlyRun
137
+ ? `\n\nRead-only investigation agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} before work (tool discovery is allowed). Share this checkout and its repository/knowledge read boundary. Write no file, edit nothing, commit nothing, start no work and call no board or staging tool. Return findings, source paths and uncertainties in your final message to the coordinator. Ask questions only through card_orchestrator.agent_question with ${arg}, and read its answer before proceeding. ${readOnlyRun.scope === 'capture' ? 'Call no flowviant tool, including streaming. Only the coordinator dedupes and authors staged outcomes and asks the person through the existing single-question flowviant-ask fence.' : 'Only the coordinator reconciles the complete workload and returns the proposal.'}`
138
+ : `\n\nCard orchestrator agent record id: ${ref}. FIRST call card_orchestrator.register_agent with ${arg} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with ${arg}; 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.`)
139
+ + (!readOnlyRun ? assignmentContext() : '') + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : '');
140
+ };
126
141
  // Admitted: the offer is spent, and notes that paused this child are being
127
142
  // delivered, so its checkpoint lets it work until their route lands.
128
143
  const start = (a, now) => {
@@ -146,7 +161,8 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
146
161
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
147
162
  if (calls.size > 128) calls.delete(calls.keys().next().value);
148
163
  if (!records.has(id) && records.size >= SUBAGENT_CAP) return { deny: `This run already has ${SUBAGENT_CAP} agent records. Continue the work yourself.` };
149
- const a = emit({ id, brief, title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
164
+ // A sealed message's words are unknown: no brief, never its ciphertext.
165
+ const a = emit({ id, ...(spawn.sealed ? {} : { brief }), title: spawn.title ?? '', kind: spawn.kind ?? '', status: 'waiting', now: WAIT });
150
166
  if (!slot(id)) { a.denied = resumed ? 'resume' : 'spawn'; return { deny: `${WAIT}. Agent id: ${id}. Keep working yourself; you will be told when this spawn can be retried. Do not wait for a child that has not started.` }; }
151
167
  // Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
152
168
  for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
@@ -155,11 +171,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
155
171
  // stream, and the parent is asked to say why in its own update.
156
172
  const choice = childChoice(event, spawn, card);
157
173
  if (choice) trace.prose('note', `Runs on ${choice}.`, id);
158
- const childContract = readOnlyRun
159
- ? `\n\nRead-only investigation agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before work (tool discovery is allowed). Share this checkout and its repository/knowledge read boundary. Write no file, edit nothing, commit nothing, start no work and call no board or staging tool. Return findings, source paths and uncertainties in your final message to the coordinator. Ask questions only through card_orchestrator.agent_question with agentId=${id}, and read its answer before proceeding. ${readOnlyRun.scope === 'capture' ? 'Call no flowviant tool, including streaming. Only the coordinator dedupes and authors staged outcomes and asks the person through the existing single-question flowviant-ask fence.' : 'Only the coordinator reconciles the complete workload and returns the proposal.'}`
160
- : `\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.`;
174
+ if (spawn.sealed) sealedRun = true;
161
175
  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.` : ''}`,
162
- updatedInput: withChildContract(event, spawn, childContract + (!readOnlyRun ? assignmentContext() : '') + (access === 'read' && !readOnlyRun ? READ_ONLY_CHILD : ''), chosen.brain) };
176
+ updatedInput: withChildContract(event, spawn, spawn.sealed ? '' : childContract(id), chosen.brain) };
163
177
  }
164
178
  // Native readings never revive a settled record or start a waiting spawn
165
179
  // or follow-up (Codex records a refused call's start anyway); only an
@@ -205,10 +219,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
205
219
  steps.delete(native);
206
220
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
207
221
  };
208
- // Codex V2 names a child `backend` or `/root/backend` interchangeably.
222
+ // Codex V2 names a child `backend` or `/root/backend` interchangeably, and
223
+ // a sealed child registers by the canonical path its task message wears.
209
224
  const named = native => {
225
+ if (typeof native !== 'string') return resolve(native);
210
226
  if (aliases.has(native) || records.has(native)) return resolve(native);
211
- const hits = new Set([...aliases].filter(([k]) => k.endsWith(`/${native}`) || native.endsWith(`/${k}`)).map(([, v]) => v));
227
+ const near = k => k.endsWith(`/${native}`) || native.endsWith(`/${k}`);
228
+ const hits = new Set([...[...aliases].filter(([k]) => near(k)).map(([, v]) => v), ...[...records.keys()].filter(near)]);
212
229
  return hits.size === 1 ? [...hits][0] : native;
213
230
  };
214
231
  function postSpawn(event) {
@@ -219,7 +236,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
219
236
  if (failed) { release(id); emit({ id, status: 'stopped' }); }
220
237
  }
221
238
  function preResume(event) {
222
- const input = event.tool_input ?? {}, { claude, native } = followupTarget(event);
239
+ const input = event.tool_input ?? {}, { claude, native, sealed } = followupTarget(event);
223
240
  if (native === null) return claude ? null : { deny: 'Name the native agent to resume.' };
224
241
  const id = named(native);
225
242
  if (!records.has(id)) {
@@ -227,7 +244,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
227
244
  // SendMessage also reaches "main" and teammates: only a known child is a follow-up.
228
245
  if (claude) return null;
229
246
  if (records.size >= SUBAGENT_CAP) return { deny: 'Continue the work in this run’s orchestrator.' };
230
- emit({ id, kind: '', title: native, brief: typeof input.message === 'string' ? input.message : '', status: 'waiting', now: WAIT, resumable: true });
247
+ emit({ id, kind: '', title: native, ...(sealed ? {} : { brief: typeof input.message === 'string' ? input.message : '' }), status: 'waiting', now: WAIT, resumable: true });
231
248
  }
232
249
  if (spawnable(records.get(id))) return { deny: `Agent ${id} has not started. Retry its native spawn instead.` };
233
250
  // A refused follow-up retried on its offered slot reaches a finished child.
@@ -236,6 +253,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
236
253
  start(records.get(id), 'Continuing with the orchestrator’s note');
237
254
  records.get(id).queued = running;
238
255
  calls.set(event.tool_use_id, id);
256
+ // A sealed follow-up rides as written; its child registers by task name
257
+ // if this run has not bound it yet (the refusal at its first tool says how).
258
+ if (sealed) { sealedRun = true; return {}; }
239
259
  return typeof input.message === 'string' ? { updatedInput: { ...input,
240
260
  message: input.message + `\n\nCurrent card orchestrator agent record id: ${id}. Before continuing work, call card_orchestrator.register_agent with this id. Return questions to the orchestrator.` } } : {};
241
261
  }
@@ -259,6 +279,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
259
279
  return { deny: 'Your orchestrator has a note for this checkpoint. Stop editing and wait for its follow-up; keep your work so far.' };
260
280
  }
261
281
  function hook(ev) {
282
+ // A sealed child's contract, as it starts (measured on codex-cli 0.163
283
+ // and 0.164: this additionalContext reached the child, and the event
284
+ // names the child's agent_id but not the spawn that started it).
285
+ if (ev.hook_event_name === 'SubagentStart') return sealedRun && childOf(ev) ? { context: childContract().trimStart() } : {};
262
286
  // A child's next tool round carries a follow-up queued to it; after its
263
287
  // stop, its own tool use is the CLI running it again with that follow-up.
264
288
  const kid = records.get(resolve(childOf(ev)));
@@ -275,12 +299,14 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
275
299
  if (String(ev.tool_name).includes(ORCHESTRATOR_SERVER) && String(ev.tool_name).endsWith('register_agent')) {
276
300
  // Binding the root would pause the orchestrator at its children's checkpoints.
277
301
  if (!child) return { deny: 'Only a native child registers an agent record. As the orchestrator, use the record id with describe_agent, route_message or answer_agent.' };
278
- const id = resolve(ev.tool_input?.agentId);
279
- if (!records.has(id)) return { deny: 'Use the agent record id in your prompt.' };
302
+ const id = named(ev.tool_input?.agentId);
303
+ if (!records.has(id)) return { deny: sealedRun ? `That names no agent in this run.${childContract()}` : 'Use the agent record id in your prompt.' };
280
304
  bind(child, id);
281
305
  }
306
+ // A sealed child that missed (or never got) its start's words reads
307
+ // them here, whole: an earlier run's follow-up, or a CLI without the hook.
282
308
  if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
283
- return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
309
+ return { deny: sealedRun ? `Register with your orchestrator before working.${childContract()}` : 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
284
310
  if ((child && access === 'read' || readOnlyRun) && isChildWriteTool(ev.tool_name)) return { deny: readOnlyRun ? 'This attempt only reads: no file writes.' : READ_ONLY_REFUSAL };
285
311
  if (rootTools && captureToolName(ev.tool_name)) {
286
312
  if (child || !rootSession || ev.session_id !== rootSession) return { deny: 'Only the top-level capture coordinator may call capture tools. Return findings to it.' };
@@ -314,7 +340,7 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
314
340
  return held.length ? { ...result, context: [result.context, ...held].filter(Boolean).join('\n\n') } : result;
315
341
  }
316
342
  if (action === 'route_message') return route(args);
317
- const id = resolve(args.agentId);
343
+ const id = named(args.agentId);
318
344
  // A person’s answer starts a new CLI turn. The parent carries the earlier
319
345
  // question and native identity in its own conversation; relay that known
320
346
  // context instead of inventing a new child or requiring another question.
@@ -24,9 +24,22 @@ export function orchestratorHooks() {
24
24
  return { PreToolUse: [{ matcher: '.*', hooks }], PostToolUse: [{ matcher: spawns, hooks }], PostToolUseFailure: [{ matcher: spawns, hooks }], SubagentStop: [{ hooks }] };
25
25
  }
26
26
 
27
- export function codexOrchestratorArgs() {
27
+ /**
28
+ * CODEX'S SubagentStart HANDS A SEALED CHILD ITS CONTRACT (2026-10-24). A V2
29
+ * spawn's message is ciphertext the gate must not edit (orchestratorNative.mjs
30
+ * `isSealedMessage`), so the words reach the child as this hook's
31
+ * additionalContext. Measured on real codex-cli 0.163.0 and 0.164.0 with this
32
+ * exact `matcher=".*"` row: the hook ran once per child and the child obeyed
33
+ * its context. Both CLIs start normally with the key (and with an unknown
34
+ * event's, offline). An older CLI is unmeasured, so it goes without; its
35
+ * children meet the same words when the gate refuses their first tool.
36
+ */
37
+ export const CODEX_SUBAGENT_START_MIN = '0.163.0';
38
+ export function codexOrchestratorArgs(codexVersion) {
28
39
  // A Codex `-c` value is TOML, quoted by the one quoter (codexToml.mjs).
29
40
  const row = `{matcher=".*",hooks=[{type="command",command=${toml(command())},timeout=${ORCHESTRATOR_HOOK_TIMEOUT_S}}]}`;
41
+ const start = versionAtLeast(String(codexVersion ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENT_START_MIN);
30
42
  return ['--dangerously-bypass-hook-trust', '-c', 'features.hooks=true',
31
- '-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`];
43
+ '-c', `hooks.PreToolUse=[${row}]`, '-c', `hooks.PostToolUse=[${row}]`, '-c', `hooks.SubagentStop=[${row}]`,
44
+ ...(start ? ['-c', `hooks.SubagentStart=[${row}]`] : [])];
32
45
  }
@@ -12,8 +12,27 @@
12
12
  */
13
13
  import { isBackgroundSpawnTool, isClaudeFollowupTool, isSpawnTool } from './nativeAgentTools.mjs';
14
14
 
15
+ /**
16
+ * A CODEX V2 MESSAGE IS CIPHERTEXT (2026-10-24). Since codex-cli 0.138
17
+ * (openai/codex PR #26210) the model writes spawn_agent's and followup_task's
18
+ * `message` encrypted, and the child's first request hands it back to be
19
+ * decrypted. The gate appended its contract to it, so every V2 child of the
20
+ * card runs from 10-19 to 10-24 (133 of them) died on that request with
21
+ * `invalid_encrypted_content` before its first tool. Measured on real 0.164:
22
+ * no hook and an identity rewrite both ran the child; one appended line failed
23
+ * it. Sealed, a message is relayed untouched and never read as a brief.
24
+ * V2 is the `collaboration` namespace (all 209 real messages, every one a
25
+ * `gAAAAA…` token); the token's shape is checked too, so a sealed message under
26
+ * another spelling is never edited. Codex V1 and Claude write plain text.
27
+ */
28
+ const SEALED_TOKEN = /^gAAAAA[A-Za-z0-9_-]{40,}={0,2}$/;
29
+ export const isSealedMessage = (toolName, message) => typeof message === 'string' &&
30
+ !isBackgroundSpawnTool(toolName) && !isClaudeFollowupTool(toolName) &&
31
+ (/^collaboration[./]?(?:spawn_agent|followup_task)$/.test(String(toolName ?? '')) || SEALED_TOKEN.test(message));
32
+
15
33
  /** A spawn's prompt and the fields a waiting record is matched and named by,
16
- * or null when the gate cannot hold it (no call id or no text prompt). */
34
+ * or null when the gate cannot hold it (no call id or no text prompt). A
35
+ * sealed message (`isSealedMessage`) gives no brief: its words are unknown. */
17
36
  export function readSpawn(event) {
18
37
  // A child stays in the card's worktree on this machine, inside the pool.
19
38
  const { isolation, cwd, ...input } = event.tool_input ?? {}, callId = event.tool_use_id;
@@ -21,18 +40,20 @@ export function readSpawn(event) {
21
40
  const items = Array.isArray(input.items) ? input.items : null;
22
41
  const key = ['prompt', 'message'].find(k => typeof input[k] === 'string') ?? (items ? 'items' : null);
23
42
  if (!callId || !key) return null;
24
- const brief = key === 'items' ? items.filter(i => i?.type === 'text' && typeof i.text === 'string').map(i => i.text).join('\n') : input[key];
25
- return { callId, input, items, key, brief, relocated: isolation !== undefined || cwd !== undefined,
43
+ const sealed = key === 'message' && isSealedMessage(event.tool_name, input.message);
44
+ const brief = sealed ? null : key === 'items' ? items.filter(i => i?.type === 'text' && typeof i.text === 'string').map(i => i.text).join('\n') : input[key];
45
+ return { callId, input, items, key, brief, sealed, relocated: isolation !== undefined || cwd !== undefined,
26
46
  resume: typeof input.resume === 'string' ? input.resume : null,
27
47
  title: input.description ?? input.task_name, kind: input.subagent_type ?? input.agent_type,
28
48
  names: [input.name, input.task_name] };
29
49
  }
30
50
 
31
51
  /** The spawn's input as the gate rewrites it: the contract appended to its
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. */
52
+ * prompt (never to a sealed message, whose child is handed it at its start),
53
+ * a Claude child sent to the background, and the child's model and effort
54
+ * (`brain`, from `childBrain`) where the spawn named none. */
34
55
  export const withChildContract = (event, spawn, contract, brain = {}) => ({ ...spawn.input, ...brain, ...(isBackgroundSpawnTool(event.tool_name) ? { run_in_background: true } : {}),
35
- ...(spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
56
+ ...(spawn.sealed ? {} : spawn.key === 'items' ? { items: [...spawn.items, { type: 'text', text: contract.trimStart() }] } : { [spawn.key]: spawn.input[spawn.key] + contract }) });
36
57
 
37
58
  /** Both CLIs' effort ladder, lowest first (Codex stops at xhigh). */
38
59
  export const EFFORT_LADDER = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -178,11 +199,11 @@ export function spawnReceipt(event) {
178
199
  return { ids, failed: Boolean(output?.is_error || output?.isError || event.error) };
179
200
  }
180
201
 
181
- /** Which child a follow-up addresses (null when it names none in bounds), and
182
- * whether it is Claude's SendMessage. */
202
+ /** Which child a follow-up addresses (null when it names none in bounds),
203
+ * whether it is Claude's SendMessage, and whether its message is sealed. */
183
204
  export function followupTarget(event) {
184
205
  const input = event.tool_input ?? {}, claude = isClaudeFollowupTool(event.tool_name);
185
206
  // Measured Claude 2.1.302 PreToolUse SendMessage input: {to, message, recipient, content, …}.
186
207
  const native = claude ? input.to ?? input.recipient ?? input.target ?? input.id : input.target ?? input.id;
187
- return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null };
208
+ return { claude, native: typeof native === 'string' && native.length <= 160 ? native : null, sealed: isSealedMessage(event.tool_name, input.message) };
188
209
  }
@@ -24,7 +24,7 @@ import { AGENT_PREREQUISITE_CONTRACT } from './agentPrerequisiteContract.mjs';
24
24
  import { agentTaskKindOf } from './agentTaskKinds.mjs';
25
25
  // A card's type (0.106.0) likewise: the table holds the words, this file prints them.
26
26
  import { AGENT_TASK_TYPES, agentTaskTypeFor, agentTaskTypeOf } from './agentTaskTypes.mjs';
27
- import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL } from './artifactContracts.mjs';
27
+ import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_DECK, SYSTEM_AGENT_IMAGE, SYSTEM_AGENT_MODEL, WHOLE_REQUEST } from './artifactContracts.mjs';
28
28
  import { isLibraryReference } from './knowledgeLibrary.mjs';
29
29
  import { readCardThread } from './cardThread.mjs';
30
30
  import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
@@ -581,9 +581,19 @@ with the matching name your CLI exposes.
581
581
  ask: each brief names the original ask, the part this card delivers and
582
582
  what its sibling cards own, and carries the context its agent needs — the
583
583
  design link or kept reference, the expected look and behaviour of its
584
- area, the files you found. If you could not open or read a reference, say
585
- so in each brief and in \`assumed\`, and cut by what the person described
586
- and the current product shows; never invent what it contains. For example,
584
+ area, the files you found. Copy the person's links, design references and
585
+ file lists into every brief they shape, word for word. If you could not
586
+ open or read a reference, say so in each brief and in \`assumed\`, and cut
587
+ by what the person described and the current product shows; never invent
588
+ what it contains. AN UNREAD REFERENCE NEVER NARROWS SCOPE: write "UNREAD
589
+ AT PLANNING: <the link or name>. The agent working this card opens it
590
+ itself (a Claude Code agent reads a claude.ai/design link with its
591
+ DesignSync tool), builds this card's share of all of it, and names any part
592
+ no card owns." Never write that the agent should use only what you could
593
+ read as its scope, and never state that the reference is unavailable to the
594
+ agent: your read access is not its. When the person names a design or
595
+ mockup without a link, look for its link in the repository's docs, decision
596
+ records and the Library, and carry what you found. For example,
587
597
  "remake the settings page from this design", where the design shows a new
588
598
  sidebar, a redesigned profile form, a billing tab and inline save
589
599
  confirmations, is four cards, each carrying the design link and the
@@ -1268,6 +1278,8 @@ implementation and ask for the person's decision. Unknown generator-source
1268
1278
  identity is not a claim that an embedded GLB image is the original PNG. Opening
1269
1279
  a result handoff never authorizes an automatic asset commit or merge.
1270
1280
 
1281
+ ${WHOLE_REQUEST}
1282
+
1271
1283
  ${HELP_THEM_DECIDE}`;
1272
1284
 
1273
1285
  /**
@@ -1814,6 +1826,15 @@ HOW TO READ IT:
1814
1826
  asked for that the diff does not show, changes that reach further than the
1815
1827
  card did. Not formatting, not naming preferences, not the rewrite you would
1816
1828
  have preferred.
1829
+ 6. THE WHOLE REQUEST, AND WHAT THE AGENT SAYS IS MISSING. A card may carry
1830
+ THE PERSON'S OWN WORDS (the chat it was planned in) and the agent's
1831
+ declared gaps. Judge the diff against the words as well as the criteria:
1832
+ a screen, state, rule or linked design file the person asked for that the
1833
+ diff does not show and the gaps do not list is a concern, named in the
1834
+ card's note. A declared gap is the agent being honest, not a concern by
1835
+ itself; put the serious ones in "overall" so the reviewer meets them first.
1836
+ A linked design you cannot open is named in the note as unchecked, never
1837
+ judged from its name.
1817
1838
 
1818
1839
  END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
1819
1840
 
@@ -347,7 +347,7 @@ export const CODEX_RUNTIME = {
347
347
  if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
348
348
 
349
349
  a.push(...mcp, ...resultSchemaArgs);
350
- if (gated) a.push(...codexOrchestratorArgs());
350
+ if (gated) a.push(...codexOrchestratorArgs(codexVersion));
351
351
  a.push(streamInput ? '-' : codexPrompt({ system, prompt, profile }));
352
352
  return a;
353
353
  },
@@ -0,0 +1,47 @@
1
+ /**
2
+ * WHAT THE AGENT SAYS IT DID NOT DELIVER (2026-10-25).
3
+ *
4
+ * An audit of eighteen merged runs found the same shape in most of them: the
5
+ * delivery report said what was done and how it was checked, and nothing in
6
+ * the contract asked what was NOT done against the request — so a dropped
7
+ * design state came back as a "decision" ("Before Review they keep the old
8
+ * per-card plates"), a faked measurement as "measured 2500ms", and the pre-review,
9
+ * grading only the card's own checklist, passed both. The contract's
10
+ * `WHOLE_REQUEST` now asks for `gaps`; this reads them and says them where the
11
+ * person reviews (Verification on a code card, the summary on any other).
12
+ *
13
+ * THREE STATES, the house rule: no key is unknown and renders NOTHING (an
14
+ * older prompt, a resumed conversation that never saw it); `[]` is the
15
+ * agent's claim that it walked the request and nothing is missing; entries are
16
+ * listed. A malformed entry costs that entry alone.
17
+ */
18
+ export const GAP_STATUSES = ['not built', 'partly', 'unverified', 'unowned'];
19
+ export const MAX_GAPS = 40;
20
+ export const MAX_GAP_ITEM = 300;
21
+ export const MAX_GAP_WHY = 800;
22
+
23
+ const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
24
+
25
+ /** The declared gaps, scrubbed then bounded; undefined when none were declared. */
26
+ export function parseGaps(raw, scrub) {
27
+ if (!Array.isArray(raw)) return undefined;
28
+ const out = [];
29
+ for (const g of raw) {
30
+ if (out.length >= MAX_GAPS) break;
31
+ if (!g || typeof g !== 'object' || typeof g.item !== 'string' || !g.item.trim()) continue;
32
+ const status = typeof g.status === 'string' ? g.status.trim().toLowerCase() : '';
33
+ if (!GAP_STATUSES.includes(status)) continue;
34
+ 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
+ }
37
+ return out;
38
+ }
39
+
40
+ /** The section the person reads, or null for no declaration. */
41
+ export function gapsMarkdown(gaps) {
42
+ if (!Array.isArray(gaps)) return null;
43
+ 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');
45
+ }
46
+
47
+ const oneLine = (s) => s.replace(/\s*\n\s*/g, ' ');
@@ -46,6 +46,6 @@ export function validateReviewPlan(plan) {
46
46
 
47
47
  export function uiReviewInstructions(argv) {
48
48
  return `LOCAL UI SELF-REVIEW — before delivering UI-changing code, use this turn's owned headless runner: ${JSON.stringify([...argv, '<plan.json>'])}.
49
- Read repository preview scripts and affected acceptance criteria first. Write a JSON plan with command (argv array), basis (repository file/script), cwd (relative directory, optional), port (dedicated port explicitly set in command), readyPath, fixture: { isolated: true, description }, and scenarios: [{ name, path, actions: [{ type, selector, value }] }]. Actions: click, fill, submit, assertText, assertUrl (value is a local path), waitFor. Use only disposable local fixtures; never production data, real email, payments, personal sessions or credentials. The runner starts and measures its own preview and permits only that preview's resources. External dependencies are blocked and reported; use local fixtures instead.
49
+ Read repository preview scripts and affected acceptance criteria first. Write a JSON plan with command (argv array), basis (repository file/script), cwd (relative directory, optional), port (dedicated port explicitly set in command), readyPath, fixture: { isolated: true, description }, and scenarios: [{ name, path, actions: [{ type, selector, value }] }]. Actions: click, fill, submit, assertText, assertUrl (value is a local path), waitFor. Use only disposable local fixtures; never production data, real email, payments, personal sessions or credentials. The runner starts and measures its own preview and permits only that preview's resources. External dependencies are blocked and reported; use local fixtures instead. Shape the fixture from the case the request describes, not from your implementation: the person's screenshot or report (its counts, names and states, such as a 23-card agent or a page with no stored render) or a committed export of real data among the repository's test fixtures when one exists. A fixture shaped only by your own code shows the code renders, not that the request is met: list that as an "unverified" gap.
50
50
  Run desktop and mobile scenarios; read the returned report and OPEN EVERY returned screenshot using card_ui_review.inspect_screenshot when that tool is actually mounted (it returns the exact PNG as native image input), or your runtime's actual image-input tool (for example Read or view_image). Record which files you visually inspected and what you observed in delivery verification. A PNG path, capture success, tool listing or a skill is not proof of inspection. If image input is unavailable, report visual inspection skipped with its measured reason. Check console/network findings, expected and observed interaction results. Fix in-scope problems in this same worktree and rerun affected checks. At most ${UI_REVIEW_MAX_RUNS} runs per turn, each at most ${UI_REVIEW_TIMEOUT_MS / 1000}s; at the bound deliver remaining findings honestly. Later source edits invalidate earlier evidence; commit before the final run when possible. Include changed routes, ordered actions/outcomes, viewport sizes, screenshots, changes prompted by review and every unrun/unavailable check with reasons in verification. Missing browser, runnable preview or safe fixtures is unavailable, never passed; do not install tools or weaken isolation. This review never approves or merges work. For non-UI changes, state why browser review is not applicable.`;
51
51
  }
@@ -11,6 +11,7 @@ import { FLEET_URL } from './config.mjs';
11
11
  import { gitOrNull } from './git.mjs';
12
12
  import { parsePrecheck } from './agentPlan.mjs';
13
13
  import { readStash } from './agentCards.mjs';
14
+ import { gapsMarkdown } from './turnGaps.mjs';
14
15
  import { c, note } from './ui.mjs';
15
16
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
16
17
  import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
@@ -222,7 +223,9 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
222
223
  turnArgs: {
223
224
  prompt: AGENT_PRECHECK_KICKOFF({
224
225
  agentName,
225
- cards: stash.map((s) => s.prompt).join('\n---\n'),
226
+ // Each spec with the agent's own account of what it did not
227
+ // deliver; no declaration is said, never read as "nothing missing".
228
+ cards: stash.map((s) => `${s.prompt}\n\n${gapsMarkdown(s.gaps) ?? "The agent declared no gaps list for this card."}`).join('\n---\n'),
226
229
  missingSpecs,
227
230
  commits: log.text,
228
231
  diffCommand: `git diff ${repository?.baseRef ?? baseRef()}...HEAD`,
@@ -2,7 +2,7 @@ import { reportLocalCleanup } from './localCleanupReport.mjs';
2
2
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
3
3
  import { createCaptureRetention } from './referenceCaptures.mjs';
4
4
  import { imageRevisionInstructions } from './artifactIteration.mjs';
5
- import { agentChildScope } from './agentQueueContext.mjs';
5
+ import { agentChildScope, sourceChatText } from './agentQueueContext.mjs';
6
6
  import { measureTestFindings } from './testFindings.mjs';
7
7
  import { fetchResultInputs } from './resultInputs.mjs';
8
8
  import { agentWork } from './workActivity.mjs';
@@ -33,7 +33,7 @@ import { setRuntimeLimit } from './daemonState.mjs';
33
33
  import { gitOrNull } from './git.mjs';
34
34
  import { turnCommits } from './turnReceipts.mjs';
35
35
  import { addUsage } from './turnUsage.mjs';
36
- import { stashCard } from './agentCards.mjs';
36
+ import { stashCard, stashGaps } from './agentCards.mjs';
37
37
  import { c } from './ui.mjs';
38
38
  import { runTurn } from './runTurn.mjs';
39
39
  import { liveGroups, processesSupported } from './processes.mjs';
@@ -326,7 +326,7 @@ export function createAgentTurnExecution({
326
326
  stashCard(
327
327
  sessionMetaPath(wt, 'flowviant-agent-cards', agentId),
328
328
  job.task.id,
329
- AGENT_TASK_SPEC(job.task, job.cardThread, files.card)
329
+ AGENT_TASK_SPEC(job.task, job.cardThread, files.card) + sourceChatText(job.queueContext, job.task.id)
330
330
  );
331
331
  }
332
332
 
@@ -845,6 +845,9 @@ export function createAgentTurnExecution({
845
845
  modelExports: intermediateDelivery?.modelExports,
846
846
  });
847
847
  appendUiReviewEvidence(body, uiReviewEvidence);
848
+ // The agent's own account of what it did not deliver, for the pre-review.
849
+ if (job.kind === 'task' && job.task && body.outcome === 'delivered' && res?.gaps)
850
+ stashGaps(sessionMetaPath(wt, 'flowviant-agent-cards', agentId), job.task.id, res.gaps);
848
851
  if (body.outcome === 'delivered') intermediateDelivery?.commit();
849
852
  // This place's writer lock is still held. Measure the exited children
850
853
  // and their groups before releasing any file requirements. Dirty repository files are unrelated to managed scratch eligibility.
@@ -34,6 +34,7 @@ import { usableImageArtifact } from './imageArtifact.mjs';
34
34
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
35
35
  import { ANSWER_DELIVERED_USES, researchUseOf, withResearchHeader } from './researchUse.mjs';
36
36
  import { measuredModelExports } from './intermediateArtifacts.mjs';
37
+ import { gapsMarkdown } from './turnGaps.mjs';
37
38
 
38
39
  /** Materialization runs only for a declared delivery; settlement remains the
39
40
  * sole parser and the sole authority deciding whether it really delivered. */
@@ -166,6 +167,11 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
166
167
  : prerequisite?.taskKind === 'image' && (job.runtime ?? 'claude') !== 'codex'
167
168
  ? 'This agent runs on a CLI that cannot generate images. Image cards require Codex’s own image generation; this agent cannot run the requested texture work.'
168
169
  : null;
170
+ /* WHAT IT SAYS IT DID NOT DELIVER (turnGaps.mjs), where the person reads
171
+ the delivery: Verification on a code card, which carries it; the summary
172
+ on every other kind, which has no Verification lane. */
173
+ const gaps = res.outcome === 'delivered' && !cannotRequest ? gapsMarkdown(res.gaps) : null;
174
+ const verification = [res.verification, kind.posture === 'build' ? gaps : null].filter(Boolean).join('\n\n');
169
175
  return report(
170
176
  {
171
177
  turnId,
@@ -173,16 +179,20 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
173
179
  outcome: res.outcome,
174
180
  answer: cannotRequest ?? envScrub((kind.posture === 'research' && researchUse
175
181
  ? withResearchHeader(researchUse, res.answer ?? '') : res.answer ?? '') +
176
- (job.task?.taskType === 'tests' && res.raised?.length ? '\n\nAdditional CLI-reported findings (not filed as fixes):\n' + res.raised.map(r => `${r.title}\n${r.brief ?? ''}`).join('\n\n') : '')),
182
+ (job.task?.taskType === 'tests' && res.raised?.length ? '\n\nAdditional CLI-reported findings (not filed as fixes):\n' + res.raised.map(r => `${r.title}\n${r.brief ?? ''}`).join('\n\n') : '') +
183
+ (gaps && kind.posture !== 'build' ? `\n\n${gaps}` : '')),
177
184
  // CLI-authored evidence travels separately from the short answer and
178
185
  // measured commits/checks. Older turns simply send no report.
179
- ...(kind.posture === 'build' && res.outcome === 'delivered' && res.verification
180
- ? { verification: envScrub(res.verification) } : {}),
186
+ ...(kind.posture === 'build' && res.outcome === 'delivered' && verification
187
+ ? { verification: envScrub(verification) } : {}),
181
188
  ...(kind.posture === 'build' && res.outcome === 'delivered' && res.handover
182
189
  ? { handover: envScrub(res.handover) } : {}),
183
190
  ...(kind.posture === 'build' && res.outcome === 'delivered' && res.automation
184
191
  ? { automation: envScrub(res.automation) } : {}),
185
192
  ...(commits.length ? { commits } : {}),
193
+ // …and the same gaps as data, so the server can propose the parts no
194
+ // card owned (`gapFollowUps.ts`). A report: an older server drops it.
195
+ ...(gaps && res.gaps?.length ? { gaps: res.gaps } : {}),
186
196
  // SCRUBBED like the answer beside it: a raised card lands in the
187
197
  // project doc every member reads, and a brief quoting the `.env` the
188
198
  // agent just read ("value sk_live_… is logged in pay.ts") went out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.21",
3
+ "version": "0.125.22",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {