@0xgeorgemathew/zcode 0.3.1 → 0.4.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.
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+ // lib/commands/sessions.js — status | wait | cancel. Read-only views over
3
+ // the session store (plus a signal for cancel); none of them spawns the
4
+ // engine or needs the API key, so they are instant and safe to poll.
5
+ const store = require('../store.js');
6
+ const { formatResult, formatSessionRow, formatSessionView, useColor } = require('../ui.js');
7
+
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+
10
+ function resolveOrExit(ref, json) {
11
+ const id = store.resolveSessionId(ref);
12
+ if (!id) {
13
+ if (json) process.stdout.write(JSON.stringify({ type: 'error', error: 'not_found', id: ref }) + '\n');
14
+ process.stderr.write(`zcode: no session "${ref}" (see "zcode status").\n`);
15
+ process.exit(2);
16
+ }
17
+ return id;
18
+ }
19
+
20
+ // The handback for a finished session, or a synthetic one when the owning
21
+ // process died without writing it (or is mid-resume of an older result).
22
+ function finalResult(view) {
23
+ const result = store.readResult(view.id);
24
+ if (view.state === 'finished' && result) return result;
25
+ return {
26
+ type: 'result',
27
+ ok: false,
28
+ status: 'failed',
29
+ id: view.id,
30
+ kind: view.kind,
31
+ error: {
32
+ kind: 'interrupted',
33
+ message: `the process running this session exited without a handback; continue with "zcode resume ${view.id}"`,
34
+ },
35
+ issues: ['interrupted before completion'],
36
+ durationMs: 0,
37
+ };
38
+ }
39
+
40
+ function statusPayload(view, { events = 10 } = {}) {
41
+ const payload = { type: 'status', ...view, recent: store.readEvents(view.id, { tail: events }) };
42
+ if (view.state === 'finished' || view.state === 'interrupted') payload.result = finalResult(view);
43
+ return payload;
44
+ }
45
+
46
+ async function status(parsed) {
47
+ const { flags, prompts } = parsed;
48
+ if (prompts.length === 0) {
49
+ const all = store.listSessions().slice(0, 50);
50
+ if (flags.json) {
51
+ process.stdout.write(JSON.stringify({ type: 'sessions', sessions: all.map(summaryOf) }) + '\n');
52
+ } else if (all.length === 0) {
53
+ process.stdout.write('no sessions\n');
54
+ } else {
55
+ for (const s of all) process.stdout.write(formatSessionRow(s) + '\n');
56
+ }
57
+ process.exit(0);
58
+ }
59
+ const id = resolveOrExit(prompts[0], flags.json);
60
+ const view = store.viewSession(store.readSession(id));
61
+ const payload = statusPayload(view);
62
+ if (flags.json) {
63
+ process.stdout.write(JSON.stringify(payload) + '\n');
64
+ } else {
65
+ process.stdout.write(formatSessionView(view, { events: payload.recent.slice(-8) }));
66
+ if (payload.result) process.stdout.write('\n' + formatResult(payload.result, { color: useColor(process.stdout) }));
67
+ }
68
+ process.exit(0);
69
+ }
70
+
71
+ function summaryOf(s) {
72
+ return {
73
+ id: s.id,
74
+ kind: s.kind,
75
+ label: s.label,
76
+ state: s.state,
77
+ status: s.status,
78
+ phase: s.phase,
79
+ cwd: s.cwd,
80
+ owns: s.owns,
81
+ updatedAt: s.updatedAt,
82
+ };
83
+ }
84
+
85
+ // Blocks until every id is finished (or interrupted), printing each
86
+ // handback as it lands. Exit 0 iff all completed; otherwise the exit code
87
+ // of the first one that did not (124 when --timeout-ms elapses first).
88
+ async function wait(parsed) {
89
+ const { flags, prompts, given } = parsed;
90
+ if (prompts.length === 0) {
91
+ process.stderr.write('zcode: wait needs at least one session id.\n');
92
+ process.exit(2);
93
+ }
94
+ const { exitCodeFor } = require('../executor.js');
95
+ const ids = prompts.map((p) => resolveOrExit(p, flags.json));
96
+ const deadline = given.has('timeoutMs') ? Date.now() + flags.timeoutMs : Infinity;
97
+ const pending = new Set(ids);
98
+ let code = 0;
99
+ for (;;) {
100
+ for (const id of [...pending]) {
101
+ const view = store.viewSession(store.readSession(id));
102
+ if (view.state !== 'finished' && view.state !== 'interrupted') continue;
103
+ pending.delete(id);
104
+ const result = finalResult(view);
105
+ const c = exitCodeFor(result);
106
+ 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
+ }
110
+ if (pending.size === 0) process.exit(code);
111
+ if (Date.now() >= deadline) {
112
+ for (const id of pending) {
113
+ const view = store.viewSession(store.readSession(id));
114
+ if (flags.json) process.stdout.write(JSON.stringify(statusPayload(view, { events: 3 })) + '\n');
115
+ else process.stderr.write(`zcode: ${id} still ${view.state} (phase ${view.phase})\n`);
116
+ }
117
+ process.exit(124);
118
+ }
119
+ await sleep(500);
120
+ }
121
+ }
122
+
123
+ // SIGTERM to the owning process; it stops the engine turn, kills checks,
124
+ // records "cancelled". Escalates to SIGKILL after 15 s and records the
125
+ // cancellation itself. Idempotent: cancelling a finished session is a no-op.
126
+ async function cancel(parsed) {
127
+ const { flags, prompts } = parsed;
128
+ if (prompts.length !== 1) {
129
+ process.stderr.write('zcode: cancel needs exactly one session id.\n');
130
+ process.exit(2);
131
+ }
132
+ const id = resolveOrExit(prompts[0], flags.json);
133
+ let view = store.viewSession(store.readSession(id));
134
+ const report = (outcome) => {
135
+ const v = store.viewSession(store.readSession(id));
136
+ if (flags.json) process.stdout.write(JSON.stringify({ type: 'cancel', id, outcome, state: v.state, status: v.status }) + '\n');
137
+ else process.stdout.write(`${id}: ${outcome} (${v.state === 'finished' ? v.status : v.state})\n`);
138
+ process.exit(0);
139
+ };
140
+ if (!view.live) return report('not running');
141
+ try {
142
+ process.kill(view.pid, 'SIGTERM');
143
+ } catch {
144
+ /* exited in between */
145
+ }
146
+ const deadline = Date.now() + 15000;
147
+ while (Date.now() < deadline) {
148
+ await sleep(250);
149
+ view = store.viewSession(store.readSession(id));
150
+ if (!view.live) return report('cancelled');
151
+ }
152
+ for (const target of [view.pid, -view.enginePid]) {
153
+ if (!target) continue;
154
+ try {
155
+ process.kill(target, 'SIGKILL'); // negative = the engine's process group
156
+ } catch {
157
+ /* gone */
158
+ }
159
+ }
160
+ const result = {
161
+ type: 'result',
162
+ ok: false,
163
+ status: 'cancelled',
164
+ id,
165
+ kind: view.kind,
166
+ error: { kind: 'cancelled', message: 'killed after not stopping within 15s' },
167
+ issues: ['cancelled (forced)'],
168
+ durationMs: 0,
169
+ };
170
+ store.writeResult(id, result);
171
+ store.updateSession(id, { state: 'finished', phase: 'done', status: 'cancelled' });
172
+ return report('killed');
173
+ }
174
+
175
+ module.exports = async function sessions(parsed) {
176
+ if (parsed.command === 'status') return status(parsed);
177
+ if (parsed.command === 'wait') return wait(parsed);
178
+ return cancel(parsed);
179
+ };
180
+
181
+ module.exports.finalResult = finalResult;
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+ // The task contract (handoff) and the handback.
3
+ //
4
+ // Handoff: an orchestrator describes the work as a small JSON object (or a
5
+ // markdown brief, or just flags). Only `objective` is required; everything
6
+ // else scales with the task. zcode renders it into the executor protocol
7
+ // prompt below.
8
+ //
9
+ // Handback: the model ends its final message with a fenced `zcode-handback`
10
+ // JSON block. That block is a CLAIM. The status zcode reports is derived
11
+ // from evidence zcode gathered itself (its own check runs, the workspace
12
+ // diff, the ownership scope) and only falls back on the claim for what
13
+ // cannot be checked mechanically — and says so in `notVerified`.
14
+
15
+ class ContractError extends Error {}
16
+
17
+ const CONTRACT_KEYS = [
18
+ 'objective',
19
+ 'scope',
20
+ 'constraints',
21
+ 'design',
22
+ 'acceptance',
23
+ 'checks',
24
+ 'files',
25
+ 'references',
26
+ 'context',
27
+ 'owns',
28
+ 'limits',
29
+ 'model',
30
+ 'effort',
31
+ ];
32
+ const LIMIT_KEYS = ['timeoutMs', 'maxRepairs', 'checkTimeoutMs'];
33
+
34
+ const asList = (v) =>
35
+ v === undefined || v === null
36
+ ? []
37
+ : (Array.isArray(v) ? v : [v]).map((x) => String(x).trim()).filter(Boolean);
38
+
39
+ // Builds a contract from an optional base object (JSON file / markdown
40
+ // brief) plus CLI additions. Throws ContractError on unknown keys or a
41
+ // missing objective — before any engine spawn.
42
+ function normalizeContract(base = {}, additions = {}) {
43
+ if (!base || typeof base !== 'object' || Array.isArray(base)) {
44
+ throw new ContractError('task contract must be a JSON object');
45
+ }
46
+ const unknown = Object.keys(base).filter((k) => !CONTRACT_KEYS.includes(k));
47
+ if (unknown.length) {
48
+ throw new ContractError(
49
+ `unknown task contract key(s): ${unknown.join(', ')}. Accepted: ${CONTRACT_KEYS.join(', ')}`
50
+ );
51
+ }
52
+ const limits = base.limits || {};
53
+ if (typeof limits !== 'object' || Array.isArray(limits)) {
54
+ throw new ContractError('contract "limits" must be an object');
55
+ }
56
+ const badLimit = Object.keys(limits).filter((k) => !LIMIT_KEYS.includes(k));
57
+ if (badLimit.length) {
58
+ throw new ContractError(`unknown limits key(s): ${badLimit.join(', ')}. Accepted: ${LIMIT_KEYS.join(', ')}`);
59
+ }
60
+ for (const k of LIMIT_KEYS) {
61
+ if (limits[k] !== undefined && !(Number.isFinite(limits[k]) && limits[k] >= 0)) {
62
+ throw new ContractError(`contract limits.${k} must be a non-negative number`);
63
+ }
64
+ }
65
+ const objective = [String(base.objective ?? '').trim(), String(additions.objective ?? '').trim()]
66
+ .filter(Boolean)
67
+ .join('\n\n');
68
+ if (!objective) {
69
+ throw new ContractError('the task needs an objective (positional text, --task file, or "objective" in JSON)');
70
+ }
71
+ return {
72
+ objective,
73
+ scope: asList(base.scope),
74
+ constraints: asList(base.constraints),
75
+ design: asList(base.design),
76
+ acceptance: [...asList(base.acceptance), ...asList(additions.acceptance)],
77
+ checks: [...asList(base.checks), ...asList(additions.checks)],
78
+ files: [...asList(base.files), ...asList(additions.files)],
79
+ references: asList(base.references),
80
+ context: [...asList(base.context), ...asList(additions.context)],
81
+ owns: [...asList(base.owns), ...asList(additions.owns)],
82
+ limits: { ...limits },
83
+ model: base.model ?? null,
84
+ effort: base.effort ?? null,
85
+ };
86
+ }
87
+
88
+ // --- prompts ------------------------------------------------------------------
89
+
90
+ const bullets = (items) => items.map((i) => `- ${i}`).join('\n');
91
+
92
+ function section(title, items) {
93
+ return items.length ? `## ${title}\n${bullets(items)}\n` : '';
94
+ }
95
+
96
+ const HANDBACK_SPEC = [
97
+ 'End your final message with exactly one fenced block in this form (valid JSON):',
98
+ '```zcode-handback',
99
+ '{"status": "done | partial | blocked",',
100
+ ' "summary": "one or two sentences: what you delivered",',
101
+ ' "changes": [{"path": "relative/path", "what": "short description"}],',
102
+ ' "checks": [{"what": "command or inspection you ran", "result": "pass | fail | not-run", "evidence": "short"}],',
103
+ ' "criteria": [{"criterion": "acceptance criterion", "met": true, "evidence": "short"}],',
104
+ ' "unresolved": ["problems inside your scope that remain unfixed"],',
105
+ ' "questions": ["what you need from the orchestrator (only when blocked)"],',
106
+ ' "notes": "risks or follow-ups the orchestrator should know"}',
107
+ '```',
108
+ '"done" means every acceptance criterion is met and verified; "partial" means some work remains; "blocked" means you cannot proceed without the orchestrator.',
109
+ ].join('\n');
110
+
111
+ // `ownsDisplay` are the patterns as the caller wrote them (relative to cwd).
112
+ function buildTaskPrompt(contract, { ownsDisplay = contract.owns } = {}) {
113
+ const parts = [
114
+ '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
+ '',
116
+ `## Objective\n${contract.objective}\n`,
117
+ section('Scope', contract.scope),
118
+ section('Constraints', contract.constraints),
119
+ section('Design decisions (supplied by the orchestrator: implement them exactly; do not change them)', contract.design),
120
+ section('Relevant files (read these first)', contract.files),
121
+ section('References', contract.references),
122
+ section('Context', contract.context),
123
+ section('Acceptance criteria', contract.acceptance),
124
+ ownsDisplay.length
125
+ ? `## 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
+ : '',
127
+ contract.checks.length
128
+ ? `## 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
+ : '',
130
+ [
131
+ '## How to work',
132
+ '- Make routine implementation decisions yourself. Do not stop to ask for approval of intermediate steps.',
133
+ '- 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
+ '- 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
+ '- Verify every acceptance criterion against the actual result. When something fails, diagnose and fix it, then verify again.',
136
+ '- Do not commit, push, publish, deploy, or cause other external side effects unless this task explicitly asks for it.',
137
+ '- 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
+ '',
139
+ ].join('\n'),
140
+ `## Handback\n${HANDBACK_SPEC}`,
141
+ ];
142
+ return parts.filter(Boolean).join('\n');
143
+ }
144
+
145
+ function buildRepairPrompt(failures, { attempt, maxRepairs }) {
146
+ const blocks = failures.map((f) => {
147
+ const why = f.timedOut ? 'timed out' : `exit ${f.exitCode}`;
148
+ return `### \`${f.command}\` (${why})\n\`\`\`\n${f.output.trim().slice(-3000) || '(no output)'}\n\`\`\``;
149
+ });
150
+ return [
151
+ `Zcode ran the acceptance checks and ${failures.length === 1 ? 'one failed' : `${failures.length} failed`} (repair attempt ${attempt} of ${maxRepairs}):`,
152
+ '',
153
+ ...blocks,
154
+ '',
155
+ 'Diagnose the root cause and fix it within your ownership, run the checks again yourself, then finish with a fresh zcode-handback block. If the failure is outside your scope or cannot be fixed, report status "partial" or "blocked" and explain why.',
156
+ ].join('\n');
157
+ }
158
+
159
+ const HANDBACK_REQUEST =
160
+ '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
+ HANDBACK_SPEC;
162
+
163
+ function buildResumePrompt(message, { kind }) {
164
+ const note =
165
+ '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
+ if (kind !== 'task') return message;
167
+ const body = message && message.trim()
168
+ ? `Message from the orchestrator:\n\n${message.trim()}`
169
+ : 'Continue the task from where you stopped.';
170
+ return `${body}\n\n${note}\n\n${HANDBACK_SPEC}`;
171
+ }
172
+
173
+ // --- handback -------------------------------------------------------------------
174
+
175
+ const HANDBACK_RE = /```zcode-handback[^\n]*\n([\s\S]*?)```/g;
176
+ const STATUS_ALIASES = new Map([
177
+ ['done', 'done'],
178
+ ['complete', 'done'],
179
+ ['completed', 'done'],
180
+ ['success', 'done'],
181
+ ['partial', 'partial'],
182
+ ['incomplete', 'partial'],
183
+ ['failed', 'partial'],
184
+ ['blocked', 'blocked'],
185
+ ]);
186
+
187
+ // Returns {handback, report, error}: the LAST block wins; `report` is the
188
+ // response text with the block removed.
189
+ function parseHandback(text) {
190
+ const source = String(text || '');
191
+ const matches = [...source.matchAll(HANDBACK_RE)];
192
+ if (matches.length === 0) return { handback: null, report: source.trim(), error: 'missing' };
193
+ const last = matches[matches.length - 1];
194
+ const report = (source.slice(0, last.index) + source.slice(last.index + last[0].length)).trim();
195
+ let raw;
196
+ try {
197
+ raw = JSON.parse(last[1]);
198
+ } catch (err) {
199
+ return { handback: null, report, error: `invalid JSON: ${err.message}` };
200
+ }
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
202
+ return { handback: null, report, error: 'not a JSON object' };
203
+ }
204
+ const status = STATUS_ALIASES.get(String(raw.status || '').toLowerCase()) || null;
205
+ const arr = (v) => (Array.isArray(v) ? v : v ? [v] : []);
206
+ return {
207
+ handback: {
208
+ status,
209
+ summary: typeof raw.summary === 'string' ? raw.summary : '',
210
+ changes: arr(raw.changes),
211
+ checks: arr(raw.checks),
212
+ criteria: arr(raw.criteria),
213
+ unresolved: arr(raw.unresolved).map(String),
214
+ questions: arr(raw.questions).map(String),
215
+ notes: typeof raw.notes === 'string' ? raw.notes : '',
216
+ },
217
+ report,
218
+ error: status ? null : 'missing or unknown status',
219
+ };
220
+ }
221
+
222
+ // --- status ---------------------------------------------------------------------
223
+
224
+ // The single place the final status is decided.
225
+ // completed — zcode's own checks ran and all passed, the model reports
226
+ // done, no unmet criteria, nothing changed outside ownership
227
+ // unverified — the model reports done but zcode had no checks to run
228
+ // (or no usable handback): completion is self-reported
229
+ // partial — work remains: model says partial / criteria unmet /
230
+ // files changed outside ownership
231
+ // blocked — needs the orchestrator (questions in the result)
232
+ // failed — engine/turn error, timeout, or checks still failing after
233
+ // the repair budget
234
+ // cancelled — stopped by zcode cancel / a signal
235
+ function deriveStatus({
236
+ kind,
237
+ error = null,
238
+ cancelled = false,
239
+ userInput = null,
240
+ handback = null,
241
+ handbackError = null,
242
+ checks = [],
243
+ scopeViolations = [],
244
+ }) {
245
+ const issues = [];
246
+ if (cancelled) return { status: 'cancelled', issues: ['cancelled before completion'] };
247
+ if (userInput) {
248
+ return { status: 'blocked', issues: [`the engine requested interactive input: ${userInput}`] };
249
+ }
250
+ if (error) return { status: 'failed', issues: [error.message || String(error)] };
251
+ if (kind !== 'task') return { status: 'completed', issues };
252
+
253
+ if (handback && handback.status === 'blocked') {
254
+ return { status: 'blocked', issues: ['the executor reported it is blocked (see questions)'] };
255
+ }
256
+ const failedChecks = checks.filter((c) => !c.ok);
257
+ if (failedChecks.length) {
258
+ return {
259
+ status: 'failed',
260
+ issues: [`${failedChecks.length} of ${checks.length} acceptance check(s) failing: ${failedChecks.map((c) => c.command).join('; ')}`],
261
+ };
262
+ }
263
+ let partial = false;
264
+ if (handback && handback.status === 'partial') {
265
+ partial = true;
266
+ issues.push('the executor reported partial completion');
267
+ }
268
+ const unmet = handback ? handback.criteria.filter((c) => c && c.met === false) : [];
269
+ if (unmet.length) {
270
+ partial = true;
271
+ issues.push(`unmet acceptance criteria: ${unmet.map((c) => c.criterion).join('; ')}`);
272
+ }
273
+ if (scopeViolations.length) {
274
+ partial = true;
275
+ issues.push(`changed files outside the owned scope: ${scopeViolations.join(', ')}`);
276
+ }
277
+ if (partial) return { status: 'partial', issues };
278
+ if (!handback || handback.status !== 'done') {
279
+ issues.push(`no usable handback from the executor (${handbackError || 'missing'})`);
280
+ // Passing checks prove only the mechanical criteria; without a "done"
281
+ // report nobody has confirmed the rest.
282
+ return { status: 'unverified', issues };
283
+ }
284
+ if (!checks.length) {
285
+ issues.push('no acceptance checks were declared, so completion is self-reported by the executor');
286
+ return { status: 'unverified', issues };
287
+ }
288
+ return { status: 'completed', issues };
289
+ }
290
+
291
+ module.exports = {
292
+ ContractError,
293
+ CONTRACT_KEYS,
294
+ normalizeContract,
295
+ buildTaskPrompt,
296
+ buildRepairPrompt,
297
+ buildResumePrompt,
298
+ HANDBACK_REQUEST,
299
+ parseHandback,
300
+ deriveStatus,
301
+ };