@0xgeorgemathew/zcode 0.4.4 → 0.5.1

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.
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
- // lib/commands/sessions.js — status | wait | cancel. Read-only views over
2
+ // lib/commands/sessions.js — status | wait | result | cancel. Read-only views over
3
3
  // the session store (plus a signal for cancel); none of them spawns the
4
4
  // engine or needs the API key, so they are instant and safe to poll.
5
5
  const store = require('../store.js');
6
+ const { buildBrief } = require('../brief.js');
6
7
  const { formatResult, formatSessionRow, formatSessionView, useColor } = require('../ui.js');
7
8
 
8
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -82,9 +83,35 @@ function summaryOf(s) {
82
83
  };
83
84
  }
84
85
 
86
+ const isDone = (view) => view.state === 'finished' || view.state === 'interrupted';
87
+
88
+ // `12m`, `45s`, `1h05m`: how long a session has been alive, for one-line views.
89
+ function formatElapsed(ms) {
90
+ const s = Math.max(0, Math.round(ms / 1000));
91
+ if (s < 60) return `${s}s`;
92
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
93
+ return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
94
+ }
95
+
96
+ // What a still-running session costs the reader on a wait timeout: exactly
97
+ // one line, nothing else.
98
+ function pendingLine(view, json) {
99
+ const elapsedMs = Math.max(0, Date.now() - Date.parse(view.createdAt || view.updatedAt || Date.now()));
100
+ if (json) return JSON.stringify({ type: 'pending', id: view.id, state: view.state, phase: view.phase, elapsedMs });
101
+ return `${view.id} ${view.state} ${view.phase} ${formatElapsed(elapsedMs)}`;
102
+ }
103
+
104
+ // A finished session's handback in the requested form.
105
+ function printResult(result, { json, full }) {
106
+ if (json) process.stdout.write(JSON.stringify(full ? result : buildBrief(result)) + '\n');
107
+ else process.stdout.write(formatResult(result, { color: useColor(process.stdout) }));
108
+ }
109
+
85
110
  // Blocks until every id is finished (or interrupted), printing each
86
- // handback as it lands. Exit 0 iff all completed; otherwise the exit code
87
- // of the first one that did not (124 when --timeout-ms elapses first).
111
+ // handback as it lands (--any: until the FIRST one finishes; the rest keep
112
+ // running). Exit 0 iff all completed; otherwise the exit code of the first
113
+ // one that did not (--any: that of the one reported). On --timeout-ms: one
114
+ // line per pending session and exit 124.
88
115
  async function wait(parsed) {
89
116
  const { flags, prompts, given } = parsed;
90
117
  if (prompts.length === 0) {
@@ -97,22 +124,22 @@ async function wait(parsed) {
97
124
  const pending = new Set(ids);
98
125
  let code = 0;
99
126
  for (;;) {
100
- for (const id of [...pending]) {
127
+ for (const id of ids) {
128
+ if (!pending.has(id)) continue;
101
129
  const view = store.viewSession(store.readSession(id));
102
- if (view.state !== 'finished' && view.state !== 'interrupted') continue;
130
+ if (!isDone(view)) continue;
103
131
  pending.delete(id);
104
132
  const result = finalResult(view);
105
133
  const c = exitCodeFor(result);
134
+ printResult(result, { json: flags.json, full: flags.full });
135
+ if (flags.any) process.exit(c);
106
136
  if (c !== 0 && code === 0) code = c;
107
- if (flags.json) process.stdout.write(JSON.stringify(result) + '\n');
108
- else process.stdout.write(formatResult(result, { color: useColor(process.stdout) }));
109
137
  }
110
138
  if (pending.size === 0) process.exit(code);
111
139
  if (Date.now() >= deadline) {
112
- for (const id of pending) {
113
- const view = store.viewSession(store.readSession(id));
114
- if (flags.json) process.stdout.write(JSON.stringify(statusPayload(view, { events: 3 })) + '\n');
115
- else process.stderr.write(`zcode: ${id} still ${view.state} (phase ${view.phase})\n`);
140
+ for (const id of ids) {
141
+ if (!pending.has(id)) continue;
142
+ process.stdout.write(pendingLine(store.viewSession(store.readSession(id)), flags.json) + '\n');
116
143
  }
117
144
  process.exit(124);
118
145
  }
@@ -120,6 +147,27 @@ async function wait(parsed) {
120
147
  }
121
148
  }
122
149
 
150
+ // result <id>: the handback of a finished session, without waiting. Brief
151
+ // as JSON with --json, the human view otherwise; --full prints the full
152
+ // record (JSON).
153
+ async function result(parsed) {
154
+ const { flags, prompts } = parsed;
155
+ if (prompts.length !== 1) {
156
+ process.stderr.write('zcode: result needs exactly one session id.\n');
157
+ process.exit(2);
158
+ }
159
+ const id = resolveOrExit(prompts[0], flags.json);
160
+ const view = store.viewSession(store.readSession(id));
161
+ if (!isDone(view)) {
162
+ process.stderr.write(`zcode: ${id} is still ${view.state} (phase ${view.phase}); use "zcode wait ${id}".\n`);
163
+ process.exit(7);
164
+ }
165
+ const r = finalResult(view);
166
+ if (flags.full) process.stdout.write(JSON.stringify(r) + '\n');
167
+ else printResult(r, { json: flags.json, full: false });
168
+ process.exit(0);
169
+ }
170
+
123
171
  // SIGTERM to the owning process; it stops the engine turn, kills checks,
124
172
  // records "cancelled". Escalates to SIGKILL after 15 s and records the
125
173
  // cancellation itself. Idempotent: cancelling a finished session is a no-op.
@@ -175,6 +223,7 @@ async function cancel(parsed) {
175
223
  module.exports = async function sessions(parsed) {
176
224
  if (parsed.command === 'status') return status(parsed);
177
225
  if (parsed.command === 'wait') return wait(parsed);
226
+ if (parsed.command === 'result') return result(parsed);
178
227
  return cancel(parsed);
179
228
  };
180
229
 
package/lib/contract.js CHANGED
@@ -19,12 +19,18 @@ const CONTRACT_KEYS = [
19
19
  'scope',
20
20
  'constraints',
21
21
  'design',
22
+ 'decisions',
23
+ 'units',
24
+ 'visual',
25
+ 'viewports',
26
+ 'serve',
22
27
  'acceptance',
23
28
  'checks',
24
29
  'files',
25
30
  'references',
26
31
  'context',
27
32
  'owns',
33
+ 'touch',
28
34
  'limits',
29
35
  'model',
30
36
  'effort',
@@ -36,6 +42,43 @@ const asList = (v) =>
36
42
  ? []
37
43
  : (Array.isArray(v) ? v : [v]).map((x) => String(x).trim()).filter(Boolean);
38
44
 
45
+ const uniq = (items) => [...new Set(items)];
46
+ const VIEWPORT_RE = /^\d{2,5}x\d{2,5}$/;
47
+ const DEFAULT_VIEWPORTS = ['390x844', '1440x900'];
48
+ const UNIT_KEYS = ['name', 'objective', 'owns'];
49
+
50
+ // "name: objective [owns a,b]" -> {name, objective, owns?}
51
+ function parseUnit(spec) {
52
+ const m = /^\s*([^:]+?)\s*:\s*([\s\S]*?)\s*(?:\[owns\s+([^\]]*)\])?\s*$/.exec(String(spec));
53
+ if (!m || !m[1].trim() || !m[2].trim()) {
54
+ throw new ContractError(`unit "${String(spec).slice(0, 60)}" must look like "name: objective" (optionally "[owns a,b]")`);
55
+ }
56
+ const owns = (m[3] || '').split(',').map((x) => x.trim()).filter(Boolean);
57
+ return { name: m[1].trim(), objective: m[2].trim(), ...(owns.length ? { owns } : {}) };
58
+ }
59
+
60
+ function normalizeUnits(items) {
61
+ const units = items.map((raw) => {
62
+ if (typeof raw === 'string') return parseUnit(raw);
63
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
64
+ throw new ContractError('contract "units" entries must be {name, objective, owns?} objects or "name: objective" strings');
65
+ }
66
+ const bad = Object.keys(raw).filter((k) => !UNIT_KEYS.includes(k));
67
+ if (bad.length) throw new ContractError(`unknown unit key(s): ${bad.join(', ')}. Accepted: ${UNIT_KEYS.join(', ')}`);
68
+ const name = String(raw.name ?? '').trim();
69
+ const objective = String(raw.objective ?? '').trim();
70
+ if (!name || !objective) throw new ContractError('every unit needs a name and an objective');
71
+ const owns = asList(raw.owns);
72
+ return { name, objective, ...(owns.length ? { owns } : {}) };
73
+ });
74
+ const seen = new Set();
75
+ for (const u of units) {
76
+ if (seen.has(u.name)) throw new ContractError(`duplicate unit name "${u.name}"`);
77
+ seen.add(u.name);
78
+ }
79
+ return units;
80
+ }
81
+
39
82
  // Builds a contract from an optional base object (JSON file / markdown
40
83
  // brief) plus CLI additions. Throws ContractError on unknown keys or a
41
84
  // missing objective — before any engine spawn.
@@ -62,6 +105,11 @@ function normalizeContract(base = {}, additions = {}) {
62
105
  throw new ContractError(`contract limits.${k} must be a non-negative number`);
63
106
  }
64
107
  }
108
+ const viewports = uniq([...asList(base.viewports), ...asList(additions.viewports)]);
109
+ for (const v of viewports) {
110
+ if (!VIEWPORT_RE.test(v)) throw new ContractError(`viewport "${v}" must look like 390x844`);
111
+ }
112
+ const serve = String(additions.serve ?? base.serve ?? '').trim();
65
113
  const objective = [String(base.objective ?? '').trim(), String(additions.objective ?? '').trim()]
66
114
  .filter(Boolean)
67
115
  .join('\n\n');
@@ -73,12 +121,21 @@ function normalizeContract(base = {}, additions = {}) {
73
121
  scope: asList(base.scope),
74
122
  constraints: asList(base.constraints),
75
123
  design: asList(base.design),
76
- acceptance: [...asList(base.acceptance), ...asList(additions.acceptance)],
124
+ decisions: uniq([...asList(base.decisions), ...asList(additions.decisions)]),
125
+ units: normalizeUnits([
126
+ ...(Array.isArray(base.units) ? base.units : base.units === undefined ? [] : [base.units]),
127
+ ...(additions.units || []),
128
+ ]),
129
+ visual: uniq([...asList(base.visual), ...asList(additions.visual)]),
130
+ viewports,
131
+ serve,
132
+ acceptance: uniq([...asList(base.acceptance), ...asList(additions.acceptance)]),
77
133
  checks: [...asList(base.checks), ...asList(additions.checks)],
78
- files: [...asList(base.files), ...asList(additions.files)],
134
+ files: uniq([...asList(base.files), ...asList(additions.files)]),
79
135
  references: asList(base.references),
80
- context: [...asList(base.context), ...asList(additions.context)],
136
+ context: uniq([...asList(base.context), ...asList(additions.context)]),
81
137
  owns: [...asList(base.owns), ...asList(additions.owns)],
138
+ touch: [...asList(base.touch), ...asList(additions.touch)],
82
139
  limits: { ...limits },
83
140
  model: base.model ?? null,
84
141
  effort: base.effort ?? null,
@@ -103,33 +160,90 @@ const HANDBACK_SPEC = [
103
160
  ' "criteria": [{"criterion": "acceptance criterion", "met": true, "evidence": "short"}],',
104
161
  ' "unresolved": ["problems inside your scope that remain unfixed"],',
105
162
  ' "questions": ["what you need from the orchestrator (only when blocked)"],',
163
+ ' "decisions": [{"what": "choice you made in scope", "why": "short"}],',
106
164
  ' "notes": "risks or follow-ups the orchestrator should know"}',
107
165
  '```',
108
166
  '"done" means every acceptance criterion is met and verified; "partial" means some work remains; "blocked" means you cannot proceed without the orchestrator.',
167
+ 'A question may be {"q": "...", "options": ["A ...", "B ..."], "recommend": "A"}.',
168
+ ].join('\n');
169
+
170
+ // Rendered on every task prompt, so it stays tiny (<= 400 bytes with
171
+ // realistic paths; scripts/measure.js budgets for it). `session` is
172
+ // {id, port, out, tmp}: the environment zcode gives the executor and the
173
+ // checks so parallel sessions cannot collide.
174
+ function sessionSection(session) {
175
+ if (!session) return '';
176
+ return [
177
+ '## Session environment',
178
+ 'Parallel sessions share this machine. Use these, never fixed values:',
179
+ `- ZCODE_PORT=${session.port}: the only port to bind (dev/preview servers)`,
180
+ `- ZCODE_OUT=${session.out}: build output dirs (e.g. --outDir) and test artifacts`,
181
+ `- ZCODE_TMP=${session.tmp}: scratch files`,
182
+ '',
183
+ ].join('\n');
184
+ }
185
+
186
+ function unitsSection(units) {
187
+ if (!units || !units.length) return '';
188
+ const lines = units.map((u) => `${u.name}: ${u.objective}${u.owns && u.owns.length ? ` (owns: ${u.owns.join(', ')})` : ''}`);
189
+ return `## Units\nDispatch each unit below to its own subagent (Agent tool) with that unit's objective, the binding decisions above and its owns. Run independent units in parallel, then integrate and verify the whole. Report each in handback \`units\`: [{"name", "status", "note"}].\n${bullets(lines)}\n`;
190
+ }
191
+
192
+ // Only rendered when the contract declares visual criteria.
193
+ function visualSection(contract) {
194
+ if (!contract.visual || !contract.visual.length) return '';
195
+ const viewports = contract.viewports && contract.viewports.length ? contract.viewports : DEFAULT_VIEWPORTS;
196
+ return [
197
+ '## Visual verification',
198
+ 'Verify the rendered result yourself with Playwright; you can look at screenshots.',
199
+ contract.serve
200
+ ? `- Serve the app with \`${contract.serve}\` on $ZCODE_PORT (it must honour that port).`
201
+ : '- Serve the app on $ZCODE_PORT (pick the dev/preview command from package.json; no other port).',
202
+ `- At each viewport (${viewports.join(', ')}) capture screenshots into $ZCODE_OUT/shots: the full page plus every key state a criterion names.`,
203
+ '- Look at the screenshots and judge each criterion. Fix failures, re-capture, judge again. Stop the server when done.',
204
+ 'Criteria:',
205
+ bullets(contract.visual),
206
+ 'Report in handback `visual`: [{"criterion", "viewport", "met", "note", "shot"}] (shot = screenshot path); it MUST include an entry for every criterion above.',
207
+ '',
208
+ ].join('\n');
209
+ }
210
+
211
+ const PREFLIGHT_SECTION = [
212
+ '## Preflight (read-only)',
213
+ 'This turn is a preflight, not the task. Make NO edits: do not create, modify or delete files, and run nothing with side effects. Read what you need, then list every open question you would otherwise have to guess (user-visible or behavioural choices the binding decisions and context do not settle) in handback `questions`, each as {"q": "...", "options": ["A ...", "B ..."], "recommend": "A"}. Report status "blocked" with those questions, or "done" with summary "no open questions" if there are none. The orchestrator will answer, then continue this session.',
214
+ '',
109
215
  ].join('\n');
110
216
 
111
217
  // `ownsDisplay` are the patterns as the caller wrote them (relative to cwd).
112
- function buildTaskPrompt(contract, { ownsDisplay = contract.owns } = {}) {
218
+ function buildTaskPrompt(contract, { ownsDisplay = contract.owns, session = null, preflight = false } = {}) {
113
219
  const parts = [
114
220
  'You are Zcode, an execution engine invoked by an orchestrator agent. The orchestrator owns product decisions, design direction, planning and task boundaries. You own implementation and verification inside the scope of this task. Work autonomously: there is no human watching and nobody reviews your intermediate steps.',
115
221
  '',
116
222
  `## Objective\n${contract.objective}\n`,
117
223
  section('Scope', contract.scope),
118
224
  section('Constraints', contract.constraints),
119
- section('Design decisions (supplied by the orchestrator: implement them exactly; do not change them)', contract.design),
225
+ section('Binding decisions (made by the orchestrator: implement exactly)', [...(contract.decisions || []), ...contract.design]),
120
226
  section('Relevant files (read these first)', contract.files),
121
227
  section('References', contract.references),
122
228
  section('Context', contract.context),
123
229
  section('Acceptance criteria', contract.acceptance),
230
+ unitsSection(contract.units),
124
231
  ownsDisplay.length
125
232
  ? `## Workspace ownership\nYou may create, modify or delete ONLY these paths (relative to the working directory):\n${bullets(ownsDisplay)}\nOther sessions may be working in this workspace at the same time: do not touch anything else, and do not run formatters or code generators over the whole repository.\n`
126
233
  : '',
127
- contract.checks.length
234
+ contract.touch && contract.touch.length
235
+ ? `## Also allowed\nYou may also make minimal edits to:\n${bullets(contract.touch)}\nReport each such edit in your handback \`changes\`. Do not use this to widen the task.\n`
236
+ : '',
237
+ sessionSection(session),
238
+ visualSection(contract),
239
+ preflight ? PREFLIGHT_SECTION : '',
240
+ contract.checks.length && !preflight
128
241
  ? `## Acceptance checks\nAfter you finish, Zcode will run these commands in the working directory and treat any non-zero exit as a failure. Run them yourself before you finish:\n${bullets(contract.checks.map((c) => `\`${c}\``))}\n`
129
242
  : '',
130
243
  [
131
244
  '## How to work',
132
245
  '- Make routine implementation decisions yourself. Do not stop to ask for approval of intermediate steps.',
246
+ '- Where the binding decisions and context leave something user-visible or behavioural open, do not guess: if it blocks the work, report "blocked" with a structured question; otherwise take the most conservative choice and list it in handback `decisions`.',
133
247
  '- Do not make product or design decisions the orchestrator has not made. If one is consequential and cannot be resolved from this task, report status "blocked" with your questions instead of guessing.',
134
248
  '- You may use the Agent tool for independent units of work. Give each subagent a bounded objective and an explicit list of files it may change; never let two subagents change the same file. A subagent reporting success is a claim: inspect its changes and verify the integrated result yourself.',
135
249
  '- Verify every acceptance criterion against the actual result. When something fails, diagnose and fix it, then verify again.',
@@ -137,37 +251,56 @@ function buildTaskPrompt(contract, { ownsDisplay = contract.owns } = {}) {
137
251
  '- Report "blocked" only when you lack information or authority that this task does not provide (for example a missing credential, a required change outside your ownership, or a consequential ambiguity).',
138
252
  '',
139
253
  ].join('\n'),
140
- `## Handback\n${HANDBACK_SPEC}`,
254
+ `## Handback\n${HANDBACK_SPEC}${contract.visual && contract.visual.length ? '\nThe handback MUST include a `visual` entry for every declared visual criterion.' : ''}`,
141
255
  ];
142
256
  return parts.filter(Boolean).join('\n');
143
257
  }
144
258
 
145
- function buildRepairPrompt(failures, { attempt, maxRepairs }) {
259
+ function buildRepairPrompt(failures, { attempt, maxRepairs, visual = [] }) {
146
260
  const blocks = failures.map((f) => {
147
261
  const why = f.timedOut ? 'timed out' : `exit ${f.exitCode}`;
148
262
  return `### \`${f.command}\` (${why})\n\`\`\`\n${f.output.trim().slice(-3000) || '(no output)'}\n\`\`\``;
149
263
  });
150
- return [
151
- `Zcode ran the acceptance checks and ${failures.length === 1 ? 'one failed' : `${failures.length} failed`} (repair attempt ${attempt} of ${maxRepairs}):`,
152
- '',
153
- ...blocks,
264
+ const head = failures.length
265
+ ? `Zcode ran the acceptance checks and ${failures.length === 1 ? 'one failed' : `${failures.length} failed`} (repair attempt ${attempt} of ${maxRepairs}):`
266
+ : `Your visual verification left criteria unmet (repair attempt ${attempt} of ${maxRepairs}).`;
267
+ const lines = [head, '', ...blocks];
268
+ if (visual.length) {
269
+ lines.push(
270
+ failures.length ? '' : null,
271
+ 'Visual criteria not met (or not reported):',
272
+ ...visual.map((v) => `- ${v}`),
273
+ 'Serve the app on $ZCODE_PORT again, fix the cause, re-capture the screenshots, judge them again and report `visual` for every criterion.'
274
+ );
275
+ }
276
+ lines.push(
154
277
  '',
155
- 'Diagnose the root cause and fix it within your ownership, run the checks again yourself, then finish with a fresh zcode-handback block. If the failure is outside your scope or cannot be fixed, report status "partial" or "blocked" and explain why.',
156
- ].join('\n');
278
+ failures.length
279
+ ? 'Diagnose the root cause and fix it within your ownership, run the checks again yourself, then finish with a fresh zcode-handback block. If the failure is outside your scope or cannot be fixed, report status "partial" or "blocked" and explain why.'
280
+ : 'Finish with a fresh zcode-handback block. If a criterion cannot be met within your scope, report status "partial" or "blocked" and explain why.'
281
+ );
282
+ return lines.filter((l) => l !== null).join('\n');
157
283
  }
158
284
 
159
285
  const HANDBACK_REQUEST =
160
286
  'Your previous message did not end with a valid zcode-handback block. Do not redo any work. Reply with only the handback block describing the current state.\n\n' +
161
287
  HANDBACK_SPEC;
162
288
 
163
- function buildResumePrompt(message, { kind }) {
289
+ // Cheap report-only follow-up when declared visual criteria were left out of
290
+ // the handback (no redo, no repair budget).
291
+ function buildVisualReportRequest(missing) {
292
+ return `Do not redo any work. Reply with only the handback block, including \`visual\` for every criterion: ${missing.join('; ')}\n\n` + HANDBACK_SPEC;
293
+ }
294
+
295
+ function buildResumePrompt(message, { kind, portChange = null }) {
164
296
  const note =
165
297
  'Work from earlier in this session is already applied to the workspace. Inspect the current state before repeating any step, and do not repeat external side effects.';
166
298
  if (kind !== 'task') return message;
167
299
  const body = message && message.trim()
168
300
  ? `Message from the orchestrator:\n\n${message.trim()}`
169
301
  : 'Continue the task from where you stopped.';
170
- return `${body}\n\n${note}\n\n${HANDBACK_SPEC}`;
302
+ const env = portChange ? `\n\nZCODE_PORT is now ${portChange} (the previous port was taken); bind that one.` : '';
303
+ return `${body}\n\n${note}${env}\n\n${HANDBACK_SPEC}`;
171
304
  }
172
305
 
173
306
  // --- handback -------------------------------------------------------------------
@@ -184,6 +317,48 @@ const STATUS_ALIASES = new Map([
184
317
  ['blocked', 'blocked'],
185
318
  ]);
186
319
 
320
+ const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
321
+
322
+ // Plain-string questions stay strings; structured ones are {q, options?, recommend?}.
323
+ function question(q) {
324
+ if (!isObject(q) || typeof q.q !== 'string') return isObject(q) ? JSON.stringify(q) : String(q);
325
+ const options = Array.isArray(q.options) ? q.options.map(String) : [];
326
+ return {
327
+ q: q.q,
328
+ ...(options.length ? { options } : {}),
329
+ ...(q.recommend !== undefined && q.recommend !== null && q.recommend !== '' ? { recommend: String(q.recommend) } : {}),
330
+ };
331
+ }
332
+
333
+ const questionText = (q) => (typeof q === 'string' ? q : [q.q, ...(q.options || []).map((o) => ` - ${o}`), q.recommend ? ` recommend: ${q.recommend}` : null].filter(Boolean).join('\n'));
334
+
335
+ function decision(d) {
336
+ return isObject(d) ? { what: String(d.what ?? '').trim(), why: String(d.why ?? '').trim() } : { what: String(d ?? '').trim(), why: '' };
337
+ }
338
+
339
+ // Declared visual criteria the executor did not meet. An absent report counts
340
+ // as unmet; an entry without met:true does too.
341
+ function visualGaps(handback, declared) {
342
+ if (!declared || !declared.length || !handback || handback.status === 'blocked') return { missing: [], failed: [] };
343
+ const entries = handback.visual || [];
344
+ const norm = (x) => String(x || '').trim().toLowerCase();
345
+ const covered = (c) => entries.some((v) => {
346
+ const a = norm(v.criterion);
347
+ return a && (a === norm(c) || a.includes(norm(c)) || norm(c).includes(a));
348
+ });
349
+ return {
350
+ missing: declared.filter((c) => !covered(c)),
351
+ failed: entries
352
+ .filter((v) => v.met !== true)
353
+ .map((v) => `${v.criterion || '(unnamed)'}${v.viewport ? ` @${v.viewport}` : ''}`),
354
+ };
355
+ }
356
+
357
+ function unmetVisual(handback, declared) {
358
+ const { missing, failed } = visualGaps(handback, declared);
359
+ return [...failed, ...missing.map((c) => `${c} (not reported)`)];
360
+ }
361
+
187
362
  // Returns {handback, report, error}: the LAST block wins; `report` is the
188
363
  // response text with the block removed.
189
364
  function parseHandback(text) {
@@ -211,7 +386,10 @@ function parseHandback(text) {
211
386
  checks: arr(raw.checks),
212
387
  criteria: arr(raw.criteria),
213
388
  unresolved: arr(raw.unresolved).map(String),
214
- questions: arr(raw.questions).map(String),
389
+ questions: arr(raw.questions).map(question),
390
+ decisions: arr(raw.decisions).map(decision).filter((d) => d.what),
391
+ units: arr(raw.units).filter(isObject),
392
+ visual: arr(raw.visual).filter(isObject),
215
393
  notes: typeof raw.notes === 'string' ? raw.notes : '',
216
394
  },
217
395
  report,
@@ -241,6 +419,9 @@ function deriveStatus({
241
419
  handbackError = null,
242
420
  checks = [],
243
421
  scopeViolations = [],
422
+ preflight = false,
423
+ preflightWrote = [],
424
+ visualDeclared = [],
244
425
  }) {
245
426
  const issues = [];
246
427
  if (cancelled) return { status: 'cancelled', issues: ['cancelled before completion'] };
@@ -250,6 +431,16 @@ function deriveStatus({
250
431
  if (error) return { status: 'failed', issues: [error.message || String(error)] };
251
432
  if (kind !== 'task') return { status: 'completed', issues };
252
433
 
434
+ if (preflight) {
435
+ if (preflightWrote.length) {
436
+ return { status: 'failed', issues: [`preflight must be read-only but changed: ${preflightWrote.join(', ')}`] };
437
+ }
438
+ if (!handback) return { status: 'unverified', issues: [`no usable handback from the executor (${handbackError || 'missing'})`] };
439
+ if (handback.status === 'blocked' || handback.questions.length) {
440
+ return { status: 'blocked', issues: ['preflight found open questions (see questions)'] };
441
+ }
442
+ return { status: 'completed', issues };
443
+ }
253
444
  if (handback && handback.status === 'blocked') {
254
445
  return { status: 'blocked', issues: ['the executor reported it is blocked (see questions)'] };
255
446
  }
@@ -270,6 +461,11 @@ function deriveStatus({
270
461
  partial = true;
271
462
  issues.push(`unmet acceptance criteria: ${unmet.map((c) => c.criterion).join('; ')}`);
272
463
  }
464
+ const visualUnmet = unmetVisual(handback, visualDeclared);
465
+ if (visualUnmet.length) {
466
+ partial = true;
467
+ issues.push(`visual criteria unmet: ${visualUnmet.join('; ')}`);
468
+ }
273
469
  if (scopeViolations.length) {
274
470
  partial = true;
275
471
  issues.push(`changed files outside the owned scope: ${scopeViolations.join(', ')}`);
@@ -290,7 +486,14 @@ function deriveStatus({
290
486
 
291
487
  module.exports = {
292
488
  ContractError,
489
+ sessionSection,
293
490
  CONTRACT_KEYS,
491
+ DEFAULT_VIEWPORTS,
492
+ parseUnit,
493
+ questionText,
494
+ unmetVisual,
495
+ visualGaps,
496
+ buildVisualReportRequest,
294
497
  normalizeContract,
295
498
  buildTaskPrompt,
296
499
  buildRepairPrompt,