@0xgeorgemathew/zcode 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +146 -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/exec.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Output contract:
|
|
8
8
|
// --json stdout = NDJSON events + exactly one final {"type":"result"}
|
|
9
|
-
// line (--quiet: only that line)
|
|
9
|
+
// line (--quiet: only that line): the compact brief
|
|
10
|
+
// (zcode.brief/1, lib/brief.js), or the full record with --full.
|
|
11
|
+
// stderr = diagnostics only.
|
|
10
12
|
// plain stdout = the answer (run/agent) or the handback summary (task);
|
|
11
13
|
// stderr = the live feed (header, phases, tools, checks).
|
|
12
14
|
const fs = require('node:fs');
|
|
@@ -15,17 +17,22 @@ const { spawn } = require('node:child_process');
|
|
|
15
17
|
const store = require('../store.js');
|
|
16
18
|
const { execute, prepareSession, exitCodeFor, ExecError, readSettings } = require('../executor.js');
|
|
17
19
|
const { ConfigError } = require('../bootstrap.js');
|
|
18
|
-
const {
|
|
20
|
+
const { ContractError } = require('../contract.js');
|
|
21
|
+
const { buildContract } = require('../taskspec.js');
|
|
19
22
|
const { workspaceRoot, normalizeOwns, OwnershipError } = require('../workspace.js');
|
|
23
|
+
const { buildBrief } = require('../brief.js');
|
|
20
24
|
const { makeReporter, formatResult, useColor } = require('../ui.js');
|
|
21
|
-
const { normalizeModel, normalizeEffort } = require('../models.js');
|
|
25
|
+
const { normalizeModel, normalizeEffort, effortProblem } = require('../models.js');
|
|
22
26
|
|
|
23
27
|
const BIN = path.join(__dirname, '..', '..', 'bin', 'zcode.js');
|
|
24
28
|
const DEFAULT_TIMEOUT = { run: 600000, agent: 600000, task: 1800000 };
|
|
25
29
|
|
|
30
|
+
// Thrown (not exited) so builders can be reused by the plan supervisor; the
|
|
31
|
+
// command entry points below turn it into the one-line exit 2.
|
|
32
|
+
class UsageError extends Error {}
|
|
33
|
+
|
|
26
34
|
function usage(message) {
|
|
27
|
-
|
|
28
|
-
process.exit(2);
|
|
35
|
+
throw new UsageError(message);
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function readStdin() {
|
|
@@ -39,7 +46,7 @@ function readStdin() {
|
|
|
39
46
|
// --task <file|->: .json (or text starting with "{") is a contract object,
|
|
40
47
|
// anything else is a markdown brief that becomes the objective.
|
|
41
48
|
function loadTaskBase(spec) {
|
|
42
|
-
if (spec === undefined) return {};
|
|
49
|
+
if (spec === undefined) return { base: {}, dir: null };
|
|
43
50
|
const text = spec === '-' ? readStdin() : (() => {
|
|
44
51
|
try {
|
|
45
52
|
return fs.readFileSync(spec, 'utf8');
|
|
@@ -48,19 +55,24 @@ function loadTaskBase(spec) {
|
|
|
48
55
|
}
|
|
49
56
|
})();
|
|
50
57
|
const trimmed = text.trim();
|
|
58
|
+
// "@file" entries inside a contract resolve next to the contract file.
|
|
59
|
+
const dir = spec === '-' ? null : path.dirname(path.resolve(spec));
|
|
51
60
|
if (spec.endsWith('.json') || trimmed.startsWith('{')) {
|
|
52
61
|
try {
|
|
53
|
-
return JSON.parse(trimmed);
|
|
62
|
+
return { base: JSON.parse(trimmed), dir };
|
|
54
63
|
} catch (err) {
|
|
55
64
|
return usage(`--task JSON is invalid: ${err.message}`);
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
|
-
return { objective: trimmed };
|
|
67
|
+
return { base: { objective: trimmed }, dir };
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
|
|
70
|
+
// `spec` ({base, dir}) replaces --task: the plan supervisor hands over an
|
|
71
|
+
// assembled contract instead of a file.
|
|
72
|
+
function buildInvocation(parsed, spec) {
|
|
62
73
|
const { command, flags, prompts } = parsed;
|
|
63
74
|
const kind = command;
|
|
75
|
+
let effortSource = '--effort';
|
|
64
76
|
const inv = {
|
|
65
77
|
kind,
|
|
66
78
|
id: flags.id || store.newId(),
|
|
@@ -88,19 +100,30 @@ function buildInvocation(parsed) {
|
|
|
88
100
|
|
|
89
101
|
if (kind === 'task') {
|
|
90
102
|
const objective = prompts.join(' ');
|
|
91
|
-
let base = loadTaskBase(flags.task);
|
|
92
|
-
if (!objective.trim() && flags.task === undefined && !process.stdin.isTTY) {
|
|
103
|
+
let { base, dir } = spec || loadTaskBase(flags.task);
|
|
104
|
+
if (!spec && !objective.trim() && flags.task === undefined && !process.stdin.isTTY) {
|
|
93
105
|
base = { objective: readStdin().trim() };
|
|
94
106
|
}
|
|
95
107
|
let contract;
|
|
96
108
|
try {
|
|
97
|
-
contract =
|
|
109
|
+
contract = buildContract({
|
|
110
|
+
base,
|
|
111
|
+
baseDir: dir || process.cwd(),
|
|
112
|
+
cwd: inv.cwd,
|
|
98
113
|
objective,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
additions: {
|
|
115
|
+
acceptance: flags.accept,
|
|
116
|
+
checks: flags.check,
|
|
117
|
+
files: flags.file,
|
|
118
|
+
context: flags.context,
|
|
119
|
+
decisions: flags.decisions,
|
|
120
|
+
units: flags.units,
|
|
121
|
+
visual: flags.visual,
|
|
122
|
+
viewports: flags.viewports,
|
|
123
|
+
serve: flags.serve,
|
|
124
|
+
owns: flags.owns,
|
|
125
|
+
touch: flags.touch,
|
|
126
|
+
},
|
|
104
127
|
});
|
|
105
128
|
} catch (err) {
|
|
106
129
|
if (err instanceof ContractError) usage(err.message);
|
|
@@ -111,6 +134,7 @@ function buildInvocation(parsed) {
|
|
|
111
134
|
inv.model = normalizeModel(contract.model) || usage(`contract model "${contract.model}" is not supported.`);
|
|
112
135
|
}
|
|
113
136
|
if (contract.effort && !parsed.given.has('effort')) {
|
|
137
|
+
effortSource = 'contract effort';
|
|
114
138
|
inv.effort = normalizeEffort(contract.effort) || usage(`contract effort "${contract.effort}" is not valid.`);
|
|
115
139
|
}
|
|
116
140
|
const lim = contract.limits;
|
|
@@ -120,7 +144,9 @@ function buildInvocation(parsed) {
|
|
|
120
144
|
inv.contract = contract;
|
|
121
145
|
inv.checks = contract.checks;
|
|
122
146
|
inv.ownsDisplay = contract.owns;
|
|
147
|
+
inv.touchDisplay = contract.touch;
|
|
123
148
|
inv.acceptanceDeclared = contract.acceptance.length > 0;
|
|
149
|
+
inv.preflight = Boolean(flags.preflight);
|
|
124
150
|
} else {
|
|
125
151
|
let prompt = prompts.join(' ');
|
|
126
152
|
if (!prompt && kind === 'run' && !process.stdin.isTTY) prompt = readStdin();
|
|
@@ -128,8 +154,14 @@ function buildInvocation(parsed) {
|
|
|
128
154
|
inv.prompt = prompt;
|
|
129
155
|
}
|
|
130
156
|
|
|
157
|
+
// The model/effort pair that will actually run, refused before any spawn.
|
|
158
|
+
const problem = effortProblem(inv.model, inv.effort, { source: effortSource });
|
|
159
|
+
if (problem) usage(problem);
|
|
160
|
+
|
|
131
161
|
try {
|
|
132
|
-
|
|
162
|
+
const root = workspaceRoot(inv.cwd);
|
|
163
|
+
inv.owns = normalizeOwns(inv.ownsDisplay, inv.cwd, root);
|
|
164
|
+
inv.touch = normalizeOwns(inv.touchDisplay || [], inv.cwd, root, '--touch');
|
|
133
165
|
} catch (err) {
|
|
134
166
|
if (err instanceof OwnershipError) usage(err.message);
|
|
135
167
|
throw err;
|
|
@@ -145,6 +177,10 @@ function buildResume(parsed, ref, message) {
|
|
|
145
177
|
if (!settings) usage(`session "${id}" has no stored settings; it cannot be resumed.`);
|
|
146
178
|
const { flags, given } = parsed;
|
|
147
179
|
if (settings.kind !== 'task' && !message.trim()) usage(`resume of a ${settings.kind} session needs a message.`);
|
|
180
|
+
const model = given.has('model') ? flags.model : settings.model;
|
|
181
|
+
const effort = given.has('effort') ? flags.effort : settings.effort;
|
|
182
|
+
const problem = effortProblem(model, effort);
|
|
183
|
+
if (problem) usage(problem);
|
|
148
184
|
return {
|
|
149
185
|
...settings,
|
|
150
186
|
id,
|
|
@@ -168,23 +204,19 @@ function readTask(id) {
|
|
|
168
204
|
}
|
|
169
205
|
}
|
|
170
206
|
|
|
171
|
-
//
|
|
172
|
-
// (`zcode __exec <id>`)
|
|
173
|
-
//
|
|
174
|
-
|
|
207
|
+
// Claims the id, hands the invocation to a detached worker
|
|
208
|
+
// (`zcode __exec <id>`) and returns its handle. The worker writes its NDJSON
|
|
209
|
+
// to <session>/stdout.ndjson and diagnostics to stderr.log. Throws
|
|
210
|
+
// store.SessionExistsError for a taken id, ExecError (exit 7) when a resumed
|
|
211
|
+
// session is still running.
|
|
212
|
+
function startDetached(inv) {
|
|
175
213
|
if (inv.resume) {
|
|
176
214
|
const view = store.viewSession(store.readSession(inv.id));
|
|
177
215
|
if (view && view.live) {
|
|
178
|
-
|
|
179
|
-
process.exit(7);
|
|
216
|
+
throw new ExecError(`session "${inv.id}" is still running (pid ${view.pid}).`, { exitCode: 7, kind: 'busy' });
|
|
180
217
|
}
|
|
181
218
|
} else {
|
|
182
|
-
|
|
183
|
-
prepareSession(inv);
|
|
184
|
-
} catch (err) {
|
|
185
|
-
if (err instanceof store.SessionExistsError) usage(err.message);
|
|
186
|
-
throw err;
|
|
187
|
-
}
|
|
219
|
+
prepareSession(inv);
|
|
188
220
|
}
|
|
189
221
|
const dir = store.sessionDir(inv.id);
|
|
190
222
|
store.writeJsonAtomic(path.join(dir, 'pending.json'), inv);
|
|
@@ -200,8 +232,10 @@ function detach(inv, flags) {
|
|
|
200
232
|
env: process.env,
|
|
201
233
|
});
|
|
202
234
|
child.unref();
|
|
235
|
+
fs.closeSync(out);
|
|
236
|
+
fs.closeSync(err);
|
|
203
237
|
store.updateSession(inv.id, { state: 'queued', phase: 'queued', pid: child.pid, status: null });
|
|
204
|
-
|
|
238
|
+
return {
|
|
205
239
|
type: 'detached',
|
|
206
240
|
id: inv.id,
|
|
207
241
|
pid: child.pid,
|
|
@@ -209,17 +243,32 @@ function detach(inv, flags) {
|
|
|
209
243
|
status: 'queued',
|
|
210
244
|
files: { session: dir, stdout: path.join(dir, 'stdout.ndjson'), stderr: path.join(dir, 'stderr.log') },
|
|
211
245
|
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --detach: start in the background, print the handle, exit 0.
|
|
249
|
+
function detach(inv, flags) {
|
|
250
|
+
let handle;
|
|
251
|
+
try {
|
|
252
|
+
handle = startDetached(inv);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err instanceof store.SessionExistsError) usage(err.message);
|
|
255
|
+
if (err instanceof ExecError) {
|
|
256
|
+
process.stderr.write(`zcode: ${err.message}\n`);
|
|
257
|
+
process.exit(err.exitCode);
|
|
258
|
+
}
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
212
261
|
if (flags.json) process.stdout.write(JSON.stringify(handle) + '\n');
|
|
213
262
|
else {
|
|
214
263
|
process.stdout.write(`${inv.id}\n`);
|
|
215
264
|
if (!flags.quiet) {
|
|
216
|
-
process.stderr.write(`zcode: started ${inv.id} in the background (pid ${
|
|
265
|
+
process.stderr.write(`zcode: started ${inv.id} in the background (pid ${handle.pid}). zcode status ${inv.id} · zcode wait ${inv.id}\n`);
|
|
217
266
|
}
|
|
218
267
|
}
|
|
219
268
|
process.exit(0);
|
|
220
269
|
}
|
|
221
270
|
|
|
222
|
-
|
|
271
|
+
async function exec(parsed) {
|
|
223
272
|
const { command, flags } = parsed;
|
|
224
273
|
let inv;
|
|
225
274
|
let worker = false;
|
|
@@ -251,6 +300,7 @@ module.exports = async function exec(parsed) {
|
|
|
251
300
|
// Workers always emit NDJSON (to their stdout file) and never a TTY feed.
|
|
252
301
|
const json = worker || flags.json;
|
|
253
302
|
const quiet = flags.quiet && !worker;
|
|
303
|
+
const full = flags.full && !worker;
|
|
254
304
|
const kind = inv.kind;
|
|
255
305
|
const reporter = makeReporter({
|
|
256
306
|
quiet,
|
|
@@ -295,7 +345,7 @@ module.exports = async function exec(parsed) {
|
|
|
295
345
|
type: 'result', ok: false, status: 'failed', id: inv.id, kind,
|
|
296
346
|
error: { kind: err.kind || 'config', message: err.message }, issues: [err.message],
|
|
297
347
|
};
|
|
298
|
-
if (json) process.stdout.write(JSON.stringify(failed) + '\n');
|
|
348
|
+
if (json) process.stdout.write(JSON.stringify(full ? failed : buildBrief(failed)) + '\n');
|
|
299
349
|
// A detached worker's caller polls the store: record why it never ran
|
|
300
350
|
// (conflicts already record themselves).
|
|
301
351
|
if (worker && err.kind !== 'conflict' && store.readSession(inv.id)) {
|
|
@@ -311,7 +361,7 @@ module.exports = async function exec(parsed) {
|
|
|
311
361
|
|
|
312
362
|
const code = exitCodeFor(result);
|
|
313
363
|
if (json) {
|
|
314
|
-
process.stdout.write(JSON.stringify(result) + '\n');
|
|
364
|
+
process.stdout.write(JSON.stringify(full ? result : buildBrief(result)) + '\n');
|
|
315
365
|
} else if (kind === 'task') {
|
|
316
366
|
reporter.closeThinking();
|
|
317
367
|
process.stdout.write(formatResult(result, { color: useColor(process.stdout) }));
|
|
@@ -325,4 +375,19 @@ module.exports = async function exec(parsed) {
|
|
|
325
375
|
}
|
|
326
376
|
}
|
|
327
377
|
process.exit(code);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = async function execCommand(parsed) {
|
|
381
|
+
try {
|
|
382
|
+
return await exec(parsed);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (!(err instanceof UsageError)) throw err;
|
|
385
|
+
process.stderr.write(`zcode: ${err.message}\n`);
|
|
386
|
+
process.exit(2);
|
|
387
|
+
}
|
|
328
388
|
};
|
|
389
|
+
|
|
390
|
+
module.exports.buildInvocation = buildInvocation;
|
|
391
|
+
module.exports.buildResume = buildResume;
|
|
392
|
+
module.exports.startDetached = startDetached;
|
|
393
|
+
module.exports.UsageError = UsageError;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/commands/plan.js — plan check|run|wait|status|resume, and __plan (the
|
|
3
|
+
// detached supervisor). Logic lives in lib/plan.js.
|
|
4
|
+
//
|
|
5
|
+
// Output contract: JSON mode prints exactly one line per call (plan check:
|
|
6
|
+
// {type:"plan-check"}; run/wait/status/resume: {type:"plan",name,status,
|
|
7
|
+
// sessions:[{id,status}],briefs:[…]}; a timeout or --detach: the same
|
|
8
|
+
// without briefs). `plan run --preflight` prints one brief per session with
|
|
9
|
+
// questions. Exit codes: 0 completed · 10 blocked · 8 partial/failed ·
|
|
10
|
+
// 124 timeout · 2 usage · 7 plan exists.
|
|
11
|
+
const store = require('../store.js');
|
|
12
|
+
const plans = require('../plan.js');
|
|
13
|
+
const { buildBrief } = require('../brief.js');
|
|
14
|
+
const { exitCodeFor } = require('../executor.js');
|
|
15
|
+
const { questionText } = require('../contract.js');
|
|
16
|
+
const { formatResult, useColor } = require('../ui.js');
|
|
17
|
+
|
|
18
|
+
const out = (line) => process.stdout.write(line + '\n');
|
|
19
|
+
|
|
20
|
+
function die(code, message) {
|
|
21
|
+
process.stderr.write(`zcode: ${message}\n`);
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function load(file, parsed) {
|
|
26
|
+
if (!file) die(2, 'plan needs a plan file.');
|
|
27
|
+
try {
|
|
28
|
+
return plans.resolvePlan(file, parsed.given.has('cwd') ? parsed.flags.cwd : undefined);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof plans.PlanError) return die(2, err.message);
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nameOf(parsed, file) {
|
|
36
|
+
try {
|
|
37
|
+
return plans.planName(parsed.flags.id, file);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err instanceof plans.PlanError) return die(2, err.message);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A plan by name (state + its plan file), or exit 2.
|
|
45
|
+
function open(name) {
|
|
46
|
+
const state = store.readPlanState(name);
|
|
47
|
+
if (!state) die(2, `no plan "${name}" (zcode plan run <file> starts one).`);
|
|
48
|
+
try {
|
|
49
|
+
return { state, r: plans.resolvePlan(state.file, state.cwd) };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof plans.PlanError) return die(2, err.message);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function problemsOrExit(problems, json) {
|
|
57
|
+
if (!problems.length) return;
|
|
58
|
+
if (json) out(JSON.stringify({ type: 'plan-check', ok: false, problems }));
|
|
59
|
+
for (const p of problems) process.stderr.write(`zcode: ${p}\n`);
|
|
60
|
+
process.exit(2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One line per session; a non-completed session adds its one-line summary,
|
|
64
|
+
// a blocked one its questions.
|
|
65
|
+
function humanReport(report) {
|
|
66
|
+
const brief = new Map((report.briefs || []).map((b) => [b.id, b]));
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (const s of report.sessions) {
|
|
69
|
+
const b = brief.get(s.id);
|
|
70
|
+
const note = b && (b.summary || (b.error && b.error.message) || '');
|
|
71
|
+
lines.push(`${s.id} ${s.status}${note ? ` - ${note.replace(/\s+/g, ' ').slice(0, 120)}` : ''}`);
|
|
72
|
+
if (b && b.questions) for (const q of b.questions) lines.push(` ? ${questionText(q).replace(/\n/g, '\n ')}`);
|
|
73
|
+
}
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function print(report, flags) {
|
|
78
|
+
if (flags.json) out(JSON.stringify(report));
|
|
79
|
+
else {
|
|
80
|
+
process.stderr.write(`plan ${report.name}: ${report.status}\n`);
|
|
81
|
+
out(humanReport(report));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reportOf = (name) => {
|
|
86
|
+
const { state, r } = open(name);
|
|
87
|
+
return plans.planReport(name, r, state);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function finish(report, flags) {
|
|
91
|
+
print(report, flags);
|
|
92
|
+
process.exit(plans.exitCodeForPlan(report.status));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Timeout / detach summary: statuses only.
|
|
96
|
+
function brief(name, flags, status) {
|
|
97
|
+
const { state, r } = open(name);
|
|
98
|
+
const report = plans.planReport(name, r, state, { briefs: false });
|
|
99
|
+
if (status) report.status = status;
|
|
100
|
+
if (flags.json) return print(report, flags);
|
|
101
|
+
// Human mode: the whole timeout/detach summary is one stdout line.
|
|
102
|
+
out(`plan ${report.name}: ${report.status} (${report.sessions.map((s) => `${s.id} ${s.status}`).join(', ')})`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Supervises in-process until the plan settles (or the deadline). On a
|
|
106
|
+
// deadline the supervision is handed to a detached supervisor so the plan
|
|
107
|
+
// keeps going.
|
|
108
|
+
async function superviseAndReport(name, flags, given) {
|
|
109
|
+
const deadline = given.has('timeoutMs') ? Date.now() + flags.timeoutMs : Infinity;
|
|
110
|
+
const res = await plans.supervise(name, { deadline });
|
|
111
|
+
if (res.timedOut) {
|
|
112
|
+
plans.startSupervisor(name);
|
|
113
|
+
brief(name, flags, 'running');
|
|
114
|
+
process.exit(124);
|
|
115
|
+
}
|
|
116
|
+
if (res.busy) {
|
|
117
|
+
// Another supervisor already drives it: just wait for it.
|
|
118
|
+
return waitFor(name, flags, given);
|
|
119
|
+
}
|
|
120
|
+
return finish(reportOf(name), flags);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitFor(name, flags, given) {
|
|
124
|
+
const deadline = given.has('timeoutMs') ? Date.now() + flags.timeoutMs : Infinity;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const report = reportOf(name);
|
|
127
|
+
if (report.status !== 'running') return finish(report, flags);
|
|
128
|
+
if (Date.now() >= deadline) {
|
|
129
|
+
brief(name, flags, 'running');
|
|
130
|
+
process.exit(124);
|
|
131
|
+
}
|
|
132
|
+
await plans.sleep(plans.POLL_MS);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function check(parsed, file) {
|
|
137
|
+
const r = load(file, parsed);
|
|
138
|
+
const name = nameOf(parsed, file);
|
|
139
|
+
const problems = plans.checkPlan(r, { name, state: store.readPlanState(name) });
|
|
140
|
+
problemsOrExit(problems, parsed.flags.json);
|
|
141
|
+
const n = plans.sessionList(r.plan).length;
|
|
142
|
+
if (parsed.flags.json) out(JSON.stringify({ type: 'plan-check', ok: true, name, sessions: n }));
|
|
143
|
+
else out(`plan ${name}: ${n} session${n === 1 ? '' : 's'}, ok`);
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function run(parsed, file) {
|
|
148
|
+
const { flags, given } = parsed;
|
|
149
|
+
const r = load(file, parsed);
|
|
150
|
+
const name = nameOf(parsed, file);
|
|
151
|
+
const state = store.readPlanState(name);
|
|
152
|
+
if (flags.preflight) return preflight(parsed, r, name, state);
|
|
153
|
+
if (state) {
|
|
154
|
+
die(7, `plan "${name}" already exists. Continue it with "zcode plan resume ${name}" (status: "zcode plan status ${name}"), or run again with another --id.`);
|
|
155
|
+
}
|
|
156
|
+
problemsOrExit(plans.checkPlan(r, { name }), flags.json);
|
|
157
|
+
const ok = store.createPlanState(name, {
|
|
158
|
+
file: r.file,
|
|
159
|
+
cwd: r.cwd,
|
|
160
|
+
sessions: plans.sessionList(r.plan).map((s) => s.id),
|
|
161
|
+
supervisor: null,
|
|
162
|
+
launchErrors: {},
|
|
163
|
+
});
|
|
164
|
+
if (!ok) die(7, `plan "${name}" already exists. Continue it with "zcode plan resume ${name}".`);
|
|
165
|
+
return startSupervision(name, flags, given);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function startSupervision(name, flags, given) {
|
|
169
|
+
if (flags.detach) {
|
|
170
|
+
plans.startSupervisor(name);
|
|
171
|
+
out(JSON.stringify({ type: 'plan', name, status: 'running' }));
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
return superviseAndReport(name, flags, given);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Preflight every session (read-only, in parallel, as "<id>.pre"), wait for
|
|
178
|
+
// all, print one brief per session that has questions. Creates no plan state.
|
|
179
|
+
async function preflight(parsed, r, name, state) {
|
|
180
|
+
const { flags, given } = parsed;
|
|
181
|
+
problemsOrExit(plans.checkPlan(r, { name, state }), flags.json);
|
|
182
|
+
const sessions = plans.sessionList(r.plan);
|
|
183
|
+
const e = require('./exec.js');
|
|
184
|
+
const ids = [];
|
|
185
|
+
const failed = [];
|
|
186
|
+
for (const s of sessions) {
|
|
187
|
+
const pre = `${s.id}.pre`;
|
|
188
|
+
const old = store.readSession(pre);
|
|
189
|
+
if (old) {
|
|
190
|
+
if (store.viewSession(old).live) die(7, `preflight session "${pre}" is still running; cancel it first (zcode cancel ${pre}).`);
|
|
191
|
+
store.deleteSession(pre);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
e.startDetached(plans.sessionInvocation(r, name, s, { id: pre, preflight: true }));
|
|
195
|
+
ids.push(pre);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err instanceof e.UsageError || err.exitCode || err instanceof store.SessionExistsError) {
|
|
198
|
+
failed.push(plans.launchFailure(pre, err.message));
|
|
199
|
+
} else throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const { finalResult } = require('./sessions.js');
|
|
203
|
+
const deadline = Date.now() + (given.has('timeoutMs') ? flags.timeoutMs : 600000);
|
|
204
|
+
const pending = new Set(ids);
|
|
205
|
+
const results = [...failed];
|
|
206
|
+
for (;;) {
|
|
207
|
+
for (const id of [...pending]) {
|
|
208
|
+
const view = store.viewSession(store.readSession(id));
|
|
209
|
+
if (view.state === 'finished' || view.state === 'interrupted') {
|
|
210
|
+
pending.delete(id);
|
|
211
|
+
results.push(finalResult(view));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!pending.size) break;
|
|
215
|
+
if (Date.now() >= deadline) {
|
|
216
|
+
for (const id of pending) {
|
|
217
|
+
const v = store.viewSession(store.readSession(id));
|
|
218
|
+
out(flags.json ? JSON.stringify({ type: 'pending', id, state: v.state, phase: v.phase }) : `${id} ${v.state} ${v.phase}`);
|
|
219
|
+
}
|
|
220
|
+
process.exit(124);
|
|
221
|
+
}
|
|
222
|
+
await plans.sleep(plans.POLL_MS);
|
|
223
|
+
}
|
|
224
|
+
const asked = results.filter((x) => x.questions && x.questions.length);
|
|
225
|
+
const broken = results.filter((x) => !(x.questions && x.questions.length) && x.status !== 'completed');
|
|
226
|
+
for (const x of [...asked, ...broken]) {
|
|
227
|
+
if (flags.json) out(JSON.stringify(buildBrief(x)));
|
|
228
|
+
else out(formatResult(x, { color: useColor(process.stdout) }));
|
|
229
|
+
}
|
|
230
|
+
if (!asked.length && !broken.length) {
|
|
231
|
+
if (flags.json) out(JSON.stringify({ type: 'preflight', name, status: 'completed', sessions: ids.length }));
|
|
232
|
+
else out(`plan ${name}: preflight found no open questions (${ids.length} session${ids.length === 1 ? '' : 's'})`);
|
|
233
|
+
}
|
|
234
|
+
process.exit(asked.length ? 10 : broken.length ? Math.max(...broken.map((x) => exitCodeFor(x))) : 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function resume(parsed, name, words) {
|
|
238
|
+
const { flags, given } = parsed;
|
|
239
|
+
const { state, r } = open(name);
|
|
240
|
+
const text = words.join(' ').trim();
|
|
241
|
+
const statuses = plans.sessionStatuses(r, state);
|
|
242
|
+
const RESUMABLE = new Set(['failed', 'partial', 'unverified', 'blocked', 'cancelled', 'interrupted']);
|
|
243
|
+
let targets;
|
|
244
|
+
if (flags.session) {
|
|
245
|
+
const e = statuses.get(flags.session);
|
|
246
|
+
if (!e) die(2, `plan "${name}" has no session "${flags.session}".`);
|
|
247
|
+
if (!RESUMABLE.has(e.status) || !e.view) die(2, `session "${flags.session}" is ${e.status}; only failed, partial, unverified, blocked, cancelled or interrupted sessions resume.`);
|
|
248
|
+
targets = [flags.session];
|
|
249
|
+
} else {
|
|
250
|
+
targets = plans.sessionList(r.plan).map((s) => s.id).filter((id) => RESUMABLE.has(statuses.get(id).status) && statuses.get(id).view);
|
|
251
|
+
}
|
|
252
|
+
const blocked = targets.filter((id) => statuses.get(id).status === 'blocked');
|
|
253
|
+
if (blocked.length && !text) {
|
|
254
|
+
die(2, `${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} blocked: answer the questions: zcode plan resume ${name} "…" (or --session <id> "…" for one session)`);
|
|
255
|
+
}
|
|
256
|
+
if (!targets.length && !flags.session && ![...statuses.values()].some((x) => x.status === 'pending' || x.status === 'skipped')) {
|
|
257
|
+
// nothing to resume: report as is
|
|
258
|
+
return finish(plans.planReport(name, r, state), flags);
|
|
259
|
+
}
|
|
260
|
+
const message = text || 'Continue: fix the remaining issues.';
|
|
261
|
+
for (const id of targets) {
|
|
262
|
+
const refusal = plans.resumeSession(id, message);
|
|
263
|
+
if (refusal) die(7, refusal);
|
|
264
|
+
}
|
|
265
|
+
// Launch failures recorded earlier are retried by the supervisor.
|
|
266
|
+
if (state.launchErrors && Object.keys(state.launchErrors).length) {
|
|
267
|
+
store.updatePlanState(name, { launchErrors: {} });
|
|
268
|
+
}
|
|
269
|
+
return startSupervision(name, flags, given);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Detached supervisor process: `zcode __plan <name>`.
|
|
273
|
+
async function supervisorWorker(name) {
|
|
274
|
+
if (!store.readPlanState(name)) die(2, `no plan "${name}".`);
|
|
275
|
+
await plans.supervise(name);
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = async function plan(parsed) {
|
|
280
|
+
if (parsed.command === '__plan') return supervisorWorker(parsed.prompts[0]);
|
|
281
|
+
const [sub, ...rest] = parsed.prompts;
|
|
282
|
+
switch (sub) {
|
|
283
|
+
case 'check':
|
|
284
|
+
return check(parsed, rest[0]);
|
|
285
|
+
case 'run':
|
|
286
|
+
return run(parsed, rest[0]);
|
|
287
|
+
case 'wait':
|
|
288
|
+
case 'status': {
|
|
289
|
+
if (!rest[0]) die(2, `plan ${sub} needs a plan name.`);
|
|
290
|
+
open(rest[0]);
|
|
291
|
+
if (sub === 'wait') return waitFor(rest[0], parsed.flags, parsed.given);
|
|
292
|
+
print(reportOf(rest[0]), parsed.flags);
|
|
293
|
+
process.exit(0);
|
|
294
|
+
}
|
|
295
|
+
case 'resume':
|
|
296
|
+
if (!rest[0]) die(2, 'plan resume needs a plan name.');
|
|
297
|
+
return resume(parsed, rest[0], rest.slice(1));
|
|
298
|
+
default:
|
|
299
|
+
return die(2, 'plan needs a subcommand: check | run | wait | status | resume (zcode --help).');
|
|
300
|
+
}
|
|
301
|
+
};
|