flowviant 0.125.21 → 0.125.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +5 -0
- package/bin/lib/agentBrief.mjs +4 -0
- package/bin/lib/agentCards.mjs +24 -1
- package/bin/lib/agentPlan.mjs +3 -0
- package/bin/lib/agentQueueContext.mjs +20 -1
- package/bin/lib/agentTaskKinds.mjs +23 -3
- package/bin/lib/agentTaskTypes.mjs +9 -1
- package/bin/lib/artifactContracts.mjs +116 -1
- package/bin/lib/artifactPolicy.mjs +5 -0
- package/bin/lib/artifactPreview.mjs +4 -3
- package/bin/lib/artifacts.mjs +4 -0
- package/bin/lib/codexFencedMount.mjs +3 -0
- package/bin/lib/codexSubagents.mjs +6 -2
- package/bin/lib/knowledgeFetch.mjs +8 -3
- package/bin/lib/knowledgeFiles.mjs +4 -4
- package/bin/lib/knowledgeLibrary.mjs +14 -10
- package/bin/lib/orchestrator.mjs +40 -14
- package/bin/lib/orchestratorHooks.mjs +15 -2
- package/bin/lib/orchestratorNative.mjs +30 -9
- package/bin/lib/pinnedCapture.mjs +58 -7
- package/bin/lib/projectTools.mjs +4 -1
- package/bin/lib/prompts.mjs +35 -7
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/turnGaps.mjs +47 -0
- package/bin/lib/uiReviewContract.mjs +1 -1
- package/bin/lib/videoEncode.mjs +406 -0
- package/bin/lib/videoRender.mjs +529 -0
- package/bin/lib/workAgentPrecheck.mjs +4 -1
- package/bin/lib/workAgentTools.mjs +8 -4
- package/bin/lib/workAgentTurnExecution.mjs +19 -4
- package/bin/lib/workAgentTurnOutcome.mjs +13 -3
- package/bin/lib/worktreeDiff.mjs +154 -55
- package/package.json +1 -1
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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
|
|
33
|
-
*
|
|
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),
|
|
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
|
}
|
|
@@ -38,6 +38,21 @@
|
|
|
38
38
|
* The launch obeys shot.mjs's policy: sandboxed unless `sandboxOff`, one retry
|
|
39
39
|
* without it after a launch that measured a sandbox failure, and the machine
|
|
40
40
|
* credential stripped from Chrome's environment.
|
|
41
|
+
*
|
|
42
|
+
* A CALLER MAY DRIVE THE HELD PAGE (`work`, 2026-10-26, the video kind's
|
|
43
|
+
* render tool): instead of one screenshot, `work(page)` runs once the page
|
|
44
|
+
* has loaded and is still the page, with `page.screenshot()` and
|
|
45
|
+
* `page.evaluate(expression)`; `initScript` runs before the page's own
|
|
46
|
+
* scripts. Every pin above still holds; the page is checked again after
|
|
47
|
+
* `work` and a page that left is still 'left-page'.
|
|
48
|
+
*
|
|
49
|
+
* A DRIVEN PAGE RUNS ON REAL TIME. Stepping Chrome's virtual time was tried
|
|
50
|
+
* for the video render and measured broken (Chrome 149): once a page's last
|
|
51
|
+
* animation ended the compositor went idle and `Page.captureScreenshot`
|
|
52
|
+
* waited for a frame that never came, whatever the clock or screenshot
|
|
53
|
+
* options — a real video turn's page hung at frame 41 — and CSS and
|
|
54
|
+
* requestAnimationFrame time ran ahead of `performance.now()`. A page that
|
|
55
|
+
* needs a clock brings its own in `initScript` (videoRender.mjs `VIDEO_CLOCK`).
|
|
41
56
|
*/
|
|
42
57
|
|
|
43
58
|
import { spawn } from 'node:child_process';
|
|
@@ -147,9 +162,18 @@ export function sameDocument(a, b) {
|
|
|
147
162
|
* `launchFailed` means the browser never answered on the pipe — the only
|
|
148
163
|
* failure a different launch (classic headless, or no sandbox) can change.
|
|
149
164
|
*/
|
|
150
|
-
async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env }) {
|
|
165
|
+
async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, work, initScript, signal }, { spawnImpl, env }) {
|
|
166
|
+
// The thumbnail's bounded virtual-time policy; never a model export's or a
|
|
167
|
+
// driven page's (see the header).
|
|
168
|
+
const clocked = !modelExport && !work;
|
|
151
169
|
const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
|
|
152
|
-
|
|
170
|
+
// A driven page's WINDOW is at least 400×400, its viewport still exactly
|
|
171
|
+
// width × height (the metrics override below): MEASURED on Chrome 149
|
|
172
|
+
// (2026-10-26), a window 50 px tall or less left screenshots of a driven
|
|
173
|
+
// page waiting on a frame that never came, from about the tenth frame on,
|
|
174
|
+
// while 120 px and taller never did, at any width.
|
|
175
|
+
const windowSize = work ? { width: Math.max(width, 400), height: Math.max(height, 400) } : { width, height };
|
|
176
|
+
const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, ...windowSize }), '--remote-debugging-pipe', 'about:blank'];
|
|
153
177
|
let child;
|
|
154
178
|
try {
|
|
155
179
|
child = spawnImpl(browser.bin, args, {
|
|
@@ -238,6 +262,9 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
238
262
|
const ourSession = new Promise((r) => { ready = r; });
|
|
239
263
|
let budgetDone;
|
|
240
264
|
const budget = new Promise((r) => { budgetDone = r; });
|
|
265
|
+
// A driven page is driven once it has loaded.
|
|
266
|
+
let loadedDone;
|
|
267
|
+
const loaded = new Promise((r) => { loadedDone = r; });
|
|
241
268
|
|
|
242
269
|
let navigating = false;
|
|
243
270
|
// Called only for a target that is paused before it runs. Once the page
|
|
@@ -292,6 +319,7 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
292
319
|
return;
|
|
293
320
|
}
|
|
294
321
|
if (msg.method === 'Emulation.virtualTimeBudgetExpired') budgetDone();
|
|
322
|
+
if (msg.method === 'Page.loadEventFired') loadedDone();
|
|
295
323
|
});
|
|
296
324
|
|
|
297
325
|
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
|
|
@@ -313,11 +341,12 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
313
341
|
ours = { targetId: ourTargetId, sessionId };
|
|
314
342
|
|
|
315
343
|
await cdp.send('Page.enable', {}, sessionId);
|
|
316
|
-
if (modelExport) await cdp.send('Runtime.enable', {}, sessionId);
|
|
344
|
+
if (modelExport || work) await cdp.send('Runtime.enable', {}, sessionId);
|
|
317
345
|
if (modelExport) await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: MODEL_EXPORT_BRIDGE }, sessionId);
|
|
346
|
+
if (work && initScript) await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: initScript }, sessionId);
|
|
318
347
|
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
|
|
319
348
|
await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
|
|
320
|
-
if (
|
|
349
|
+
if (clocked) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
|
|
321
350
|
await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
|
|
322
351
|
navigating = true;
|
|
323
352
|
const nav = await cdp.send('Page.navigate', { url }, sessionId);
|
|
@@ -326,7 +355,7 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
326
355
|
}
|
|
327
356
|
// Model readiness/export waits on actual loaded textures and the page's
|
|
328
357
|
// handler. Thumbnails keep their bounded virtual-time screenshot policy.
|
|
329
|
-
if (
|
|
358
|
+
if (clocked) await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
|
|
330
359
|
let exported = null;
|
|
331
360
|
if (modelExport) {
|
|
332
361
|
const answer = await cdp.send('Runtime.evaluate', {
|
|
@@ -338,7 +367,8 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
338
367
|
// A Chrome that never reports the budget is captured on the wall clock,
|
|
339
368
|
// short of the deadline, rather than never.
|
|
340
369
|
let wall;
|
|
341
|
-
if (
|
|
370
|
+
if (clocked) await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
|
|
371
|
+
else if (work) await Promise.race([loaded, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
|
|
342
372
|
clearTimeout(wall);
|
|
343
373
|
|
|
344
374
|
const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
|
|
@@ -353,6 +383,25 @@ async function captureOnce(browser, { url, out, width, height, timeoutMs, budget
|
|
|
353
383
|
|
|
354
384
|
if (modelExport) return { ok: true, base64: exported.base64 };
|
|
355
385
|
|
|
386
|
+
if (work) {
|
|
387
|
+
const page = {
|
|
388
|
+
screenshot: async () => {
|
|
389
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
390
|
+
return Buffer.from(shot.data ?? '', 'base64');
|
|
391
|
+
},
|
|
392
|
+
evaluate: async (expression) => {
|
|
393
|
+
const answer = await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId);
|
|
394
|
+
if (answer.exceptionDetails) throw new Error(String(answer.exceptionDetails.exception?.description ?? answer.exceptionDetails.text ?? 'evaluation failed').slice(0, 500));
|
|
395
|
+
return answer.result?.value;
|
|
396
|
+
},
|
|
397
|
+
left: () => left,
|
|
398
|
+
pageError: () => pageError,
|
|
399
|
+
};
|
|
400
|
+
const value = await work(page);
|
|
401
|
+
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
402
|
+
return { ok: true, value };
|
|
403
|
+
}
|
|
404
|
+
|
|
356
405
|
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
357
406
|
// One last look: a navigation that landed during the capture.
|
|
358
407
|
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
@@ -390,6 +439,8 @@ export async function capturePinnedPage({
|
|
|
390
439
|
budgetMs = 5_000,
|
|
391
440
|
allowSubresource = () => false,
|
|
392
441
|
modelExport = false,
|
|
442
|
+
work = null,
|
|
443
|
+
initScript = null,
|
|
393
444
|
signal,
|
|
394
445
|
browser = resolveBrowser(),
|
|
395
446
|
spawn: spawnImpl = spawn,
|
|
@@ -406,7 +457,7 @@ export async function capturePinnedPage({
|
|
|
406
457
|
let last = null;
|
|
407
458
|
for (const headlessFlag of ['--headless=new', '--headless']) {
|
|
408
459
|
const once = () =>
|
|
409
|
-
captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, signal }, { spawnImpl, env });
|
|
460
|
+
captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox, modelExport, work, initScript, signal }, { spawnImpl, env });
|
|
410
461
|
let r = await once();
|
|
411
462
|
if (!r.ok && r.sandboxFailed) {
|
|
412
463
|
noSandbox = true; // sticky: the one retry this capture gets
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -219,7 +219,10 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
219
219
|
mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
-
|
|
222
|
+
// Daemon-owned names cannot be replaced by project config. A turn server's
|
|
223
|
+
// `toolTimeoutSec` is the Codex mount's alone (codexFencedMount.mjs); it is
|
|
224
|
+
// no key of Claude's mcp.json.
|
|
225
|
+
for (const [name, { toolTimeoutSec: _codexOnly, ...config }] of Object.entries(turnServers)) mcpServers[name] = config;
|
|
223
226
|
const mcpPath = join(dir, 'mcp.json');
|
|
224
227
|
writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
225
228
|
const pointers = [];
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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, SYSTEM_AGENT_VIDEO, 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.
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
@@ -642,6 +652,9 @@ with the matching name your CLI exposes.
|
|
|
642
652
|
"An illustration", "a hero image", "a photo of", "artwork for". Only on a
|
|
643
653
|
machine whose Codex makes images: if stage_card refuses the kind, say so
|
|
644
654
|
rather than staging another kind in its place.
|
|
655
|
+
- "video": an animation — GIF, MP4 or WebM files rendered from a page the
|
|
656
|
+
agent writes; no code changed. "A GIF", "an animation", "a video of",
|
|
657
|
+
"a looping clip", "animate the logo".
|
|
645
658
|
- "deck": a presentation — one HTML slide deck; no code changed. "Slides",
|
|
646
659
|
"a deck", "a presentation", "a pitch".
|
|
647
660
|
- "research": findings or a requested Markdown document — read the repo and the web; no code changed.
|
|
@@ -881,6 +894,7 @@ export const INTAKE_TASK_TYPE_MEANINGS = Object.freeze({
|
|
|
881
894
|
model3d: 'Game-ready or product assets',
|
|
882
895
|
vector: 'SVG icons, logos, marks',
|
|
883
896
|
image: 'Illustrations, photos, artwork',
|
|
897
|
+
video: 'Animated GIFs and short clips',
|
|
884
898
|
research: 'Find out and cite',
|
|
885
899
|
writing: 'Docs, copy, specs',
|
|
886
900
|
});
|
|
@@ -993,15 +1007,16 @@ THE RULES THAT MATTER:
|
|
|
993
1007
|
|
|
994
1008
|
8. EVERY CARD HAS A KIND. A "code" card lands as commits and is merged; every
|
|
995
1009
|
other kind — "design" (a mockup), "model" (a 3D model), "image" (pictures),
|
|
996
|
-
"
|
|
1010
|
+
"video" (GIF, MP4 or WebM animations), "deck" (a presentation) and
|
|
1011
|
+
"research" (findings or a requested document) —
|
|
997
1012
|
changes nothing in the repository. Research retains findings, sources and
|
|
998
1013
|
uncertainties directly on its source card; only requested documents need
|
|
999
1014
|
a Markdown file. The other non-code kinds hand back files. Keep non-code cards in agents of their
|
|
1000
1015
|
own unless they are prerequisites for code, including an investigation
|
|
1001
1016
|
whose findings the next card fixes. Independent artifacts mixed into a code
|
|
1002
1017
|
agent turn a review that is "look at this and
|
|
1003
|
-
accept it" into a merge. Image cards run on Codex. Mockup, 3D model
|
|
1004
|
-
cards run on Claude OR Codex, so dependent image and model work belongs on
|
|
1018
|
+
accept it" into a merge. Image cards run on Codex. Mockup, 3D model, video
|
|
1019
|
+
and deck cards run on Claude OR Codex, so dependent image and model work belongs on
|
|
1005
1020
|
ONE Codex agent. Research runs on Claude, or on Codex where that machine
|
|
1006
1021
|
reports support; keep each group on a CLI that can run all its cards.
|
|
1007
1022
|
Different end products alone are not a reason to split dependent work.
|
|
@@ -1268,6 +1283,8 @@ implementation and ask for the person's decision. Unknown generator-source
|
|
|
1268
1283
|
identity is not a claim that an embedded GLB image is the original PNG. Opening
|
|
1269
1284
|
a result handoff never authorizes an automatic asset commit or merge.
|
|
1270
1285
|
|
|
1286
|
+
${WHOLE_REQUEST}
|
|
1287
|
+
|
|
1271
1288
|
${HELP_THEM_DECIDE}`;
|
|
1272
1289
|
|
|
1273
1290
|
/**
|
|
@@ -1437,6 +1454,7 @@ export const AGENT_CONTRACTS = Object.freeze({
|
|
|
1437
1454
|
design: SYSTEM_AGENT_DESIGN,
|
|
1438
1455
|
model: SYSTEM_AGENT_MODEL,
|
|
1439
1456
|
image: SYSTEM_AGENT_IMAGE,
|
|
1457
|
+
video: SYSTEM_AGENT_VIDEO,
|
|
1440
1458
|
deck: SYSTEM_AGENT_DECK,
|
|
1441
1459
|
research: SYSTEM_AGENT_RESEARCH,
|
|
1442
1460
|
});
|
|
@@ -1444,6 +1462,7 @@ export const AGENT_HANDS_BACK = Object.freeze({
|
|
|
1444
1462
|
design: 'a DESIGN card: it hands back a mockup',
|
|
1445
1463
|
model: 'a 3D MODEL card: it hands back a model to view and download as a .glb',
|
|
1446
1464
|
image: 'an IMAGE card: it hands back pictures as PNG or WebP files',
|
|
1465
|
+
video: 'a VIDEO card: it hands back animation files as GIF, MP4 or WebM',
|
|
1447
1466
|
deck: 'a PRESENTATION card: it hands back an HTML slide deck',
|
|
1448
1467
|
research: 'a RESEARCH card: it hands back findings or a requested document',
|
|
1449
1468
|
});
|
|
@@ -1814,6 +1833,15 @@ HOW TO READ IT:
|
|
|
1814
1833
|
asked for that the diff does not show, changes that reach further than the
|
|
1815
1834
|
card did. Not formatting, not naming preferences, not the rewrite you would
|
|
1816
1835
|
have preferred.
|
|
1836
|
+
6. THE WHOLE REQUEST, AND WHAT THE AGENT SAYS IS MISSING. A card may carry
|
|
1837
|
+
THE PERSON'S OWN WORDS (the chat it was planned in) and the agent's
|
|
1838
|
+
declared gaps. Judge the diff against the words as well as the criteria:
|
|
1839
|
+
a screen, state, rule or linked design file the person asked for that the
|
|
1840
|
+
diff does not show and the gaps do not list is a concern, named in the
|
|
1841
|
+
card's note. A declared gap is the agent being honest, not a concern by
|
|
1842
|
+
itself; put the serious ones in "overall" so the reviewer meets them first.
|
|
1843
|
+
A linked design you cannot open is named in the note as unchecked, never
|
|
1844
|
+
judged from its name.
|
|
1817
1845
|
|
|
1818
1846
|
END YOUR TURN WITH ONE JSON OBJECT AND NOTHING AFTER IT, in a \`\`\`json fence:
|
|
1819
1847
|
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
}
|