@0xgeorgemathew/zcode 0.4.4 → 0.5.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/README.md +69 -13
- package/bin/zcode.js +119 -19
- package/lib/bootstrap.js +2 -2
- package/lib/brief.js +69 -0
- package/lib/checks.js +3 -2
- package/lib/commands/doctor.js +3 -0
- package/lib/commands/exec.js +100 -35
- package/lib/commands/plan.js +301 -0
- package/lib/commands/sessions.js +60 -11
- package/lib/contract.js +220 -17
- package/lib/executor.js +141 -34
- package/lib/isolation.js +4 -1
- package/lib/models.js +84 -0
- package/lib/plan.js +424 -0
- package/lib/ports.js +45 -0
- package/lib/store.js +50 -0
- package/lib/taskspec.js +107 -0
- package/lib/ui.js +7 -2
- package/lib/workspace.js +24 -7
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/claude-code/zcode/SKILL.md +104 -74
- package/skills/codex/zcode/SKILL.md +104 -76
package/lib/commands/sessions.js
CHANGED
|
@@ -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
|
|
87
|
-
//
|
|
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
|
|
127
|
+
for (const id of ids) {
|
|
128
|
+
if (!pending.has(id)) continue;
|
|
101
129
|
const view = store.viewSession(store.readSession(id));
|
|
102
|
-
if (view
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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('
|
|
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.
|
|
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
|
-
|
|
151
|
-
`Zcode ran the acceptance checks and ${failures.length === 1 ? 'one failed' : `${failures.length} failed`} (repair attempt ${attempt} of ${maxRepairs})
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|