@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/isolation.js CHANGED
@@ -19,6 +19,7 @@
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const os = require('node:os');
22
+ const { DEFAULT_MODEL } = require('./models.js');
22
23
 
23
24
  // The desktop app's live directory. ZAI_CODE_LIVE_DIR points elsewhere
24
25
  // (e.g. a second desktop profile, or a fixture in tests).
@@ -215,6 +216,19 @@ function isDanglingLink(p) {
215
216
  // Playwright into a dead end.
216
217
  const HOST_ONLY_SKILLS = new Set(['control-browser']);
217
218
 
219
+ // Rewrites the frontmatter `model:` of an agent profile when it names
220
+ // GLM-5.3 / GLM-5.3-Flash (any prefix: "account:x/GLM-5.3",
221
+ // "custom:account%3Ax:GLM-5.3") so its model part is `model`.
222
+ function pinAgentModel(text, model) {
223
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
224
+ if (!m) return text;
225
+ const front = m[2].replace(/^(\s*model\s*:\s*["']?)([^\n"']*?)(?:\/|:)GLM-5\.3(?:-Flash)?(["']?\s*)$/im, (all, a, prefix, tail) => {
226
+ const sep = all.slice(a.length + prefix.length, a.length + prefix.length + 1);
227
+ return `${a}${prefix}${sep}${model}${tail}`;
228
+ });
229
+ return m[1] + front + text.slice(m[0].length - m[3].length);
230
+ }
231
+
218
232
  function buildEngineHome({
219
233
  home,
220
234
  root,
@@ -225,6 +239,7 @@ function buildEngineHome({
225
239
  browser = true,
226
240
  skillsDirs = [],
227
241
  noSkills = false,
242
+ model = DEFAULT_MODEL,
228
243
  }) {
229
244
  const zdir = path.join(home, '.zcode');
230
245
  const v2 = path.join(zdir, 'v2');
@@ -263,7 +278,10 @@ function buildEngineHome({
263
278
  for (const entry of listDir(path.join(live, 'agents'))) {
264
279
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
265
280
  fs.mkdirSync(agentsDir, { recursive: true });
266
- fs.copyFileSync(path.join(live, 'agents', entry.name), path.join(agentsDir, entry.name));
281
+ // Profiles pinning a GLM-5.3 id would silently run another model than
282
+ // the session's: the copy follows the session (the live file is never edited).
283
+ const text = fs.readFileSync(path.join(live, 'agents', entry.name), 'utf8');
284
+ fs.writeFileSync(path.join(agentsDir, entry.name), pinAgentModel(text, model));
267
285
  }
268
286
  const agentsMd = path.join(zdir, 'AGENTS.md');
269
287
  if (fs.existsSync(path.join(live, 'AGENTS.md'))) {
@@ -393,6 +411,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env, sessi
393
411
 
394
412
  module.exports = {
395
413
  buildEngineHome,
414
+ pinAgentModel,
396
415
  engineEnv,
397
416
  rootDir,
398
417
  liveDir,
package/lib/models.js CHANGED
@@ -115,6 +115,45 @@ function modelLevels(opts) {
115
115
  return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportedEfforts(id, opts)]));
116
116
  }
117
117
 
118
+ // --- image input per model ---------------------------------------------------
119
+ //
120
+ // Same resolution as reasoning levels: modelRules[].config.properties
121
+ // .inputFormat.supportsImage, last matching rule that sets it wins (the
122
+ // generic `glm-5.3(-flash)?` rule says false, the later `glm-5.3-flash` rule
123
+ // true). GLM-5.3 cannot see images: reading a PNG returns only an upload
124
+ // confirmation, so it must never be the judge of a screenshot.
125
+ const FALLBACK_IMAGES = { 'GLM-5.3': false, 'GLM-5.3-Flash': true };
126
+
127
+ function supportsImage(model, { env = process.env, builtinConfig } = {}) {
128
+ const file = builtinConfig || builtinConfigPath(env);
129
+ let value = null;
130
+ if (file) {
131
+ for (const rule of readRules(file)) {
132
+ let match = false;
133
+ try {
134
+ match = new RegExp(`^(?:${rule.modelMatch})$`, 'i').test(model);
135
+ } catch {
136
+ /* a malformed pattern matches nothing */
137
+ }
138
+ if (!match) continue;
139
+ const v = rule.config && rule.config.properties && rule.config.properties.inputFormat
140
+ && rule.config.properties.inputFormat.supportsImage;
141
+ if (typeof v === 'boolean') value = v;
142
+ }
143
+ }
144
+ return value === null ? FALLBACK_IMAGES[model] === true : value;
145
+ }
146
+
147
+ // {modelId: bool} for every supported model (doctor).
148
+ function modelVision(opts) {
149
+ return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportsImage(id, opts)]));
150
+ }
151
+
152
+ // The first image-capable model: judges screenshots for the visual verifier.
153
+ function visionModel(opts) {
154
+ return OFFICIAL_IDS.find((id) => supportsImage(id, opts)) || DEFAULT_MODEL;
155
+ }
156
+
118
157
  // null when `effort` is usable with `model`, else the one-line refusal.
119
158
  // `source` names where the value came from ("--effort", "contract effort").
120
159
  function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
@@ -124,6 +163,9 @@ function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
124
163
  }
125
164
 
126
165
  module.exports = {
166
+ supportsImage,
167
+ modelVision,
168
+ visionModel,
127
169
  supportedEfforts,
128
170
  modelLevels,
129
171
  effortProblem,
package/lib/plan.js CHANGED
@@ -22,7 +22,7 @@ const { normalizeModel, normalizeEffort, effortProblem, unknownModelMessage, DEF
22
22
  const { buildBrief } = require('./brief.js');
23
23
 
24
24
  const BIN = path.join(__dirname, '..', 'bin', 'zcode.js');
25
- const PLAN_KEYS = ['cwd', 'decisions', 'context', 'files', 'model', 'effort', 'sessions'];
25
+ const PLAN_KEYS = ['cwd', 'decisions', 'context', 'files', 'model', 'effort', 'limits', 'sessions'];
26
26
  const POLL_MS = 500;
27
27
 
28
28
  class PlanError extends Error {}
@@ -65,10 +65,10 @@ const sessionList = (plan) => (Array.isArray(plan.sessions) ? plan.sessions.filt
65
65
  const afterOf = (s) => toArray(s.after).map(String);
66
66
 
67
67
  // The session's contract object: top-level decisions/context/files first,
68
- // the plan's model/effort as defaults. `id` and `after` are plan keys, not
69
- // contract keys.
68
+ // the plan's model/effort/limits as defaults (a session's own limits win per
69
+ // key). `id`, `after` and `label` are plan keys, not contract keys.
70
70
  function sessionBase(r, s) {
71
- const { id, after, ...rest } = s;
71
+ const { id, after, label, ...rest } = s;
72
72
  const top = r.plan;
73
73
  const base = { ...rest };
74
74
  for (const key of ['decisions', 'context', 'files']) {
@@ -78,6 +78,9 @@ function sessionBase(r, s) {
78
78
  for (const key of ['model', 'effort']) {
79
79
  if (base[key] === undefined && top[key] !== undefined) base[key] = top[key];
80
80
  }
81
+ if (top.limits && typeof top.limits === 'object' && !Array.isArray(top.limits)) {
82
+ base.limits = { ...top.limits, ...(rest.limits && typeof rest.limits === 'object' ? rest.limits : {}) };
83
+ }
81
84
  return base;
82
85
  }
83
86
 
@@ -88,6 +91,9 @@ function checkPlan(r, { name, state } = {}) {
88
91
  const { plan } = r;
89
92
  const unknown = Object.keys(plan).filter((k) => !PLAN_KEYS.includes(k));
90
93
  if (unknown.length) problems.push(`unknown plan key(s): ${unknown.join(', ')}. Accepted: ${PLAN_KEYS.join(', ')}`);
94
+ if (plan.limits !== undefined && (!plan.limits || typeof plan.limits !== 'object' || Array.isArray(plan.limits))) {
95
+ problems.push('plan "limits" must be an object (timeoutMs, maxRepairs, checkTimeoutMs).');
96
+ }
91
97
  if (!Array.isArray(plan.sessions) || plan.sessions.length === 0) {
92
98
  problems.push('plan needs a non-empty "sessions" array.');
93
99
  return problems;
@@ -115,6 +121,7 @@ function checkPlan(r, { name, state } = {}) {
115
121
  problems.push(`duplicate session id "${s.id}".`);
116
122
  }
117
123
  if (typeof s.id === 'string') ids.push(s.id);
124
+ if (s.label !== undefined && typeof s.label !== 'string') problems.push(`${label}: "label" must be a string.`);
118
125
  if (s.after !== undefined && !toArray(s.after).every((a) => typeof a === 'string')) {
119
126
  problems.push(`${label}: "after" must be a list of session ids.`);
120
127
  }
@@ -212,11 +219,16 @@ function checkPlan(r, { name, state } = {}) {
212
219
 
213
220
  // Ids already taken by sessions that are not this plan's.
214
221
  const mine = new Set(state && Array.isArray(state.sessions) ? state.sessions : []);
222
+ let taken = false;
215
223
  for (const id of known) {
216
224
  if (store.readSession(id) && !mine.has(id)) {
217
- problems.push(`session "${id}" already exists (zcode status ${id}); a plan needs new ids, or continue its own sessions with "zcode plan resume ${name || '<name>'}".`);
225
+ taken = true;
226
+ problems.push(`session "${id}" already exists (zcode status ${id}).`);
218
227
  }
219
228
  }
229
+ if (taken) {
230
+ problems.push(`hint: use a fresh id prefix for a new run (e.g. "<prefix>-<id>"), or "zcode plan resume ${name || '<name>'}" to continue that plan's sessions.`);
231
+ }
220
232
  return problems;
221
233
  }
222
234
 
@@ -289,7 +301,10 @@ function planReport(name, r, state, { briefs = true } = {}) {
289
301
  type: 'plan',
290
302
  name,
291
303
  status,
292
- sessions: sessionList(r.plan).map((s) => ({ id: s.id, status: statuses.get(s.id).status })),
304
+ sessions: sessionList(r.plan).map((s) => {
305
+ const e = statuses.get(s.id);
306
+ return { id: s.id, status: e.status, ...(e.view ? store.idleInfo(e.view) : null) };
307
+ }),
293
308
  };
294
309
  if (briefs) {
295
310
  payload.briefs = [];
@@ -309,13 +324,32 @@ function exec() {
309
324
  return require('./commands/exec.js');
310
325
  }
311
326
 
327
+ // Preflights run all at once on the current workspace, so a session with
328
+ // `after` would see none of its prerequisites' files. This paragraph tells it
329
+ // to assume them (ids + clipped objectives) instead of asking about them.
330
+ function preflightContext(r, s) {
331
+ const upstream = afterOf(s).map((a) => sessionList(r.plan).find((x) => x.id === a)).filter(Boolean);
332
+ if (!upstream.length) return null;
333
+ const clip = (t) => {
334
+ const x = String(t || '').replace(/\s+/g, ' ').trim();
335
+ return x.length <= 200 ? x : `${x.slice(0, 199)}…`;
336
+ };
337
+ const list = upstream.map((u) => `- ${u.id}: ${clip(sessionBase(r, u).objective) || '(no objective)'}`).join('\n');
338
+ return `This is a plan preflight. Sessions ${upstream.map((u) => u.id).join(', ')} run before you and have not run yet; their files will exist when you execute. Assume they deliver what the binding decisions and their objectives say:\n${list}\nDo not ask about their absence; ask only about what the decisions leave open for YOUR scope.`;
339
+ }
340
+
312
341
  // The detached-start invocation for one plan session (id overrides the plan
313
- // id: preflight uses "<id>.pre").
342
+ // id: preflight uses "<id>.pre"). The label is `plan:<name>` plus the
343
+ // session's own `label`, if any.
314
344
  function sessionInvocation(r, name, s, { id = s.id, preflight = false } = {}) {
315
345
  const { parseArgv } = require('../bin/zcode.js');
316
- const argv = ['task', '--id', id, '--cwd', r.cwd, '--label', `plan:${name}`];
346
+ const label = `plan:${name}${typeof s.label === 'string' && s.label.trim() ? ` ${s.label.trim()}` : ''}`;
347
+ const argv = ['task', '--id', id, '--cwd', r.cwd, '--label', label];
317
348
  if (preflight) argv.push('--preflight');
318
- return exec().buildInvocation(parseArgv(argv), { base: sessionBase(r, s), dir: r.dir });
349
+ const base = sessionBase(r, s);
350
+ const note = preflight ? preflightContext(r, s) : null;
351
+ if (note) base.context = [...toArray(base.context), note];
352
+ return exec().buildInvocation(parseArgv(argv), { base, dir: r.dir });
319
353
  }
320
354
 
321
355
  function launchSession(r, name, s) {
@@ -415,6 +449,7 @@ module.exports = {
415
449
  planReport,
416
450
  launchFailure,
417
451
  sessionInvocation,
452
+ preflightContext,
418
453
  launchSession,
419
454
  resumeSession,
420
455
  supervise,
package/lib/store.js CHANGED
@@ -175,6 +175,18 @@ function viewSession(record) {
175
175
  return { ...record, live: ACTIVE_STATES.has(record.state) };
176
176
  }
177
177
 
178
+ // How long a running session has gone without any engine event
179
+ // ({idleMs, stalled?}); null unless it is running. Informational only: zcode
180
+ // never acts on it. The same rule everywhere (status, wait, watch).
181
+ const STALL_MIN = 8;
182
+ function idleInfo(view, { stallMin = STALL_MIN, now = Date.now() } = {}) {
183
+ if (!view || view.state !== 'running') return null;
184
+ const t = Date.parse((view.activity && view.activity.lastEventAt) || view.updatedAt);
185
+ if (!Number.isFinite(t)) return null;
186
+ const idleMs = Math.max(0, now - t);
187
+ return idleMs >= stallMin * 60000 ? { idleMs, stalled: true } : { idleMs };
188
+ }
189
+
178
190
  function listSessions(env = process.env) {
179
191
  let names = [];
180
192
  try {
@@ -312,6 +324,8 @@ module.exports = {
312
324
  readEvents,
313
325
  pidAlive,
314
326
  viewSession,
327
+ idleInfo,
328
+ STALL_MIN,
315
329
  listSessions,
316
330
  resolveSessionId,
317
331
  claimSession,
package/lib/taskspec.js CHANGED
@@ -61,38 +61,45 @@ function validateUnits(contract, cwd) {
61
61
  }
62
62
 
63
63
  // base: parsed contract object; baseDir: where its "@file" entries resolve
64
- // (the contract file's directory, else cwd). additions: the CLI flags
64
+ // (the contract file's directory, else cwd), then `cwd` when not found there. additions: the CLI flags
65
65
  // (strings; "@file" resolves against the process cwd). cwd: --cwd.
66
66
  function buildContract({ base = {}, baseDir = process.cwd(), objective = '', additions = {}, cwd = process.cwd() }) {
67
67
  const inlined = new Set();
68
- const inline = (list, dir, label) =>
68
+ // dirs are tried in order; the error names every path tried.
69
+ const inline = (list, dirs, label) =>
69
70
  toArray(list).map((item) => {
70
71
  const text = String(item).trim();
71
72
  const m = FILE_REF.exec(text);
72
73
  if (!m) return text;
73
- const file = path.resolve(dir, m[1]);
74
- try {
75
- const body = fs.readFileSync(file, 'utf8');
76
- inlined.add(canon(file));
77
- return body;
78
- } catch {
79
- throw new ContractError(`${label} file "${m[1]}" could not be read.`);
74
+ const tried = [];
75
+ for (const dir of dirs) {
76
+ const file = path.resolve(dir, m[1]);
77
+ if (tried.includes(file)) continue;
78
+ tried.push(file);
79
+ try {
80
+ const body = fs.readFileSync(file, 'utf8');
81
+ inlined.add(canon(file));
82
+ return body;
83
+ } catch {
84
+ /* try the next directory */
85
+ }
80
86
  }
87
+ throw new ContractError(`${label} file "${m[1]}" could not be read (tried ${tried.join(', ')}).`);
81
88
  });
82
89
 
83
90
  let b = base;
84
91
  if (b && typeof b === 'object' && !Array.isArray(b)) {
85
92
  b = { ...b };
86
93
  for (const key of ['context', 'decisions']) {
87
- if (b[key] !== undefined) b[key] = inline(b[key], baseDir, `contract ${key}`);
94
+ if (b[key] !== undefined) b[key] = inline(b[key], [baseDir, cwd], `contract ${key}`);
88
95
  }
89
96
  }
90
97
  const flagDir = process.cwd();
91
98
  const contract = normalizeContract(b, {
92
99
  ...additions,
93
100
  objective,
94
- context: inline(additions.context, flagDir, '--context'),
95
- decisions: inline(additions.decisions, flagDir, '--decisions'),
101
+ context: inline(additions.context, [flagDir], '--context'),
102
+ decisions: inline(additions.decisions, [flagDir], '--decisions'),
96
103
  });
97
104
  // A file the executor already gets inlined is not also listed to read.
98
105
  if (inlined.size) {
package/lib/ui.js CHANGED
@@ -84,6 +84,7 @@ function makeReporter({ quiet = false, thinking = true, stream = process.stderr,
84
84
  const extra =
85
85
  ev.phase === 'repairing' ? ` ${ev.attempt}/${ev.of}`
86
86
  : ev.phase === 'checking' ? ` ${ev.count}`
87
+ : ev.phase === 'verifying' ? ` round ${ev.round}`
87
88
  : ev.reason ? ` (${ev.reason})` : '';
88
89
  line(`${c.cyan('●')} ${ev.phase}${extra}`);
89
90
  return;
@@ -186,7 +187,7 @@ function formatResult(r, { color = false } = {}) {
186
187
  ['questions', (r.questions || []).map(questionText)],
187
188
  ['decisions', (r.decisions || []).map((d) => `${d.what}${d.why ? ` (${d.why})` : ''}`)],
188
189
  ['units', (r.units || []).map((u) => `${u.name}: ${u.status || '?'}${u.note ? ` - ${u.note}` : ''}`)],
189
- ['visual', (r.visual || []).map((v) => `${v.met === true ? 'met' : 'UNMET'} ${v.criterion || ''}${v.viewport ? ` @${v.viewport}` : ''}${v.note ? ` - ${v.note}` : ''}`)],
190
+ ['visual', (r.visual || []).map((v) => `${v.met === true ? 'met' : v.met === null ? 'NOT VERIFIED' : 'UNMET'} ${v.criterion || ''}${v.viewport ? ` @${v.viewport}` : ''}${v.note ? ` - ${v.note}` : ''}`)],
190
191
  ['unresolved', r.unresolved],
191
192
  ['not verified', r.notVerified],
192
193
  ]) {
@@ -211,17 +212,26 @@ function formatSessionRow(s) {
211
212
  return `${s.id.padEnd(18)} ${String(state).padEnd(11)} ${String(s.kind || '').padEnd(5)} ${when} ${s.label || s.cwd || ''}`;
212
213
  }
213
214
 
215
+ // `repairing 1/2`, `verifying round 2`, or the bare phase.
216
+ function phaseText(v) {
217
+ const d = v.phaseDetail;
218
+ if (d && d.attempt !== undefined) return `${v.phase} ${d.attempt}/${d.of}`;
219
+ if (d && d.round !== undefined) return `${v.phase} round ${d.round}`;
220
+ return v.phase;
221
+ }
222
+
214
223
  function formatSessionView(v, { events = [] } = {}) {
215
224
  const L = [];
216
225
  const state = v.state === 'finished' ? `finished (${v.status})` : v.state;
217
226
  L.push(`${v.id} ${v.kind}${v.label ? ` ${v.label}` : ''}`);
218
- L.push(` state ${state}${v.live ? ` · phase ${v.phase} · pid ${v.pid}` : ''}`);
227
+ L.push(` state ${state}${v.live ? ` · phase ${phaseText(v)} · pid ${v.pid}` : ''}`);
219
228
  L.push(` cwd ${v.cwd}`);
220
229
  if (v.owns && v.owns.length) L.push(` owns ${v.owns.join(', ')}`);
221
230
  L.push(` model ${v.model} · effort ${v.effort} · turns ${v.turns || 0}`);
222
231
  if (v.activity) {
223
232
  L.push(` activity ${v.activity.toolCalls || 0} tool calls · ${v.activity.subagentsStarted || 0} subagents${v.activity.lastTool ? ` · last ${v.activity.lastTool}` : ''} · ${v.activity.lastEventAt || ''}`);
224
233
  }
234
+ if (v.idleMs !== undefined) L.push(` idle ${Math.round(v.idleMs / 1000)}s since the last engine event${v.stalled ? ' STALLED?' : ''}`);
225
235
  if (v.state === 'interrupted') L.push(` the owning process is gone; continue with: zcode resume ${v.id}`);
226
236
  if (events.length) {
227
237
  L.push(' recent');
@@ -236,4 +246,4 @@ function formatSessionView(v, { events = [] } = {}) {
236
246
  return L.join('\n') + '\n';
237
247
  }
238
248
 
239
- module.exports = { makeReporter, formatResult, formatSessionRow, formatSessionView, useColor };
249
+ module.exports = { phaseText, makeReporter, formatResult, formatSessionRow, formatSessionView, useColor };
package/lib/verify.js ADDED
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ // The independent visual verifier (v0.6). The implementer never decides
3
+ // `visual`: after its turn zcode opens a fresh engine session on an
4
+ // image-capable model that did not build the result, has it screenshot the
5
+ // running app and LOOK at the pixels, and parses its `zcode-visual` block.
6
+ // This file is the prompt and the (lenient) parser; the orchestration is in
7
+ // lib/executor.js.
8
+ const { DEFAULT_VIEWPORTS } = require('./contract.js');
9
+
10
+ // Always judged in addition to the declared criteria.
11
+ const BUILTIN_CRITERION =
12
+ 'All text the page means to show is readable after animations finish: not clipped, hidden, transparent, off-screen or overlapped.';
13
+
14
+ const bullets = (items) => items.map((i) => `- ${i}`).join('\n');
15
+
16
+ const clip = (text, max) => {
17
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
18
+ return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
19
+ };
20
+
21
+ const WAIT_SCRIPT =
22
+ 'await document.fonts.ready; await Promise.all(document.getAnimations().map(a => a.finished.catch(() => {})))';
23
+
24
+ // contract: the task contract; session: {id, port, out, tmp}; round: 1-based.
25
+ function buildVerifierPrompt(contract, { session, round = 1 }) {
26
+ const viewports = contract.viewports && contract.viewports.length ? contract.viewports : DEFAULT_VIEWPORTS;
27
+ const dir = `${session.out}/verify/${round}`;
28
+ return [
29
+ 'You are an independent visual verifier. You did not build this and have not seen it. Assume it is broken until the pixels prove otherwise. DOM or text presence is NOT evidence: only what you can see in a screenshot counts.',
30
+ '',
31
+ `## What was built\n${clip(contract.objective, 500)}`,
32
+ '',
33
+ '## Criteria',
34
+ bullets([...contract.visual, BUILTIN_CRITERION]),
35
+ '',
36
+ '## How',
37
+ contract.serve
38
+ ? `- Serve the app with \`${contract.serve}\` on $ZCODE_PORT=${session.port} (build first if it needs one; put build output under $ZCODE_OUT=${session.out}, not in the workspace).`
39
+ : `- Serve the app on $ZCODE_PORT=${session.port} (pick the dev/preview command from package.json, build first if needed; put build output under $ZCODE_OUT=${session.out}, not in the workspace).`,
40
+ `- For every viewport (${viewports.join(', ')}): navigate, wait for fonts and for all animations to finish (\`${WAIT_SCRIPT}\`, capped at about 8 s) plus 500 ms, then capture a viewport screenshot and a full-page screenshot into ${dir}/ (names <viewport>-<n>.png, plus a named screenshot for every state a criterion requires).`,
41
+ '- Then LOOK at each image (read the PNG file). Judge every criterion at every viewport. For each verdict QUOTE the text you can actually read in the relevant region (`seen`); if you read none, say so.',
42
+ `- Make NO edits: write only files under ${session.out}. Stop any server you started.`,
43
+ '',
44
+ '## Reply',
45
+ 'End with exactly one fenced block, a JSON array with one entry per criterion per viewport:',
46
+ '```zcode-visual',
47
+ '[{"criterion": "text of the criterion", "viewport": "1440x900", "met": true, "seen": "text you read there", "note": "what is wrong, if anything", "shot": "path of the screenshot"}]',
48
+ '```',
49
+ ].join('\n');
50
+ }
51
+
52
+ const BLOCK_RE = /```zcode-visual[^\n]*\n([\s\S]*?)```/g;
53
+
54
+ function normMet(v) {
55
+ if (v === true || v === false) return v;
56
+ if (typeof v === 'string') {
57
+ const t = v.trim().toLowerCase();
58
+ if (['true', 'yes', 'met', 'pass'].includes(t)) return true;
59
+ if (['false', 'no', 'unmet', 'not met', 'fail'].includes(t)) return false;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ // {entries: [{criterion, viewport?, met: true|false|null, seen, note, shot}],
65
+ // error}. The LAST block wins; an unparsable or empty block is an error.
66
+ function parseVisualBlock(text) {
67
+ const matches = [...String(text || '').matchAll(BLOCK_RE)];
68
+ if (!matches.length) return { entries: [], error: 'no zcode-visual block' };
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(matches[matches.length - 1][1]);
72
+ } catch (err) {
73
+ return { entries: [], error: `invalid JSON: ${err.message}` };
74
+ }
75
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
76
+ raw = Array.isArray(raw.visual) ? raw.visual : Array.isArray(raw.entries) ? raw.entries : [raw];
77
+ }
78
+ if (!Array.isArray(raw)) return { entries: [], error: 'not a JSON array' };
79
+ const entries = raw
80
+ .filter((e) => e && typeof e === 'object' && !Array.isArray(e) && e.criterion !== undefined)
81
+ .map((e) => ({
82
+ criterion: String(e.criterion),
83
+ ...(e.viewport ? { viewport: String(e.viewport) } : {}),
84
+ met: normMet(e.met),
85
+ seen: e.seen === undefined || e.seen === null ? '' : String(e.seen),
86
+ note: e.note === undefined || e.note === null ? '' : String(e.note),
87
+ ...(e.shot ? { shot: String(e.shot) } : {}),
88
+ }));
89
+ return entries.length ? { entries, error: null } : { entries: [], error: 'no usable entries' };
90
+ }
91
+
92
+ // Entries for criteria that could not be judged: met:null, never met:false
93
+ // (a verifier failure is not the implementer's fault).
94
+ function notVerified(criteria, why) {
95
+ return criteria.map((criterion) => ({ criterion, met: null, seen: '', note: `not verified: ${why}` }));
96
+ }
97
+
98
+ module.exports = { buildVerifierPrompt, parseVisualBlock, notVerified, BUILTIN_CRITERION };
package/lib/workspace.js CHANGED
@@ -337,7 +337,37 @@ function diffSnapshots(before, after) {
337
337
  return out;
338
338
  }
339
339
 
340
+ // Keeps zcode's own scratch dirs out of the target repo's `git status`:
341
+ // appends missing entries to the repo-local exclude file (never .gitignore;
342
+ // `--git-path info/exclude` also resolves for worktrees). Idempotent,
343
+ // append-only, silent: every error is ignored.
344
+ const LOCAL_EXCLUDES = ['.zcode/', '.playwright-mcp/'];
345
+ function ensureLocalExclude(cwd) {
346
+ try {
347
+ const rel = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-path', 'info/exclude'], {
348
+ encoding: 'utf8',
349
+ stdio: ['ignore', 'pipe', 'ignore'],
350
+ }).trim();
351
+ if (!rel) return;
352
+ const file = path.resolve(cwd, rel);
353
+ let text = '';
354
+ try {
355
+ text = fs.readFileSync(file, 'utf8');
356
+ } catch {
357
+ /* no exclude file yet */
358
+ }
359
+ const have = new Set(text.split(/\r?\n/).map((l) => l.trim().replace(/^\//, '').replace(/\/$/, '')));
360
+ const missing = LOCAL_EXCLUDES.filter((e) => !have.has(e.replace(/\/$/, '')));
361
+ if (!missing.length) return;
362
+ fs.mkdirSync(path.dirname(file), { recursive: true });
363
+ fs.appendFileSync(file, `${text && !text.endsWith('\n') ? '\n' : ''}${missing.join('\n')}\n`);
364
+ } catch {
365
+ /* hygiene is best-effort */
366
+ }
367
+ }
368
+
340
369
  module.exports = {
370
+ ensureLocalExclude,
341
371
  OwnershipError,
342
372
  workspaceRoot,
343
373
  normalizeOwns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xgeorgemathew/zcode",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Lean GLM-5.3 execution engine for orchestrator agents: task contracts, independent sessions, zcode-run verification, structured handbacks, subagents, MCP, skills. Wraps the ZCode engine. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,22 +1,22 @@
1
1
  ---
2
2
  name: zcode
3
- description: Delegate scoped implementation work to the zcode CLI (GLM-5.3 executor, vision-capable, has Playwright) and consume its compact verified handback. Use when you are orchestrating: you make the product, design and logic decisions once, then zcode implements, tests, visually verifies and repairs — in one session with subagent units, several parallel sessions, or a dependency plan — while you spend tokens only on decisions and exceptions.
3
+ description: Delegate scoped implementation work to the zcode CLI (GLM-5.3-Flash executor: sees images, has Playwright; an independent vision verifier judges UI) and consume its compact verified handback. Use when you are orchestrating: you make the product, design and logic decisions once, then zcode implements, tests, visually verifies and repairs — in one session with subagent units, several parallel sessions, or a dependency plan — while you spend tokens only on decisions and exceptions.
4
4
  ---
5
5
 
6
6
  # Operating zcode as an executor
7
7
 
8
- zcode is a CLI execution engine. You decide; it builds. It implements a scoped task in its own session, runs your checks itself, screenshots and judges UI with its own vision when you ask, repairs failures within a budget, and returns a small structured handback.
8
+ zcode is a CLI execution engine. You decide; it builds. It implements a scoped task in its own session, runs your checks itself, has a fresh, independent vision verifier screenshot and judge UI when you ask, repairs failures within a budget, and returns a small structured handback.
9
9
 
10
10
  **The point is token economy.** Your tokens go to decisions and exceptions. Everything mechanical — writing code, running builds and tests, reading diffs, looking at screenshots, fixing small failures — is zcode's. If you are doing any of that after dispatch, you are paying twice.
11
11
 
12
- Check readiness once per machine: `zcode doctor` (exit 3 → `zcode login`; exit 4 → engine missing). Don't run `--help`: everything you need is here.
12
+ Check readiness once per machine: `zcode doctor` (exit 3 → `zcode login`; exit 4 → engine missing). Don't run `--help` or a bare `zcode status`: everything you need is here.
13
13
 
14
14
  ## The workflow
15
15
 
16
16
  1. **Read** the codebase as much as you need to plan. This is the one phase where reading files is expected.
17
17
  2. **Decide** everything a user could see or a system depends on (checklists below). Write the decisions down once, as decisions — a file such as `docs/decisions.md` that sessions receive with `--decisions @file`.
18
18
  3. **Shape** the work: one session with units, several sessions, or a plan (next section).
19
- 4. **Preflight** (optional, recommended for anything large or ambiguous): `--preflight` makes zcode list every question it would otherwise guess. Answer them all in one edit to the decisions file.
19
+ 4. **Preflight** (optional): `--preflight` makes zcode list every question it would otherwise guess; answer them all in one edit to the decisions file. It costs you a round trip, so use it only when your decisions have known gaps or the work is large and novel; skip it when the checklists below are fully covered.
20
20
  5. **Dispatch and wait** with `--detach` + `zcode wait`, or `zcode plan run` + `zcode plan wait`.
21
21
  6. **Handle exceptions only.** `completed` → move on. Anything else → read only what the brief shows, answer or adjust in one line with `zcode resume`.
22
22
 
@@ -30,7 +30,7 @@ The executor implements binding decisions exactly and is told not to invent anyt
30
30
 
31
31
  **Boundaries:** what the executor may choose freely (internal names, file structure, helpers) and what it must ask about.
32
32
 
33
- Write decisions as statements, not wishes: "Reveal: opacity 0→1 and translateY 16px→0, 400 ms, cubic-bezier(.2,.7,.2,1), 60 ms stagger, at 20% visibility; reduced motion: opacity only."
33
+ Keep the decisions file dense: tables and one-line statements, no prose, no rationale the executor doesn't need (aim for a few thousand tokens for a one-page site). Write decisions as statements, not wishes: "Reveal: opacity 0→1 and translateY 16px→0, 400 ms, cubic-bezier(.2,.7,.2,1), 60 ms stagger, at 20% visibility; reduced motion: opacity only."
34
34
 
35
35
  ## Shaping the work
36
36
 
@@ -40,7 +40,7 @@ Pick the cheapest shape that keeps each session finishable without you.
40
40
  - **Several sessions** when pieces are independent and own disjoint paths. Launch them `--detach` in parallel; each may have its own units.
41
41
  - **A plan** when there are dependencies (scaffold → parallel features → integration). `zcode plan run plan.json` sequences and parallelises for you; you wait once.
42
42
 
43
- Parallel safety is automatic: each session gets `ZCODE_PORT`, `ZCODE_OUT`, `ZCODE_TMP` and `ZCODE_SESSION_ID`. Tell the first (scaffold) session to make dev-server ports, build output dirs and test artifacts honour them. Target repos should ignore `.zcode/`.
43
+ Parallel safety is automatic: each session gets `ZCODE_PORT`, `ZCODE_OUT`, `ZCODE_TMP` and `ZCODE_SESSION_ID`. Tell the first (scaffold) session to make dev-server ports, build output dirs and test artifacts honour them. zcode keeps `.zcode/` and `.playwright-mcp/` out of git via the repo's local `.git/info/exclude`; you don't need to touch `.gitignore` for them.
44
44
 
45
45
  ## The handoff
46
46
 
@@ -49,7 +49,7 @@ Parallel safety is automatic: each session gets `ZCODE_PORT`, `ZCODE_OUT`, `ZCOD
49
49
  | objective | positional text | always: the outcome, not the steps |
50
50
  | decisions | `--decision "…"`, `--decisions @file` | always for anything user-visible or behavioural |
51
51
  | checks | `--check "cmd"` | always: build, typecheck, tests, lint, greps — zcode runs them itself |
52
- | visual | `--visual "criterion"`, `--viewport WxH`, `--serve "cmd"` | any UI work: zcode serves on $ZCODE_PORT, screenshots, judges and repairs |
52
+ | visual | `--visual "criterion"`, `--viewport WxH`, `--serve "cmd"` | any UI work: after the build, a fresh vision verifier serves on $ZCODE_PORT, waits for animations to finish, screenshots each viewport, quotes the text it can actually see, judges each criterion (plus "all intended text is readable"), and unmet ones go back to the implementer for repair. Phrase criteria as what must be visible: "hero headline 'Your money, simplified' is fully readable at 390 and 1440" |
53
53
  | units | `--unit "name: objective [owns a,b]"` | one session should fan out to subagents |
54
54
  | owns | `--owns path/glob` | always when other sessions may run in the same repo |
55
55
  | touch | `--touch path` | a small edit to a shared file no running session owns |
@@ -58,7 +58,7 @@ Parallel safety is automatic: each session gets `ZCODE_PORT`, `ZCODE_OUT`, `ZCOD
58
58
  | acceptance | `--accept "…"` | criteria beyond checks and visuals |
59
59
  | everything | `--task contract.json` | keys: objective, scope, constraints, decisions, design, acceptance, checks, visual, viewports, units, files, references, context, owns, touch, limits, model, effort |
60
60
 
61
- Model and effort: `--model glm-5.3` for real work (`flash` for trivial edits); `--effort low|high|max` (default `max`). Other values are refused before anything starts.
61
+ Model and effort: don't pass `--model`. Everything runs on GLM-5.3-Flash by default (it sees images): sessions, units, plans, the verifier. Use `--model glm-5.3` only when the user asks for it; it can't see images. `--effort low|high|max` (default `max`). Other values are refused before anything starts.
62
62
 
63
63
  ## Running
64
64
 
@@ -81,13 +81,17 @@ zcode plan resume <name> ["<message>"] # resume failed/partial, rel
81
81
  zcode plan resume <name> --session <id> "<answer>" # answer one blocked session
82
82
  ```
83
83
 
84
- `plan.json`: `{"cwd"?, "decisions"?, "context"?, "files"?, "model"?, "effort"?, "sessions": [{"id", "after"?: [ids], ...contract keys}]}`. Top-level decisions/context/files go to every session; `@` entries are files relative to the plan. The plan name is the file basename (or `--id`). Sessions that can run at the same time must own disjoint paths; `plan check` enforces it. When an upstream session ends non-completed, its dependants are `skipped` until you `plan resume`.
84
+ `plan.json`: `{"cwd"?, "decisions"?, "context"?, "files"?, "model"?, "effort"?, "limits"?, "sessions": [{"id", "after"?: [ids], "label"?, ...contract keys}]}`. Top-level decisions/context/files/limits go to every session; `@` entries resolve against the plan file's directory, then the plan's cwd. Don't also list the decisions file under `files`: it is already inlined. The plan name is the file basename (or `--id`). Sessions that can run at the same time must own disjoint paths; `plan check` enforces it. When an upstream session ends non-completed, its dependants are `skipped` until you `plan resume`.
85
+
86
+ Session ids are global and permanent: give every new run a fresh prefix (`wc3-scaffold`, …) so `plan check` passes first time. A task session may run 2 h by default; a big session (a whole page, a whole flow) can set `"limits": {"timeoutMs": 10800000}`. If one still times out, `zcode plan resume <name> "continue"` picks up from the files it already wrote.
87
+
88
+ While sessions run, the user can watch them with `zcode watch` (live phase, last tool, model requests, `STALLED?` after 8 idle minutes); you never run it. `wait` timeout lines carry `idleMs`/`stalled`: a stalled session is the one case to `cancel` + `resume` early.
85
89
 
86
90
  Your Bash tool times out at 10 minutes; always wait in bounded slices. Run the wait with `run_in_background` if you have other planning to do.
87
91
 
88
92
  ## Reading the brief
89
93
 
90
- `--json` prints one compact line: `status`, `summary`, `files`, failing check tails, `questions`, `issues`, `unresolved`, `scopeViolations`, `touched`, `decisions`, `visual`, `units`, and `result` (path to the full record).
94
+ `--json` prints one compact line: `status`, `summary`, `files`, failing `checks` with tails (`checksPassed` counts the rest), `questions`, `issues`, `unresolved`, `scopeViolations`, `touched`, the first `decisions` (`decisionsMore` counts the rest), unmet `visual` with what the verifier actually `seen` (`visualMet` is "met/total"), `units`, and `result` (path to the full record). Read it straight from the command output; don't pipe it through jq, python or tail.
91
95
 
92
96
  | status | exit | do |
93
97
  |---|---|---|
@@ -101,7 +105,7 @@ Your Bash tool times out at 10 minutes; always wait in bounded slices. Run the w
101
105
  ## Hard rules after dispatch
102
106
 
103
107
  - Don't open files a `completed` session changed. Don't re-run checks it ran. Don't look at its screenshots.
104
- - Don't poll `status`; use `wait`.
108
+ - Don't poll `status`; use `wait` (or `plan wait`) once, in the background if you like, and act on its exit.
105
109
  - Don't fix code yourself; `resume` the session with one line.
106
110
  - Need to know something about the result? `zcode result <id> --full` first; read code only as a last resort.
107
111
  - Don't ask zcode to commit, push, publish or deploy unless the user asked; when they did, make it its own final session gated on everything else being `completed`.
@@ -113,7 +117,7 @@ You read the empty repo, decide the design system, copy, motion, form logic and
113
117
  ```json
114
118
  {
115
119
  "decisions": ["@docs/decisions.md"],
116
- "model": "glm-5.3", "effort": "high",
120
+ "effort": "high",
117
121
  "sessions": [
118
122
  {"id": "scaffold", "owns": ["package.json", "package-lock.json", "index.html", "*.config.*", "tsconfig*.json", "src/app/**", "src/styles/**", "src/components/**", "e2e/smoke.spec.ts", ".gitignore"],
119
123
  "objective": "Vite + React + TS scaffold, tokens, shared shell, stubs for src/landing and src/apply; dev/preview/e2e honour ZCODE_PORT and ZCODE_OUT",
@@ -135,7 +139,7 @@ You read the empty repo, decide the design system, copy, motion, form logic and
135
139
  }
136
140
  ```
137
141
 
138
- `zcode plan check plan.json` → `zcode plan run plan.json --preflight` (read-only `<id>.pre` sessions; prints only the questions) → answer them once in the decisions file → `zcode plan run plan.json --detach` → `zcode plan wait plan --timeout-ms 540000 --json`. Your total involvement: the decisions, one answer pass, one summary.
142
+ `zcode plan check plan.json` → `zcode plan run plan.json --preflight` (read-only `<id>.pre` sessions, all at once; downstream sessions are told their upstream hasn't run yet; prints only the questions) → answer them once in the decisions file → `zcode plan run plan.json --detach` → `zcode plan wait plan --timeout-ms 540000 --json`. Your total involvement: the decisions, one answer pass, one summary.
139
143
 
140
144
  ## Exit codes
141
145