@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.
@@ -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). stderr = diagnostics only.
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 { normalizeContract, ContractError } = require('../contract.js');
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
- process.stderr.write(`zcode: ${message}\n`);
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
- function buildInvocation(parsed) {
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 = normalizeContract(base, {
109
+ contract = buildContract({
110
+ base,
111
+ baseDir: dir || process.cwd(),
112
+ cwd: inv.cwd,
98
113
  objective,
99
- acceptance: flags.accept,
100
- checks: flags.check,
101
- files: flags.file,
102
- context: flags.context,
103
- owns: flags.owns,
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
- inv.owns = normalizeOwns(inv.ownsDisplay, inv.cwd, workspaceRoot(inv.cwd));
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
- // --detach: claim the id, hand the invocation to a detached worker
172
- // (`zcode __exec <id>`), print the handle, exit 0. The worker writes its
173
- // NDJSON to <session>/stdout.ndjson and diagnostics to stderr.log.
174
- function detach(inv, flags) {
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
- process.stderr.write(`zcode: session "${inv.id}" is still running (pid ${view.pid}).\n`);
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
- try {
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
- const handle = {
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 ${child.pid}). zcode status ${inv.id} · zcode wait ${inv.id}\n`);
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
- module.exports = async function exec(parsed) {
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
+ };