@0xgeorgemathew/zcode 0.4.1 → 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 +81 -15
- package/bin/zcode.js +126 -20
- package/lib/appserver.js +16 -1
- 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 +101 -35
- package/lib/commands/mcp-list.js +1 -1
- package/lib/commands/plan.js +301 -0
- package/lib/commands/sessions.js +60 -11
- package/lib/commands/skills.js +39 -0
- package/lib/contract.js +220 -17
- package/lib/executor.js +142 -34
- package/lib/isolation.js +36 -4
- package/lib/models.js +84 -0
- package/lib/plan.js +424 -0
- package/lib/ports.js +45 -0
- package/lib/skills.js +64 -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 -2
- package/skills/README.md +9 -3
- package/skills/claude-code/zcode/SKILL.md +104 -74
- package/skills/codex/zcode/SKILL.md +104 -76
- package/lib/install-skills.js +0 -54
package/lib/commands/doctor.js
CHANGED
|
@@ -8,6 +8,7 @@ const { sha256File, engineVersion } = require('../engine.js');
|
|
|
8
8
|
const { resolveRuntime, ConfigError } = require('../bootstrap.js');
|
|
9
9
|
const { buildEngineHome, engineEnv, rootDir, liveDir, effectiveMcpServers } = require('../isolation.js');
|
|
10
10
|
const store = require('../store.js');
|
|
11
|
+
const { modelLevels } = require('../models.js');
|
|
11
12
|
|
|
12
13
|
module.exports = async function doctor(parsed) {
|
|
13
14
|
const flags = parsed.flags;
|
|
@@ -51,6 +52,7 @@ module.exports = async function doctor(parsed) {
|
|
|
51
52
|
version,
|
|
52
53
|
builtinProviderConfig: builtinConfig,
|
|
53
54
|
},
|
|
55
|
+
models: modelLevels({ builtinConfig }),
|
|
54
56
|
isolation: {
|
|
55
57
|
root,
|
|
56
58
|
home: built.home,
|
|
@@ -83,6 +85,7 @@ module.exports = async function doctor(parsed) {
|
|
|
83
85
|
` sha256 ${report.engine.sha256}`,
|
|
84
86
|
` version ${version ?? 'unknown'}`,
|
|
85
87
|
` builtin provider config ${builtinConfig}`,
|
|
88
|
+
` models ${Object.entries(report.models).map(([id, l]) => `${id} (effort ${l.join('|')})`).join(', ')}`,
|
|
86
89
|
` isolation root ${root} (one engine HOME per session; config mode: ${built.mode})`,
|
|
87
90
|
` skills inherited ${built.skillsCopied}, mcp servers available with --mcp-inherit ${report.isolation.mcpServersInherited}`,
|
|
88
91
|
` sessions ${report.sessions.total} (${report.sessions.running} running, ${report.sessions.interrupted} interrupted)`,
|
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(),
|
|
@@ -77,6 +89,7 @@ function buildInvocation(parsed) {
|
|
|
77
89
|
acceptanceDeclared: false,
|
|
78
90
|
mcpSpecs: flags.mcpSpecs,
|
|
79
91
|
mcpInherit: flags.mcpInherit,
|
|
92
|
+
noBrowser: flags.noBrowser,
|
|
80
93
|
skillsDirs: flags.skillsDirs.map((d) => path.resolve(d)),
|
|
81
94
|
noSkills: flags.noSkills,
|
|
82
95
|
profile: flags.profile,
|
|
@@ -87,19 +100,30 @@ function buildInvocation(parsed) {
|
|
|
87
100
|
|
|
88
101
|
if (kind === 'task') {
|
|
89
102
|
const objective = prompts.join(' ');
|
|
90
|
-
let base = loadTaskBase(flags.task);
|
|
91
|
-
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) {
|
|
92
105
|
base = { objective: readStdin().trim() };
|
|
93
106
|
}
|
|
94
107
|
let contract;
|
|
95
108
|
try {
|
|
96
|
-
contract =
|
|
109
|
+
contract = buildContract({
|
|
110
|
+
base,
|
|
111
|
+
baseDir: dir || process.cwd(),
|
|
112
|
+
cwd: inv.cwd,
|
|
97
113
|
objective,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
},
|
|
103
127
|
});
|
|
104
128
|
} catch (err) {
|
|
105
129
|
if (err instanceof ContractError) usage(err.message);
|
|
@@ -110,6 +134,7 @@ function buildInvocation(parsed) {
|
|
|
110
134
|
inv.model = normalizeModel(contract.model) || usage(`contract model "${contract.model}" is not supported.`);
|
|
111
135
|
}
|
|
112
136
|
if (contract.effort && !parsed.given.has('effort')) {
|
|
137
|
+
effortSource = 'contract effort';
|
|
113
138
|
inv.effort = normalizeEffort(contract.effort) || usage(`contract effort "${contract.effort}" is not valid.`);
|
|
114
139
|
}
|
|
115
140
|
const lim = contract.limits;
|
|
@@ -119,7 +144,9 @@ function buildInvocation(parsed) {
|
|
|
119
144
|
inv.contract = contract;
|
|
120
145
|
inv.checks = contract.checks;
|
|
121
146
|
inv.ownsDisplay = contract.owns;
|
|
147
|
+
inv.touchDisplay = contract.touch;
|
|
122
148
|
inv.acceptanceDeclared = contract.acceptance.length > 0;
|
|
149
|
+
inv.preflight = Boolean(flags.preflight);
|
|
123
150
|
} else {
|
|
124
151
|
let prompt = prompts.join(' ');
|
|
125
152
|
if (!prompt && kind === 'run' && !process.stdin.isTTY) prompt = readStdin();
|
|
@@ -127,8 +154,14 @@ function buildInvocation(parsed) {
|
|
|
127
154
|
inv.prompt = prompt;
|
|
128
155
|
}
|
|
129
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
|
+
|
|
130
161
|
try {
|
|
131
|
-
|
|
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');
|
|
132
165
|
} catch (err) {
|
|
133
166
|
if (err instanceof OwnershipError) usage(err.message);
|
|
134
167
|
throw err;
|
|
@@ -144,6 +177,10 @@ function buildResume(parsed, ref, message) {
|
|
|
144
177
|
if (!settings) usage(`session "${id}" has no stored settings; it cannot be resumed.`);
|
|
145
178
|
const { flags, given } = parsed;
|
|
146
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);
|
|
147
184
|
return {
|
|
148
185
|
...settings,
|
|
149
186
|
id,
|
|
@@ -167,23 +204,19 @@ function readTask(id) {
|
|
|
167
204
|
}
|
|
168
205
|
}
|
|
169
206
|
|
|
170
|
-
//
|
|
171
|
-
// (`zcode __exec <id>`)
|
|
172
|
-
//
|
|
173
|
-
|
|
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) {
|
|
174
213
|
if (inv.resume) {
|
|
175
214
|
const view = store.viewSession(store.readSession(inv.id));
|
|
176
215
|
if (view && view.live) {
|
|
177
|
-
|
|
178
|
-
process.exit(7);
|
|
216
|
+
throw new ExecError(`session "${inv.id}" is still running (pid ${view.pid}).`, { exitCode: 7, kind: 'busy' });
|
|
179
217
|
}
|
|
180
218
|
} else {
|
|
181
|
-
|
|
182
|
-
prepareSession(inv);
|
|
183
|
-
} catch (err) {
|
|
184
|
-
if (err instanceof store.SessionExistsError) usage(err.message);
|
|
185
|
-
throw err;
|
|
186
|
-
}
|
|
219
|
+
prepareSession(inv);
|
|
187
220
|
}
|
|
188
221
|
const dir = store.sessionDir(inv.id);
|
|
189
222
|
store.writeJsonAtomic(path.join(dir, 'pending.json'), inv);
|
|
@@ -199,8 +232,10 @@ function detach(inv, flags) {
|
|
|
199
232
|
env: process.env,
|
|
200
233
|
});
|
|
201
234
|
child.unref();
|
|
235
|
+
fs.closeSync(out);
|
|
236
|
+
fs.closeSync(err);
|
|
202
237
|
store.updateSession(inv.id, { state: 'queued', phase: 'queued', pid: child.pid, status: null });
|
|
203
|
-
|
|
238
|
+
return {
|
|
204
239
|
type: 'detached',
|
|
205
240
|
id: inv.id,
|
|
206
241
|
pid: child.pid,
|
|
@@ -208,17 +243,32 @@ function detach(inv, flags) {
|
|
|
208
243
|
status: 'queued',
|
|
209
244
|
files: { session: dir, stdout: path.join(dir, 'stdout.ndjson'), stderr: path.join(dir, 'stderr.log') },
|
|
210
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
|
+
}
|
|
211
261
|
if (flags.json) process.stdout.write(JSON.stringify(handle) + '\n');
|
|
212
262
|
else {
|
|
213
263
|
process.stdout.write(`${inv.id}\n`);
|
|
214
264
|
if (!flags.quiet) {
|
|
215
|
-
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`);
|
|
216
266
|
}
|
|
217
267
|
}
|
|
218
268
|
process.exit(0);
|
|
219
269
|
}
|
|
220
270
|
|
|
221
|
-
|
|
271
|
+
async function exec(parsed) {
|
|
222
272
|
const { command, flags } = parsed;
|
|
223
273
|
let inv;
|
|
224
274
|
let worker = false;
|
|
@@ -250,6 +300,7 @@ module.exports = async function exec(parsed) {
|
|
|
250
300
|
// Workers always emit NDJSON (to their stdout file) and never a TTY feed.
|
|
251
301
|
const json = worker || flags.json;
|
|
252
302
|
const quiet = flags.quiet && !worker;
|
|
303
|
+
const full = flags.full && !worker;
|
|
253
304
|
const kind = inv.kind;
|
|
254
305
|
const reporter = makeReporter({
|
|
255
306
|
quiet,
|
|
@@ -294,7 +345,7 @@ module.exports = async function exec(parsed) {
|
|
|
294
345
|
type: 'result', ok: false, status: 'failed', id: inv.id, kind,
|
|
295
346
|
error: { kind: err.kind || 'config', message: err.message }, issues: [err.message],
|
|
296
347
|
};
|
|
297
|
-
if (json) process.stdout.write(JSON.stringify(failed) + '\n');
|
|
348
|
+
if (json) process.stdout.write(JSON.stringify(full ? failed : buildBrief(failed)) + '\n');
|
|
298
349
|
// A detached worker's caller polls the store: record why it never ran
|
|
299
350
|
// (conflicts already record themselves).
|
|
300
351
|
if (worker && err.kind !== 'conflict' && store.readSession(inv.id)) {
|
|
@@ -310,7 +361,7 @@ module.exports = async function exec(parsed) {
|
|
|
310
361
|
|
|
311
362
|
const code = exitCodeFor(result);
|
|
312
363
|
if (json) {
|
|
313
|
-
process.stdout.write(JSON.stringify(result) + '\n');
|
|
364
|
+
process.stdout.write(JSON.stringify(full ? result : buildBrief(result)) + '\n');
|
|
314
365
|
} else if (kind === 'task') {
|
|
315
366
|
reporter.closeThinking();
|
|
316
367
|
process.stdout.write(formatResult(result, { color: useColor(process.stdout) }));
|
|
@@ -324,4 +375,19 @@ module.exports = async function exec(parsed) {
|
|
|
324
375
|
}
|
|
325
376
|
}
|
|
326
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
|
+
}
|
|
327
388
|
};
|
|
389
|
+
|
|
390
|
+
module.exports.buildInvocation = buildInvocation;
|
|
391
|
+
module.exports.buildResume = buildResume;
|
|
392
|
+
module.exports.startDetached = startDetached;
|
|
393
|
+
module.exports.UsageError = UsageError;
|
package/lib/commands/mcp-list.js
CHANGED
|
@@ -6,7 +6,7 @@ const { effectiveMcpServers } = require('../isolation.js');
|
|
|
6
6
|
|
|
7
7
|
module.exports = async function mcpList(parsed) {
|
|
8
8
|
const { flags } = parsed;
|
|
9
|
-
const servers = effectiveMcpServers({ inherit: flags.mcpInherit, mcpSpecs: flags.mcpSpecs });
|
|
9
|
+
const servers = effectiveMcpServers({ inherit: flags.mcpInherit, browser: !flags.noBrowser, mcpSpecs: flags.mcpSpecs });
|
|
10
10
|
process.stdout.write(JSON.stringify({ type: 'mcp', servers }) + '\n');
|
|
11
11
|
process.exit(0);
|
|
12
12
|
};
|
|
@@ -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
|
+
};
|