@0xgeorgemathew/zcode 0.5.0 → 0.6.0

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/lib/contract.js CHANGED
@@ -189,21 +189,35 @@ function unitsSection(units) {
189
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
190
  }
191
191
 
192
- // Only rendered when the contract declares visual criteria.
193
- function visualSection(contract) {
192
+ // Only rendered when the contract declares visual criteria. zcode itself
193
+ // decides `visual`: an independent image-capable verifier judges the result
194
+ // from its own screenshots after the work (lib/verify.js).
195
+ function visualSection(contract, { canSee = true } = {}) {
194
196
  if (!contract.visual || !contract.visual.length) return '';
195
197
  const viewports = contract.viewports && contract.viewports.length ? contract.viewports : DEFAULT_VIEWPORTS;
198
+ const serve = contract.serve
199
+ ? `- Make sure the app serves with \`${contract.serve}\` on $ZCODE_PORT (it must honour that port).`
200
+ : '- Make sure the app serves on $ZCODE_PORT (pick the dev/preview command from package.json; no other port).';
201
+ if (!canSee) {
202
+ return [
203
+ '## Visual verification',
204
+ 'You cannot see images. Do not judge screenshots or report `visual`; an independent vision verifier will.',
205
+ serve,
206
+ '- Self-check visibility in the DOM: key text must have a non-zero box inside the viewport, not clipped by an overflow ancestor, computed opacity > 0, visibility visible, after animations finish.',
207
+ `Viewports: ${viewports.join(', ')}. Criteria the verifier will judge:`,
208
+ bullets(contract.visual),
209
+ '',
210
+ ].join('\n');
211
+ }
196
212
  return [
197
213
  '## 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).',
214
+ 'Verify the rendered result yourself with Playwright; you can look at screenshots. An independent verifier will re-judge it from its own screenshots afterwards.',
215
+ serve,
202
216
  `- At each viewport (${viewports.join(', ')}) capture screenshots into $ZCODE_OUT/shots: the full page plus every key state a criterion names.`,
203
217
  '- Look at the screenshots and judge each criterion. Fix failures, re-capture, judge again. Stop the server when done.',
204
218
  'Criteria:',
205
219
  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.',
220
+ 'You may report handback `visual`: [{"criterion", "viewport", "met", "note", "shot"}]; it is not required.',
207
221
  '',
208
222
  ].join('\n');
209
223
  }
@@ -215,7 +229,7 @@ const PREFLIGHT_SECTION = [
215
229
  ].join('\n');
216
230
 
217
231
  // `ownsDisplay` are the patterns as the caller wrote them (relative to cwd).
218
- function buildTaskPrompt(contract, { ownsDisplay = contract.owns, session = null, preflight = false } = {}) {
232
+ function buildTaskPrompt(contract, { ownsDisplay = contract.owns, session = null, preflight = false, canSeeImages = true } = {}) {
219
233
  const parts = [
220
234
  '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.',
221
235
  '',
@@ -235,7 +249,7 @@ function buildTaskPrompt(contract, { ownsDisplay = contract.owns, session = null
235
249
  ? `## 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
250
  : '',
237
251
  sessionSection(session),
238
- visualSection(contract),
252
+ visualSection(contract, { canSee: canSeeImages }),
239
253
  preflight ? PREFLIGHT_SECTION : '',
240
254
  contract.checks.length && !preflight
241
255
  ? `## 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`
@@ -251,11 +265,12 @@ function buildTaskPrompt(contract, { ownsDisplay = contract.owns, session = null
251
265
  '- 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).',
252
266
  '',
253
267
  ].join('\n'),
254
- `## Handback\n${HANDBACK_SPEC}${contract.visual && contract.visual.length ? '\nThe handback MUST include a `visual` entry for every declared visual criterion.' : ''}`,
268
+ `## Handback\n${HANDBACK_SPEC}`,
255
269
  ];
256
270
  return parts.filter(Boolean).join('\n');
257
271
  }
258
272
 
273
+ // `visual`: the verifier's unmet entries {criterion, viewport, seen, note, shot}.
259
274
  function buildRepairPrompt(failures, { attempt, maxRepairs, visual = [] }) {
260
275
  const blocks = failures.map((f) => {
261
276
  const why = f.timedOut ? 'timed out' : `exit ${f.exitCode}`;
@@ -263,14 +278,21 @@ function buildRepairPrompt(failures, { attempt, maxRepairs, visual = [] }) {
263
278
  });
264
279
  const head = failures.length
265
280
  ? `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}).`;
281
+ : `An independent visual verifier, looking at screenshots of the running app, found criteria unmet (repair attempt ${attempt} of ${maxRepairs}).`;
267
282
  const lines = [head, '', ...blocks];
268
283
  if (visual.length) {
269
284
  lines.push(
270
285
  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.'
286
+ 'Visual criteria the verifier found unmet:',
287
+ ...visual.map((v) => {
288
+ const text = [
289
+ v.seen ? `saw: ${JSON.stringify(String(v.seen).slice(0, 300))}` : null,
290
+ v.note ? String(v.note).slice(0, 300) : null,
291
+ v.shot ? `shot: ${v.shot}` : null,
292
+ ].filter(Boolean).join(' | ');
293
+ return `- ${v.criterion || '(unnamed)'}${v.viewport ? ` @${v.viewport}` : ''}${text ? ` - ${text}` : ''}`;
294
+ }),
295
+ 'Fix the cause. Zcode will re-run the checks and the verifier will look again; its screenshots are the evidence, not DOM presence.'
274
296
  );
275
297
  }
276
298
  lines.push(
@@ -286,12 +308,6 @@ const HANDBACK_REQUEST =
286
308
  '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' +
287
309
  HANDBACK_SPEC;
288
310
 
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
311
  function buildResumePrompt(message, { kind, portChange = null }) {
296
312
  const note =
297
313
  '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.';
@@ -336,27 +352,29 @@ function decision(d) {
336
352
  return isObject(d) ? { what: String(d.what ?? '').trim(), why: String(d.why ?? '').trim() } : { what: String(d ?? '').trim(), why: '' };
337
353
  }
338
354
 
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 || [];
355
+ // Visual verdicts (zcode's verifier entries) against the declared criteria.
356
+ // `failed`: entries judged unmet (met:false); `unverified`: entries the
357
+ // verifier could not judge (met:null); `missing`: declared criteria no entry
358
+ // covers. Entries not met:true include the built-in readability criterion.
359
+ function visualGaps(entries, declared) {
360
+ const list = Array.isArray(entries) ? entries : [];
344
361
  const norm = (x) => String(x || '').trim().toLowerCase();
345
- const covered = (c) => entries.some((v) => {
362
+ const covered = (c) => list.some((v) => {
346
363
  const a = norm(v.criterion);
347
364
  return a && (a === norm(c) || a.includes(norm(c)) || norm(c).includes(a));
348
365
  });
366
+ const label = (v) => `${v.criterion || '(unnamed)'}${v.viewport ? ` @${v.viewport}` : ''}`;
349
367
  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}` : ''}`),
368
+ missing: (declared || []).filter((c) => !covered(c)),
369
+ failed: list.filter((v) => v.met === false),
370
+ unverified: list.filter((v) => v.met === null).map(label),
354
371
  };
355
372
  }
356
373
 
357
- function unmetVisual(handback, declared) {
358
- const { missing, failed } = visualGaps(handback, declared);
359
- return [...failed, ...missing.map((c) => `${c} (not reported)`)];
374
+ // Strings for what is unmet: failed entries and declared criteria nobody judged.
375
+ function unmetVisual(entries, declared) {
376
+ const { missing, failed } = visualGaps(entries, declared);
377
+ return [...failed.map((v) => `${v.criterion || '(unnamed)'}${v.viewport ? ` @${v.viewport}` : ''}`), ...missing.map((c) => `${c} (not reported)`)];
360
378
  }
361
379
 
362
380
  // Returns {handback, report, error}: the LAST block wins; `report` is the
@@ -422,6 +440,7 @@ function deriveStatus({
422
440
  preflight = false,
423
441
  preflightWrote = [],
424
442
  visualDeclared = [],
443
+ visual = [],
425
444
  }) {
426
445
  const issues = [];
427
446
  if (cancelled) return { status: 'cancelled', issues: ['cancelled before completion'] };
@@ -461,10 +480,17 @@ function deriveStatus({
461
480
  partial = true;
462
481
  issues.push(`unmet acceptance criteria: ${unmet.map((c) => c.criterion).join('; ')}`);
463
482
  }
464
- const visualUnmet = unmetVisual(handback, visualDeclared);
465
- if (visualUnmet.length) {
466
- partial = true;
467
- issues.push(`visual criteria unmet: ${visualUnmet.join('; ')}`);
483
+ if (handback && visualDeclared.length) {
484
+ const gaps = visualGaps(visual, visualDeclared);
485
+ const unmet = unmetVisual(visual, visualDeclared);
486
+ if (unmet.length) {
487
+ partial = true;
488
+ issues.push(`visual criteria unmet: ${unmet.join('; ')}`);
489
+ }
490
+ if (gaps.unverified.length) {
491
+ partial = true;
492
+ issues.push(`visual not verified: ${gaps.unverified.join('; ')}`);
493
+ }
468
494
  }
469
495
  if (scopeViolations.length) {
470
496
  partial = true;
@@ -493,7 +519,6 @@ module.exports = {
493
519
  questionText,
494
520
  unmetVisual,
495
521
  visualGaps,
496
- buildVisualReportRequest,
497
522
  normalizeContract,
498
523
  buildTaskPrompt,
499
524
  buildRepairPrompt,
package/lib/executor.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  liveLeases,
29
29
  snapshot,
30
30
  diffSnapshots,
31
+ ensureLocalExclude,
31
32
  } = require('./workspace.js');
32
33
  const {
33
34
  buildTaskPrompt,
@@ -36,10 +37,10 @@ const {
36
37
  HANDBACK_REQUEST,
37
38
  parseHandback,
38
39
  deriveStatus,
39
- unmetVisual,
40
40
  visualGaps,
41
- buildVisualReportRequest,
42
41
  } = require('./contract.js');
42
+ const { buildVerifierPrompt, parseVisualBlock, notVerified: notVerifiedEntries } = require('./verify.js');
43
+ const { visionModel, supportsImage } = require('./models.js');
43
44
 
44
45
  const RESULT_SCHEMA = 'zcode.result/1';
45
46
  const SUBAGENT_TOOLS = new Set(['Agent', 'Task']);
@@ -131,6 +132,31 @@ function sumUsage(turns) {
131
132
  return input === null && output === null ? null : { inputTokens: input, outputTokens: output };
132
133
  }
133
134
 
135
+ // Model requests and tokens over every turn of an invocation, failed ones
136
+ // included (their usage never arrived: `incomplete`). null = nothing ran.
137
+ function usageOf(turns, failedTurns) {
138
+ const all = [...turns, ...failedTurns];
139
+ if (!all.length) return null;
140
+ const base = sumUsage(turns) || { inputTokens: null, outputTokens: null };
141
+ const incomplete = all.some((t) => !t.usage);
142
+ return {
143
+ ...base,
144
+ modelRequests: all.reduce((n, t) => n + (t.modelRequests || 0), 0),
145
+ ...(incomplete ? { incomplete: true } : {}),
146
+ };
147
+ }
148
+
149
+ // Running totals across invocations (resumes) kept on the session record.
150
+ function addUsageTotal(prior, usage) {
151
+ const p = prior || {};
152
+ const u = usage || {};
153
+ return {
154
+ inputTokens: (p.inputTokens || 0) + (u.inputTokens || 0),
155
+ outputTokens: (p.outputTokens || 0) + (u.outputTokens || 0),
156
+ modelRequests: (p.modelRequests || 0) + (u.modelRequests || 0),
157
+ };
158
+ }
159
+
134
160
  function exitCodeFor(result) {
135
161
  switch (result.status) {
136
162
  case 'completed':
@@ -231,31 +257,42 @@ async function execute(inv, opts = {}) {
231
257
  let lastWrite = 0;
232
258
  let toolCalls = record.activity ? record.activity.toolCalls || 0 : 0;
233
259
  let subagentsStarted = record.activity ? record.activity.subagentsStarted || 0 : 0;
260
+ let modelRequests = record.activity ? record.activity.modelRequests || 0 : 0;
261
+ let subagentsRunning = 0;
234
262
  let lastTool = null;
263
+ let lastToolAt = null;
264
+ let lastEventAt = null;
265
+ const activity = () => ({
266
+ toolCalls, subagentsStarted, subagentsRunning, modelRequests, lastTool, lastToolAt,
267
+ lastEventAt: lastEventAt || new Date().toISOString(),
268
+ });
235
269
  const relay = (ev) => {
236
270
  emit(ev);
237
- if (ev.type === 'stream') return;
271
+ const now = Date.now();
272
+ lastEventAt = new Date(now).toISOString(); // any engine event counts as life
273
+ if (ev.type === 'model_request_started') modelRequests++;
238
274
  if (ev.type === 'tool') {
239
275
  if (ev.phase === 'started') {
240
276
  toolCalls++;
241
277
  lastTool = ev.name;
278
+ lastToolAt = new Date(now).toISOString();
242
279
  if (SUBAGENT_TOOLS.has(ev.name)) {
243
280
  subagentsStarted++;
281
+ subagentsRunning++;
244
282
  emit({ type: 'subagent', phase: 'started', toolCallId: ev.id });
245
283
  }
246
284
  } else if ((ev.phase === 'result' || ev.phase === 'error') && SUBAGENT_TOOLS.has(ev.name)) {
285
+ subagentsRunning = Math.max(0, subagentsRunning - 1);
247
286
  emit({ type: 'subagent', phase: ev.phase === 'result' ? 'returned' : 'error', toolCallId: ev.id });
248
287
  }
249
288
  }
250
- if (ev.type === 'model_request_started' || ev.type === 'state' || ev.type === 'turn') return;
251
289
  try {
252
- store.appendEvent(id, ev, env);
253
- const now = Date.now();
290
+ if (!(ev.type === 'stream' || ev.type === 'model_request_started' || ev.type === 'state' || ev.type === 'turn')) {
291
+ store.appendEvent(id, ev, env);
292
+ }
254
293
  if (now - lastWrite > 1000) {
255
294
  lastWrite = now;
256
- update({
257
- activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date(now).toISOString() },
258
- });
295
+ update({ activity: activity() });
259
296
  }
260
297
  } catch {
261
298
  /* activity is best-effort; never fail the run over it */
@@ -266,7 +303,8 @@ async function execute(inv, opts = {}) {
266
303
  emit(ev);
267
304
  try {
268
305
  store.appendEvent(id, ev, env);
269
- update({ phase, activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date().toISOString() } });
306
+ lastEventAt = new Date().toISOString();
307
+ update({ phase, phaseDetail: Object.keys(detail).length ? detail : null, activity: activity() });
270
308
  } catch {
271
309
  /* best-effort */
272
310
  }
@@ -328,10 +366,13 @@ async function execute(inv, opts = {}) {
328
366
  }
329
367
 
330
368
  update({ state: 'running', phase: 'starting' });
369
+ ensureLocalExclude(record.cwd);
331
370
 
332
371
  let client = null;
333
372
  let session = null;
334
373
  const turns = [];
374
+ const vturns = []; // independent verifier turns (never the implementer's)
375
+ const failedTurns = []; // turns that threw: only their partial facts survive
335
376
  let checks = [];
336
377
  let repairs = 0;
337
378
  let parsed = { handback: null, report: '', error: 'missing' };
@@ -345,7 +386,11 @@ async function execute(inv, opts = {}) {
345
386
  let sessionEnv = null;
346
387
  const visualDeclared = (inv.contract && inv.contract.visual) || [];
347
388
  const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
348
- const checkTouched = new Set(); // files written by zcode's own check runs
389
+ const checkTouched = new Set(); // files written by zcode's own check runs and the verifier
390
+ const verifierEdits = new Set();
391
+ let verifierModel = null;
392
+ let verifyRounds = 0;
393
+ let visualEntries = null; // the verifier's verdicts: authoritative `visual`
349
394
  const invocationStart = new Date(started).toISOString();
350
395
 
351
396
  try {
@@ -379,6 +424,7 @@ async function execute(inv, opts = {}) {
379
424
  browser: !inv.noBrowser,
380
425
  skillsDirs: inv.skillsDirs || [],
381
426
  noSkills: inv.noSkills,
427
+ model: inv.model,
382
428
  });
383
429
  before = snapshot(root);
384
430
 
@@ -415,6 +461,7 @@ async function execute(inv, opts = {}) {
415
461
  ownsDisplay: inv.ownsDisplay || [],
416
462
  session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
417
463
  preflight,
464
+ canSeeImages: supportsImage(inv.model, { env, builtinConfig: runtime.builtinConfig }),
418
465
  });
419
466
  prompt = leavingPreflight
420
467
  ? `${taskPrompt()}\n\n## Answers to your preflight questions\nThe preflight is over: implement the task now (edits are allowed). The orchestrator's answers are binding:\n${(inv.resume.message || '').trim() || '(none: take the most conservative choice for anything open and list it in handback `decisions`)'}`
@@ -435,8 +482,67 @@ async function execute(inv, opts = {}) {
435
482
  prompt = inv.resume ? buildResumePrompt(inv.resume.message, { kind: 'run' }) : inv.prompt;
436
483
  }
437
484
 
485
+ // One verifier round: a NEW engine session (own client: fresh context,
486
+ // separate from the implementer's) on the first image-capable model.
487
+ // Returns an entry per criterion; failures become met:null, never false.
488
+ const verifyVisual = async (round) => {
489
+ verifierModel = visionModel({ env, builtinConfig: runtime.builtinConfig });
490
+ verifyRounds = round;
491
+ setPhase('verifying', { round });
492
+ const vprompt = buildVerifierPrompt(inv.contract, {
493
+ session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
494
+ round,
495
+ });
496
+ const preSnap = before ? snapshot(root) : null;
497
+ let entries = null;
498
+ let why = 'verifier did not run';
499
+ for (let attempt = 1; attempt <= 2 && !entries; attempt++) {
500
+ if ((signal && signal.aborted) || Date.now() >= deadline) break;
501
+ let vclient = null;
502
+ try {
503
+ vclient = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
504
+ vclient.start();
505
+ const vsession = await openSession(vclient, {
506
+ workspacePath: record.cwd,
507
+ modelId: verifierModel,
508
+ effort: 'high',
509
+ requiredMcp: built.mcp.added,
510
+ onEvent: relay,
511
+ });
512
+ const vturn = await runTurn(vclient, vsession, {
513
+ modelId: verifierModel,
514
+ effort: 'high',
515
+ prompt: vprompt,
516
+ deadline,
517
+ signal,
518
+ onEvent: relay,
519
+ });
520
+ vturns.push(vturn);
521
+ const p = parseVisualBlock(vturn.response);
522
+ if (p.error) why = `the verifier gave no usable zcode-visual block (${p.error})`;
523
+ else entries = p.entries;
524
+ } catch (err) {
525
+ if (err && err.turn) failedTurns.push(err.turn);
526
+ if (err && (err.kind === 'timeout' || err.kind === 'cancelled')) throw err;
527
+ why = `verifier failed: ${scrub(err && err.message ? err.message : String(err)).slice(0, 200)}`;
528
+ } finally {
529
+ if (vclient) vclient.kill();
530
+ }
531
+ }
532
+ if (preSnap) {
533
+ const owned = (p) => [...concurrent.values()].some((o) => o.length && isOwned(p, o));
534
+ for (const f of diffSnapshots(preSnap, snapshot(root)) || []) {
535
+ if (isZcodePath(f.path) || owned(f.path)) continue;
536
+ verifierEdits.add(f.path);
537
+ checkTouched.add(f.path); // verifier edits are an issue, not a task scope violation
538
+ }
539
+ }
540
+ const got = entries || [];
541
+ const gaps = visualGaps(got, visualDeclared);
542
+ return [...got, ...notVerifiedEntries(gaps.missing, entries ? 'the verifier did not report it' : why)];
543
+ };
544
+
438
545
  let askedForHandback = false;
439
- let askedForVisual = false;
440
546
  let phase = 'executing';
441
547
  for (;;) {
442
548
  setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
@@ -484,18 +590,15 @@ async function execute(inv, opts = {}) {
484
590
  }
485
591
  }
486
592
  const failures = checks.filter((c) => !c.ok);
487
- // Unmet visual criteria share the repair budget with failing checks.
488
- const gaps = visualGaps(parsed.handback, visualDeclared);
489
- // Criteria merely left out of the handback get one cheap report-only
490
- // follow-up that does not consume the repair budget.
491
- if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
593
+ // zcode, not the implementer, judges the visual criteria. Unmet ones
594
+ // share the repair budget with failing checks; a verifier that could
595
+ // not run (met:null) is reported, not repaired.
596
+ let visualUnmet = [];
597
+ if (visualDeclared.length && !failures.length && parsed.handback
492
598
  && !(signal && signal.aborted) && Date.now() < deadline) {
493
- askedForVisual = true;
494
- prompt = buildVisualReportRequest(gaps.missing);
495
- phase = 'reporting';
496
- continue;
599
+ visualEntries = await verifyVisual(verifyRounds + 1);
600
+ visualUnmet = visualEntries.filter((v) => v.met === false);
497
601
  }
498
- const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
499
602
  if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
500
603
  if ((signal && signal.aborted) || Date.now() >= deadline) break;
501
604
  repairs++;
@@ -515,6 +618,7 @@ async function execute(inv, opts = {}) {
515
618
  }
516
619
  } catch (err) {
517
620
  error = err;
621
+ if (err && err.turn) failedTurns.push(err.turn);
518
622
  } finally {
519
623
  if (client) client.kill();
520
624
  if (releasePort) releasePort();
@@ -586,11 +690,20 @@ async function execute(inv, opts = {}) {
586
690
  preflight,
587
691
  preflightWrote,
588
692
  visualDeclared,
693
+ visual: visualEntries || [],
589
694
  });
590
695
 
591
696
  let errorOut = null;
592
697
  if (status === 'failed') {
593
- if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
698
+ if (error && error.kind === 'timeout') {
699
+ // The work already on disk is kept; resuming continues from it.
700
+ const plan = /^plan:(\S+)/.exec(inv.label || record.label || '');
701
+ const cmd = plan ? `zcode plan resume ${plan[1]} "continue"` : `zcode resume ${id} "continue"`;
702
+ errorOut = {
703
+ kind: 'timeout',
704
+ message: `timed out after ${Math.round(inv.timeoutMs / 60000)} min; "${cmd}" picks up from the current files`,
705
+ };
706
+ } else if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
594
707
  else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
595
708
  else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
596
709
  } else if (status === 'cancelled') {
@@ -619,18 +732,23 @@ async function execute(inv, opts = {}) {
619
732
  notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
620
733
  }
621
734
  if (visualDeclared.length) {
622
- const reported = handback ? handback.visual : [];
623
- notVerified.push(`visual criteria are the executor's judgement from its own screenshots (${reported.filter((v) => v.met === true).length} of ${reported.length} reported met); zcode ran no pixel check`);
735
+ const judged = visualEntries || [];
736
+ notVerified.push(visualEntries
737
+ ? `visual criteria judged by an independent ${verifierModel} verifier from its own screenshots (${judged.filter((v) => v.met === true).length} of ${judged.length} met)`
738
+ : 'visual criteria were not judged: the independent verifier did not run');
624
739
  }
625
740
  }
626
741
  if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
627
742
  if (subagentNote) issues.push(subagentNote);
743
+ if (verifierEdits.size) issues.push(`verifier edited files: ${[...verifierEdits].join(', ')}`);
628
744
 
629
745
  const last = turns[turns.length - 1] || null;
630
746
  const response = last ? last.response : '';
631
747
  const questions = [...(handback ? handback.questions : [])];
632
748
  if (userInput) questions.push(userInput);
633
749
 
750
+ const usage = usageOf([...turns, ...vturns], failedTurns);
751
+ const usageTotal = addUsageTotal(record.usageTotal, usage);
634
752
  const result = {
635
753
  type: 'result',
636
754
  schema: RESULT_SCHEMA,
@@ -655,7 +773,9 @@ async function execute(inv, opts = {}) {
655
773
  touched,
656
774
  ...(preflight ? { preflight: true } : {}),
657
775
  decisions: handback ? handback.decisions : [],
658
- visual: handback ? handback.visual : [],
776
+ visual: visualEntries || [],
777
+ executorVisual: handback ? handback.visual : [],
778
+ ...(visualDeclared.length ? { verifier: { model: verifierModel, rounds: verifyRounds } } : {}),
659
779
  units: handback ? handback.units : [],
660
780
  sessionEnv: sessionEnv
661
781
  ? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
@@ -684,7 +804,8 @@ async function execute(inv, opts = {}) {
684
804
  effort: (last && last.engineReasoningLevel) || inv.effort,
685
805
  effortRequested: inv.effort,
686
806
  mode: session ? session.mode : null,
687
- usage: sumUsage(turns),
807
+ usage,
808
+ usageTotal,
688
809
  reasoningChars: turns.reduce((n, t) => n + t.reasoningChars, 0),
689
810
  toolCalls: turns.some((t) => typeof t.toolCallCount === 'number')
690
811
  ? turns.reduce((n, t) => n + (t.toolCallCount || 0), 0)
@@ -709,7 +830,9 @@ async function execute(inv, opts = {}) {
709
830
  state: 'finished',
710
831
  phase: 'done',
711
832
  status,
712
- activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date().toISOString() },
833
+ usageTotal,
834
+ phaseDetail: null,
835
+ activity: activity(),
713
836
  outcome: {
714
837
  status,
715
838
  summary: result.summary,
package/lib/help.js ADDED
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // Per-command help: `zcode <command> --help` prints the command's usage and
3
+ // only the flags that apply to it (each text <= 2000 bytes). The full text
4
+ // (bin/zcode.js USAGE) is for `zcode --help` with no command.
5
+ const SESSION = ` --id <id> name the session (existing id is never re-run: use resume)
6
+ --label <text> free-form label shown in status
7
+ --detach start in the background, print the id, exit 0
8
+ --cwd <dir> workspace (default: current dir)
9
+ --owns <path> repeatable: paths this session may change (default: whole workspace)
10
+ --allow-overlap start despite overlapping ownership`;
11
+ const EXEC = ` --model <id> glm-5.3 | glm-5.3-flash (default glm-5.3-flash)
12
+ --effort <lvl> low | high | max (default max)
13
+ --mcp <json|@f> add MCP servers (repeatable) --mcp-inherit --no-browser
14
+ --skills <dir> add skills (repeatable) --no-skills
15
+ --json NDJSON events; last line is the compact brief (--full: whole record)
16
+ --quiet no progress feed; with --json only the result line`;
17
+
18
+ const HELP = {
19
+ task: `usage: zcode task [options] <objective...>
20
+ Execute a task contract, verify it with zcode-run checks, hand back.
21
+
22
+ contract:
23
+ --task <file|-> JSON contract or markdown brief (- = stdin)
24
+ --accept <text> repeatable acceptance criterion
25
+ --check <command> repeatable shell check zcode runs after the work
26
+ --file <path> repeatable file to read first
27
+ --context <text|@f> repeatable context --decision <text> / --decisions <@f>
28
+ --touch <path> repeatable minimal edits allowed outside --owns
29
+ --unit "n: goal [owns a,b]" repeatable subagent unit
30
+ --visual <criterion> repeatable --viewport <WxH> --serve <cmd>
31
+ --preflight read-only: list open questions, no edits
32
+ --max-repairs <n> default 2 --check-timeout-ms <n> default 300000
33
+ session:
34
+ ${SESSION}
35
+ --timeout-ms <n> whole invocation (default 7200000 = 2 h)
36
+ ${EXEC}
37
+ Full reference: zcode --help`,
38
+ run: `usage: zcode [run] [options] <prompt...>
39
+ One-shot turn (prompt via stdin when piped).
40
+ --session <id> continue a session
41
+ --timeout-ms <n> default 600000
42
+ --thinking | --no-thinking mirror reasoning to stderr
43
+ ${SESSION}
44
+ ${EXEC}
45
+ Full reference: zcode --help`,
46
+ agent: `usage: zcode agent [options] <instructions...>
47
+ Turn that MUST dispatch a subagent.
48
+ --profile <name> subagent profile (default general-purpose)
49
+ --timeout-ms <n> default 600000
50
+ ${SESSION}
51
+ ${EXEC}
52
+ Full reference: zcode --help`,
53
+ resume: `usage: zcode resume <id> [message...]
54
+ Continue a session: answer questions, follow up, or retry after a timeout.
55
+ Pass "-" as the message to read it from stdin.
56
+ --timeout-ms <n> --max-repairs <n> --check-timeout-ms <n>
57
+ --model <id> --effort <lvl>
58
+ --json --full --quiet --include-reasoning
59
+ Start-time flags (--id, --owns, --cwd, ...) cannot change on resume.`,
60
+ status: `usage: zcode status [<id>] [--all] [--json]
61
+ One session in detail, or the 15 most recent (--all: every session).
62
+ --json machine-readable list or detail`,
63
+ wait: `usage: zcode wait <id...> [--any] [--json] [--full] [--timeout-ms n]
64
+ Block until sessions finish; print each handback.
65
+ --any return when the FIRST id finishes
66
+ --timeout-ms <n> one line per pending session, exit 124
67
+ --json briefs --full full records`,
68
+ watch: `usage: zcode watch [<id>… | --plan <name>] [--stall-min n]
69
+ Live view of sessions, no model tokens. One line each:
70
+ id state phase[ n/m] elapsed last: <tool> <age> ago req <n> sub <running>
71
+ STALLED? is appended when a running session has had no engine event for
72
+ --stall-min minutes (default 8). Nothing is done automatically.
73
+ <id…> the sessions to watch
74
+ --plan name every session of a plan
75
+ (no args) live sessions and those finished in the last 10 minutes
76
+ TTY: redraws every 2 s. Piped: a line is printed only when a session changes.
77
+ Exits 0 when every watched session has finished; Ctrl-C exits 0.`,
78
+ result: `usage: zcode result <id> [--json] [--full]
79
+ Print a finished session's handback without waiting (exit 7 if running).
80
+ --json compact brief --full the whole record`,
81
+ cancel: `usage: zcode cancel <id> [--json]
82
+ Stop a running session (SIGTERM, SIGKILL after 15 s). No-op when finished.`,
83
+ plan: `usage: zcode plan <subcommand>
84
+ zcode plan check <file> validate (ids, after, cycles, owns, effort)
85
+ zcode plan run <file> [--preflight] [--detach] [--timeout-ms n] [--id name] [--json]
86
+ zcode plan wait|status <name> [--timeout-ms n] [--json]
87
+ zcode plan resume <name> [message...] [--session <id>]
88
+ plan file: {cwd?, decisions?, context?, files?, model?, effort?, limits?,
89
+ sessions:[{id, after?:[ids], label?, ...task contract keys}]}
90
+ top-level decisions/context/files are prepended to every session; limits
91
+ ({timeoutMs,maxRepairs,checkTimeoutMs}) default every session; "@file"
92
+ resolves next to the plan file, then against its cwd.
93
+ --preflight read-only preflight of every session (sessions with "after"
94
+ are told to assume their prerequisites)
95
+ --detach supervise in a background process
96
+ --id <name> plan name (default: file basename)
97
+ --cwd <dir> workspace override
98
+ Exit: 0 completed, 10 blocked, 8 partial/failed, 124 timeout.`,
99
+ login: `usage: zcode login
100
+ Read the API key from stdin and store it in the credentials file.`,
101
+ doctor: `usage: zcode doctor [--json]
102
+ Engine, isolation and key report.`,
103
+ skills: `usage: zcode skills [install|uninstall] [claude|codex]
104
+ Install the orchestrator skills (default: show status).`,
105
+ };
106
+
107
+ function commandHelp(command) {
108
+ return HELP[command] || null;
109
+ }
110
+
111
+ module.exports = { commandHelp };