@welluable/orch 1.3.0 → 1.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.
@@ -1,4 +1,4 @@
1
- import { Agent } from './agent.js';
1
+ import { Agent, appendVerbose } from './agent.js';
2
2
  import { normalizeCursorToolEvent } from './tool-status.js';
3
3
 
4
4
  export class AgentCursor extends Agent {
@@ -26,8 +26,10 @@ export class AgentCursor extends Agent {
26
26
  case 'thinking': {
27
27
  switch (event.subtype) {
28
28
  case 'delta': {
29
+ const text = event.text ?? '';
30
+ appendVerbose(text);
29
31
  if (verbose) {
30
- process.stderr.write(event.text ?? '');
32
+ process.stderr.write(text);
31
33
  } else if (this.activeTools.size === 0) {
32
34
  this.setStatus('thinking…');
33
35
  }
@@ -0,0 +1,177 @@
1
+ import { Agent, modelPrintState, appendVerbose } from './agent.js';
2
+ import { normalizeOpencodeToolEvent } from './tool-status.js';
3
+
4
+ const OPENCODE_DENY_PERMISSION = '{"edit":"deny","bash":"deny"}';
5
+
6
+ export class AgentOpencode extends Agent {
7
+ #textBuffer = '';
8
+ #sawStepStart = false;
9
+ #pendingStop = false;
10
+ #settled = false;
11
+
12
+ getSpawnConfig(promptToSend) {
13
+ if (this.readOnly) {
14
+ return {
15
+ command: 'opencode',
16
+ args: ['run', '--format', 'json', '--auto', '--thinking', '--agent', 'plan', promptToSend],
17
+ options: {
18
+ cwd: this.cwd,
19
+ stdio: ['ignore', 'pipe', 'pipe'],
20
+ env: { ...process.env, OPENCODE_PERMISSION: OPENCODE_DENY_PERMISSION },
21
+ },
22
+ };
23
+ }
24
+
25
+ return {
26
+ command: 'opencode',
27
+ args: ['run', '--format', 'json', '--auto', '--thinking', promptToSend],
28
+ options: {
29
+ cwd: this.cwd,
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ env: process.env,
32
+ },
33
+ };
34
+ }
35
+
36
+ #printModelOnce(modelId) {
37
+ if (modelPrintState.printed || typeof modelId !== 'string' || !modelId) return;
38
+ modelPrintState.printed = true;
39
+ const wasSpinning = this.spinner?.isSpinning;
40
+ if (wasSpinning) this.spinner.stop?.();
41
+ console.log(`model: ${modelId}`);
42
+ if (wasSpinning) this.spinner.start?.();
43
+ }
44
+
45
+ #printUsageLine(part) {
46
+ const tokens = part?.tokens;
47
+ if (!tokens || typeof tokens !== 'object') return;
48
+ const input = tokens.input ?? tokens.in;
49
+ const output = tokens.output ?? tokens.out;
50
+ if (typeof input !== 'number' && typeof output !== 'number') return;
51
+
52
+ let line = ` tokens: in=${input ?? 0} out=${output ?? 0}`;
53
+ const cost = part.cost;
54
+ if (typeof cost === 'number' && cost > 0) {
55
+ line += ` · cost=$${cost}`;
56
+ }
57
+ process.stderr.write(`${line}\n`);
58
+ }
59
+
60
+ #settleOk(finish, { verbose = false, part = null } = {}) {
61
+ if (this.#settled) return;
62
+ this.#settled = true;
63
+ this.#pendingStop = false;
64
+ this.settleResult(
65
+ {
66
+ is_error: false,
67
+ result: this.#textBuffer,
68
+ duration_ms: undefined,
69
+ },
70
+ finish,
71
+ );
72
+ if (verbose && part) this.#printUsageLine(part);
73
+ }
74
+
75
+ #recordApplyPatchFiles(toolEvent) {
76
+ if (toolEvent.name !== 'apply_patch' || !this.fileTracker) return;
77
+ const entries = this.fileTracker.recordApplyPatch(toolEvent.args?.patchText);
78
+ for (const entry of entries) {
79
+ if (!entry.isNew) continue;
80
+ const wasSpinning = this.spinner?.isSpinning;
81
+ if (wasSpinning) this.spinner.stop();
82
+ console.log(` ${entry.marker} ${entry.path}`);
83
+ if (wasSpinning) this.spinner.start();
84
+ this.onFileChange?.(entry);
85
+ }
86
+ }
87
+
88
+ handleStreamEvent(event, { verbose, finish }) {
89
+ switch (event.type) {
90
+ case 'message.updated': {
91
+ this.#printModelOnce(event.properties?.info?.modelID);
92
+ break;
93
+ }
94
+ case 'step_start': {
95
+ if (!this.#sawStepStart) {
96
+ this.#sawStepStart = true;
97
+ this.setStatus('connected');
98
+ } else if (this.activeTools.size === 0) {
99
+ this.setStatus('thinking…');
100
+ }
101
+ break;
102
+ }
103
+ case 'tool_use': {
104
+ const toolEvent = normalizeOpencodeToolEvent(event);
105
+ if (!toolEvent) break;
106
+ this.onToolEvent({ ...toolEvent, phase: 'started' });
107
+ this.onToolEvent(toolEvent);
108
+ this.#recordApplyPatchFiles(toolEvent);
109
+ break;
110
+ }
111
+ case 'reasoning': {
112
+ const text = event.part?.text ?? '';
113
+ appendVerbose(text);
114
+ if (verbose) {
115
+ process.stderr.write(text);
116
+ } else if (this.activeTools.size === 0) {
117
+ this.setStatus('thinking…');
118
+ }
119
+ break;
120
+ }
121
+ case 'text': {
122
+ const text = event.part?.text ?? '';
123
+ this.#textBuffer += text;
124
+ appendVerbose(text);
125
+ if (verbose) {
126
+ process.stderr.write(text);
127
+ } else if (this.activeTools.size === 0) {
128
+ this.setStatus('composing response…');
129
+ }
130
+ if (this.#pendingStop && this.#textBuffer !== '') {
131
+ this.#settleOk(finish, { verbose });
132
+ }
133
+ break;
134
+ }
135
+ case 'step_finish': {
136
+ const reason = event.part?.reason;
137
+ if (reason === 'tool-calls') break;
138
+ if (reason === 'stop' || reason == null) {
139
+ if (this.#textBuffer !== '') {
140
+ this.#settleOk(finish, { verbose, part: event.part });
141
+ } else {
142
+ this.#pendingStop = true;
143
+ }
144
+ }
145
+ break;
146
+ }
147
+ case 'error': {
148
+ if (this.#settled) break;
149
+ this.#settled = true;
150
+ this.#pendingStop = false;
151
+ const message =
152
+ event.error?.data?.message ||
153
+ event.error?.name ||
154
+ 'OpenCode error';
155
+ this.settleResult(
156
+ {
157
+ is_error: true,
158
+ result: message,
159
+ },
160
+ finish,
161
+ );
162
+ break;
163
+ }
164
+ default:
165
+ break;
166
+ }
167
+ }
168
+
169
+ handleProcessClose(code, finish) {
170
+ if (this.#settled) return;
171
+ if (code === 0 && this.#textBuffer !== '') {
172
+ this.#settleOk(finish);
173
+ return;
174
+ }
175
+ super.handleProcessClose(code, finish);
176
+ }
177
+ }
package/lib/agent.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import ora from 'ora';
4
- import { formatActiveTools } from './tool-status.js';
5
- import { patchJob, buildLastOutcome } from './jobs.js';
4
+ import { formatActiveTools, toolPath } from './tool-status.js';
5
+ import { patchJob, buildLastOutcome, jobPaths, readJob } from './jobs.js';
6
+ import {
7
+ appendFailureLog,
8
+ failureLogPointer,
9
+ formatFailureSection,
10
+ } from './failure-log.js';
6
11
 
7
12
  /**
8
13
  * Every child process spawned by an agent, tracked so we can reap them all
@@ -43,6 +48,116 @@ export function setJobSlug(slug) {
43
48
  activeJobSlug = slug;
44
49
  }
45
50
 
51
+ /** Active job slug: env (detached child) wins over in-process foreground slug. */
52
+ export function getActiveJobSlug() {
53
+ return process.env.ORCH_JOB_SLUG ?? activeJobSlug;
54
+ }
55
+
56
+ /** Current stage capture + prior-stage ring for failure.log (independent of -v). */
57
+ let currentCapture = null;
58
+ /** @type {{ phase: *, stage: *, round: *, verbose: string }[]} */
59
+ let priorCaptures = [];
60
+ const PRIOR_RING_MAX = 8;
61
+
62
+ /** Clear failure-log buffers (also invoked from `resetShutdownState`). */
63
+ export function resetFailureLogState() {
64
+ currentCapture = null;
65
+ priorCaptures = [];
66
+ }
67
+
68
+ /**
69
+ * Start capturing verbose for a stage. Rotates a non-empty current buffer into
70
+ * the prior-stage ring so recover can see more than the final frame.
71
+ */
72
+ export function beginStageCapture({ phase = null, stage = null, round = null } = {}) {
73
+ if (currentCapture && currentCapture.verbose) {
74
+ priorCaptures.push({
75
+ phase: currentCapture.phase,
76
+ stage: currentCapture.stage,
77
+ round: currentCapture.round,
78
+ verbose: currentCapture.verbose,
79
+ });
80
+ if (priorCaptures.length > PRIOR_RING_MAX) {
81
+ priorCaptures.splice(0, priorCaptures.length - PRIOR_RING_MAX);
82
+ }
83
+ }
84
+ currentCapture = { phase, stage, round, verbose: '' };
85
+ }
86
+
87
+ /**
88
+ * Append a verbose chunk when a job slug is active. Never gated on `-v`
89
+ * (stderr streaming stays verbose-gated in the backends).
90
+ */
91
+ export function appendVerbose(chunk) {
92
+ if (!getActiveJobSlug()) return;
93
+ if (chunk == null || chunk === '') return;
94
+ if (!currentCapture) {
95
+ currentCapture = { phase: null, stage: null, round: null, verbose: '' };
96
+ }
97
+ currentCapture.verbose += String(chunk);
98
+ }
99
+
100
+ /**
101
+ * Append one `=== orch failure ===` section to `.orch/<slug>/failure.log`,
102
+ * patch `run.json.failureLogPath`, and return a pointer string. No-op when
103
+ * `slug` is falsy.
104
+ */
105
+ export function flushFailureLog({
106
+ cwd,
107
+ slug,
108
+ state,
109
+ exitCode = null,
110
+ finishedAt,
111
+ task = null,
112
+ error = null,
113
+ } = {}) {
114
+ if (!slug) return null;
115
+
116
+ const resolvedCwd = cwd ?? process.cwd();
117
+ let phase = currentCapture?.phase ?? null;
118
+ let stage = currentCapture?.stage ?? null;
119
+ let round = currentCapture?.round ?? null;
120
+ let resolvedTask = task;
121
+
122
+ if (phase == null || stage == null || round == null || resolvedTask == null) {
123
+ try {
124
+ const record = readJob(resolvedCwd, slug);
125
+ if (record) {
126
+ if (phase == null) phase = record.phase ?? null;
127
+ if (stage == null) stage = record.stage ?? null;
128
+ if (round == null) round = record.round ?? null;
129
+ if (resolvedTask == null) resolvedTask = record.task ?? null;
130
+ }
131
+ } catch {
132
+ // Best-effort cursor fill from run.json.
133
+ }
134
+ }
135
+
136
+ const { failureLogPath } = jobPaths(resolvedCwd, slug);
137
+ const section = formatFailureSection({
138
+ slug,
139
+ state,
140
+ phase,
141
+ stage,
142
+ round,
143
+ exitCode,
144
+ finishedAt,
145
+ task: resolvedTask,
146
+ error,
147
+ stageVerbose: currentCapture?.verbose ?? '',
148
+ priorStages: priorCaptures,
149
+ });
150
+ appendFailureLog(failureLogPath, section);
151
+
152
+ try {
153
+ patchJob(resolvedCwd, slug, { failureLogPath });
154
+ } catch {
155
+ // Best-effort: file is already on disk even if the patch fails.
156
+ }
157
+
158
+ return failureLogPointer(slug);
159
+ }
160
+
46
161
  /** Conventional shell status: 128 + signal number. */
47
162
  export function exitCodeForSignal(signal) {
48
163
  if (signal === 'SIGINT') return 130;
@@ -69,6 +184,14 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
69
184
  try {
70
185
  const exitCode = exitCodeForSignal(signal);
71
186
  const finishedAt = new Date().toISOString();
187
+ const pointer = flushFailureLog({
188
+ cwd: jobCwd,
189
+ slug: jobSlug,
190
+ state: 'stopped',
191
+ exitCode,
192
+ finishedAt,
193
+ error: signal,
194
+ });
72
195
  patchJob(jobCwd, jobSlug, (current) => ({
73
196
  state: 'stopped',
74
197
  exitCode,
@@ -82,7 +205,7 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
82
205
  finishedAt,
83
206
  task: current.task,
84
207
  summary: '',
85
- error: null,
208
+ error: pointer,
86
209
  }),
87
210
  }));
88
211
  } catch {
@@ -125,6 +248,7 @@ export function resetShutdownState() {
125
248
  shuttingDown = false;
126
249
  liveChildren.clear();
127
250
  activeJobSlug = null;
251
+ resetFailureLogState();
128
252
  }
129
253
 
130
254
  /**
@@ -209,10 +333,14 @@ export class Agent {
209
333
  this.refreshSpinnerText();
210
334
  }
211
335
 
212
- /** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} toolEvent */
213
- onToolEvent({ name, args, phase, callId }) {
336
+ /** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string, title?: string }} toolEvent */
337
+ onToolEvent({ name, args, phase, callId, title }) {
214
338
  if (phase === 'started') {
215
- this.activeTools.set(callId, { name, args });
339
+ this.activeTools.set(callId, {
340
+ name,
341
+ args,
342
+ ...(typeof title === 'string' && title ? { title } : {}),
343
+ });
216
344
  this.fileTracker?.record({ name, args, phase, callId });
217
345
  } else if (phase === 'completed') {
218
346
  // Recall before delete — Claude/agn completions often have empty args.
@@ -220,7 +348,7 @@ export class Agent {
220
348
  this.activeTools.delete(callId);
221
349
 
222
350
  if (this.fileTracker) {
223
- const hasPath = Boolean(args?.path || args?.file_path);
351
+ const hasPath = Boolean(toolPath(args));
224
352
  const entry = this.fileTracker.record({
225
353
  name: name || prior?.name || '',
226
354
  args: hasPath ? args : (prior?.args ?? args ?? {}),
@@ -279,6 +407,20 @@ export class Agent {
279
407
  throw new Error('handleStreamEvent must be implemented by subclass');
280
408
  }
281
409
 
410
+ /**
411
+ * Called when the child exits before a settling stream event.
412
+ * Subclasses may override (e.g. defensive success on exit 0 + buffered text).
413
+ * @param {number|null} code
414
+ * @param {(err: Error|null, value?: object) => void} finish
415
+ */
416
+ handleProcessClose(code, finish) {
417
+ this.stopElapsedTimer();
418
+ if (this.spinner?.isSpinning) {
419
+ this.spinner.fail(`[${this.name}] exited ${code}`);
420
+ }
421
+ finish(new Error(`[${this.name}] exited ${code} before result`));
422
+ }
423
+
282
424
  settleResult(event, finish) {
283
425
  this.stopElapsedTimer();
284
426
  this.activeTools.clear();
@@ -376,11 +518,7 @@ export class Agent {
376
518
  liveChildren.delete(child);
377
519
  this.process = null;
378
520
  if (!settled) {
379
- this.stopElapsedTimer();
380
- if (this.spinner?.isSpinning) {
381
- this.spinner.fail(`[${this.name}] exited ${code}`);
382
- }
383
- finish(new Error(`[${this.name}] exited ${code} before result`));
521
+ this.handleProcessClose(code, finish);
384
522
  }
385
523
  });
386
524
  });
package/lib/config.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
- const VALID_AGENTS = new Set(['cursor', 'claude', 'agn']);
5
+ const VALID_AGENTS = new Set(['cursor', 'claude', 'agn', 'opencode']);
6
6
 
7
7
  /** Absolute path to the global orch config file. */
8
8
  export function globalConfigPath({ homedir = os.homedir() } = {}) {
@@ -16,8 +16,8 @@ export function localConfigPath(cwd) {
16
16
 
17
17
  /**
18
18
  * Read a config file. Missing file → `{}`. Bad JSON, unreadable file, or
19
- * invalid `agent` → throws with a message suitable for `Error: …` on stderr.
20
- * Unknown keys are ignored; `agent` is case-sensitive.
19
+ * invalid `agent` / `notify` → throws with a message suitable for `Error: …`
20
+ * on stderr. Unknown keys are ignored; `agent` is case-sensitive.
21
21
  */
22
22
  export function loadConfig(configPath, displayPath = configPath) {
23
23
  if (!fs.existsSync(configPath)) return {};
@@ -40,17 +40,27 @@ export function loadConfig(configPath, displayPath = configPath) {
40
40
  throw new Error(`could not parse ${displayPath}: expected a JSON object`);
41
41
  }
42
42
 
43
- if (!Object.prototype.hasOwnProperty.call(data, 'agent') || data.agent === undefined) {
44
- return {};
43
+ const out = {};
44
+
45
+ if (Object.prototype.hasOwnProperty.call(data, 'agent') && data.agent !== undefined) {
46
+ if (!VALID_AGENTS.has(data.agent)) {
47
+ throw new Error(
48
+ `invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", "agn", or "opencode")`,
49
+ );
50
+ }
51
+ out.agent = data.agent;
45
52
  }
46
53
 
47
- if (!VALID_AGENTS.has(data.agent)) {
48
- throw new Error(
49
- `invalid agent in ${displayPath}: ${JSON.stringify(data.agent)} (expected "cursor", "claude", or "agn")`,
50
- );
54
+ if (Object.prototype.hasOwnProperty.call(data, 'notify') && data.notify !== undefined) {
55
+ if (typeof data.notify !== 'boolean') {
56
+ throw new Error(
57
+ `invalid notify in ${displayPath}: ${JSON.stringify(data.notify)} (expected true or false)`,
58
+ );
59
+ }
60
+ out.notify = data.notify;
51
61
  }
52
62
 
53
- return { agent: data.agent };
63
+ return out;
54
64
  }
55
65
 
56
66
  /**
@@ -70,23 +80,74 @@ export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
70
80
  }
71
81
 
72
82
  /**
73
- * Create parent dirs if needed, overwrite `config` with pretty-printed JSON,
83
+ * Effective notify for a run: CLI > local > global > `true`.
84
+ * `cliNotify` is `true` | `false` | `undefined` (flag omitted).
85
+ */
86
+ export function resolveNotify({ cliNotify, cwd, homedir = os.homedir() } = {}) {
87
+ if (cliNotify === true || cliNotify === false) return cliNotify;
88
+
89
+ const local = loadConfig(localConfigPath(cwd), '.orch/config');
90
+ if (typeof local.notify === 'boolean') return local.notify;
91
+
92
+ const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
93
+ if (typeof global.notify === 'boolean') return global.notify;
94
+
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Create parent dirs if needed, merge `agent` / `notify` into the existing
100
+ * config object (preserve the other key), overwrite with pretty-printed JSON,
74
101
  * return the path written.
75
102
  */
76
- export function writeConfig(configPath, { agent }) {
77
- if (!VALID_AGENTS.has(agent)) {
103
+ export function writeConfig(configPath, { agent, notify } = {}) {
104
+ if (agent !== undefined && !VALID_AGENTS.has(agent)) {
105
+ throw new Error(
106
+ `invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", "agn", or "opencode")`,
107
+ );
108
+ }
109
+ if (notify !== undefined && typeof notify !== 'boolean') {
78
110
  throw new Error(
79
- `invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", or "agn")`,
111
+ `invalid notify: ${JSON.stringify(notify)} (expected true or false)`,
80
112
  );
81
113
  }
114
+
115
+ let existing = {};
116
+ if (fs.existsSync(configPath)) {
117
+ try {
118
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
119
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
120
+ existing = raw;
121
+ }
122
+ } catch {
123
+ // Corrupt file: start fresh; caller already validated inputs.
124
+ existing = {};
125
+ }
126
+ }
127
+
128
+ const next = { ...existing };
129
+ if (agent !== undefined) next.agent = agent;
130
+ if (notify !== undefined) next.notify = notify;
131
+
132
+ // Persist only known keys (drop unknown junk from a prior merge? Spec says
133
+ // unknown keys are ignored on load but does not require stripping on write.
134
+ // Keep agent/notify; preserve other keys already present.)
135
+ const out = {};
136
+ if (next.agent !== undefined) out.agent = next.agent;
137
+ if (next.notify !== undefined) out.notify = next.notify;
138
+ for (const [k, v] of Object.entries(next)) {
139
+ if (k === 'agent' || k === 'notify') continue;
140
+ out[k] = v;
141
+ }
142
+
82
143
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
83
- fs.writeFileSync(configPath, `${JSON.stringify({ agent }, null, 2)}\n`);
144
+ fs.writeFileSync(configPath, `${JSON.stringify(out, null, 2)}\n`);
84
145
  return configPath;
85
146
  }
86
147
 
87
148
  /**
88
- * Print effective agent and which file(s) contributed (stdout). Throws on
89
- * invalid existing files — same fail-fast contract as a run.
149
+ * Print effective agent/notify and which file(s) contributed (stdout). Throws
150
+ * on invalid existing files — same fail-fast contract as a run.
90
151
  */
91
152
  export function printConfig({
92
153
  cwd,
@@ -108,8 +169,22 @@ export function printConfig({
108
169
  source = `global (${globalPath})`;
109
170
  }
110
171
 
172
+ let notify = true;
173
+ let notifySource = 'default (builtin)';
174
+ if (typeof local.notify === 'boolean') {
175
+ notify = local.notify;
176
+ notifySource = `local (${localPath})`;
177
+ } else if (typeof global.notify === 'boolean') {
178
+ notify = global.notify;
179
+ notifySource = `global (${globalPath})`;
180
+ }
181
+
111
182
  log(`agent=${agent}`);
112
183
  log(`source=${source}`);
113
184
  log(`global=${global.agent ?? 'unset'} (${globalPath})`);
114
185
  log(`local=${local.agent ?? 'unset'} (${localPath})`);
186
+ log(`notify=${notify}`);
187
+ log(`notifySource=${notifySource}`);
188
+ log(`notifyGlobal=${typeof global.notify === 'boolean' ? global.notify : 'unset'} (${globalPath})`);
189
+ log(`notifyLocal=${typeof local.notify === 'boolean' ? local.notify : 'unset'} (${localPath})`);
115
190
  }
package/lib/continue.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { readJob, reconcileJob, jobPaths } from './jobs.js';
4
+ import { readSeq } from './seq.js';
4
5
 
5
- const CONTINUE_ELIGIBLE = new Set(['done', 'failed', 'stopped', 'crashed']);
6
+ const CONTINUE_ELIGIBLE = new Set(['done']);
7
+ const FAILURE_TERMINALS = new Set(['failed', 'stopped', 'crashed']);
6
8
 
7
9
  /** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
8
10
  * `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
@@ -16,6 +18,9 @@ function fail(message) {
16
18
  * Pure eligibility gate for `orch continue`. Reconciles first; never mutates
17
19
  * beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
18
20
  * (CLI wraps with `Error: ${err.message}`).
21
+ *
22
+ * Continue is follow-up work on primarily `done` jobs. Terminal failures
23
+ * refuse by default and point at `orch resume` (see `.spec/resume.md`).
19
24
  */
20
25
  export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
21
26
  const existing = readJob(cwd, slug);
@@ -30,13 +35,12 @@ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
30
35
 
31
36
  const record = reconcileJob(cwd, slug, existing);
32
37
 
33
- if (!CONTINUE_ELIGIBLE.has(record.state)) {
34
- throw fail(
35
- `cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
36
- );
37
- }
38
-
39
38
  if (record.role === 'coordinator') {
39
+ if (readSeq(cwd, slug)) {
40
+ throw fail(
41
+ `cannot continue coordinator ${slug}; continue each failed unit slug, then orch --seq-continue ${slug} or orch resume ${slug}`,
42
+ );
43
+ }
40
44
  throw fail(
41
45
  `cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
42
46
  );
@@ -49,6 +53,20 @@ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
49
53
  );
50
54
  }
51
55
 
56
+ if (FAILURE_TERMINALS.has(record.state)) {
57
+ const phase = record.phase ?? record.lastOutcome?.phase ?? '?';
58
+ const stage = record.stage ?? record.lastOutcome?.stage ?? '?';
59
+ throw fail(
60
+ `${slug} is ${record.state} at ${phase}/${stage};\nuse: orch resume ${slug}`,
61
+ );
62
+ }
63
+
64
+ if (!CONTINUE_ELIGIBLE.has(record.state)) {
65
+ throw fail(
66
+ `cannot continue ${slug} while state is ${record.state}; use orch resume / orch stop`,
67
+ );
68
+ }
69
+
52
70
  if (!record.worktree || !record.branch) {
53
71
  throw fail(`${slug} has no worktree; continue only applies to complex runs`);
54
72
  }