@welluable/orch 1.2.0 → 1.3.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 CHANGED
@@ -51,7 +51,10 @@ npm install -g @welluable/orch
51
51
 
52
52
  Make sure an agent CLI is on your `PATH` — orch defaults to `--agent cursor`
53
53
  (the Cursor Agent CLI, command `agent`); `claude` and `agn` are also
54
- supported. See [Requirements](#requirements) for details.
54
+ supported. Pin a default with `orch config --agent <name>` so you don't need
55
+ `--agent` on every run (local `.orch/config` overrides global
56
+ `~/.orch/config`; CLI `--agent` still wins). See [Requirements](#requirements)
57
+ for details.
55
58
 
56
59
  ```bash
57
60
  orch "fix the typo in the README"
@@ -210,9 +213,18 @@ Usage: orch [options] [command] <task...>
210
213
  at once; omit to let the coordinator choose (typically the current layer's
211
214
  size); only meaningful with `--fan-out`.
212
215
  - `--agent <cursor|claude|agn>` — selects the backend for the whole pipeline;
213
- defaults to `cursor`.
216
+ when omitted, uses local `.orch/config`, then global `~/.orch/config`, else
217
+ `cursor`.
214
218
  - `-h, --help` — displays help for the command.
215
219
 
220
+ Config:
221
+
222
+ - `orch config` — prints the effective agent and which file(s) contributed
223
+ (local / global / default). Does not prompt.
224
+ - `orch config --agent <cursor|claude|agn> [--global|--local]` — writes the
225
+ default agent. Bare `--agent` (and `--global`) write `~/.orch/config`;
226
+ `--local` writes `<cwd>/.orch/config`. There is no `orch init`.
227
+
216
228
  Job-control subcommands (see [Headless runs](#headless-runs)):
217
229
 
218
230
  - `orch list` — lists all runs tracked under `.orch/` in the current directory.
@@ -220,7 +232,11 @@ Job-control subcommands (see [Headless runs](#headless-runs)):
220
232
  recently started run.
221
233
  - `orch pause <slug>` — requests a pause at the run's next stage-boundary
222
234
  checkpoint.
223
- - `orch resume <slug>` — resumes a paused (or pausing) run.
235
+ - `orch resume <slug>` — unpauses a paused (or pausing) run.
236
+ - `orch continue <slug> "new task"` — starts a new complex pipeline on a
237
+ finished run's existing worktree/branch (same slug). Carries prior
238
+ stage/failure outcome into research/plan. For fan-out workers, continue the
239
+ worker slug, then `orch --integrate <parent>`. Not the same as `resume`.
224
240
  - `orch stop <slug>` — sends `SIGTERM` to a running job (or reconciles a dead
225
241
  one to `crashed`).
226
242
  - `orch logs <slug> [-f]` — prints a run's `orch.log`; `-f` follows it until
@@ -237,6 +253,9 @@ orch "implement the local spec" --agent agn -v
237
253
  orch --ask "where is the CLI entrypoint?" --agent claude
238
254
  orch --quick "fix the typo in the README" --agent claude
239
255
  orch "noop" --dry-run --agent cursor
256
+ orch config
257
+ orch config --agent claude
258
+ orch config --agent agn --local
240
259
  ```
241
260
 
242
261
  ## Headless runs
@@ -260,7 +279,8 @@ directory's `.orch/`:
260
279
  orch list # SLUG ROLE STATE PHASE AGENT STARTED DURATION PID
261
280
  orch status swift-lagoon-49ea # full record: state, phase, branch, worktree, exit code, ...
262
281
  orch pause swift-lagoon-49ea # request a pause at the next stage-boundary checkpoint
263
- orch resume swift-lagoon-49ea # resume a paused/pausing run
282
+ orch resume swift-lagoon-49ea # unpause a paused/pausing run
283
+ orch continue swift-lagoon-49ea "follow-up polish" # new work on the same worktree
264
284
  orch logs swift-lagoon-49ea -f # follow orch.log until the run finishes
265
285
  orch stop swift-lagoon-49ea # SIGTERM the run
266
286
  orch jobs clean # delete every tracked run under .orch/ (asks to confirm)
@@ -432,7 +452,7 @@ pkill -f 'agn ' # --agent agn
432
452
 
433
453
  | Backend | `--agent` value | Status | Notes |
434
454
  | --- | --- | --- | --- |
435
- | Cursor Agent CLI | `cursor` | Supported (default) | Command `agent` on `PATH`. |
455
+ | Cursor Agent CLI | `cursor` | Supported (builtin default) | Command `agent` on `PATH`. Override via `orch config` or `--agent`. |
436
456
  | Claude Code CLI | `claude` | Supported | Command `claude` on `PATH`. |
437
457
  | agn | `agn` | Supported | Requires `npm install -g @welluable/agn-cli` (`>= 0.0.12`) and `agn init`. |
438
458
 
package/lib/agent.js CHANGED
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import ora from 'ora';
4
4
  import { formatActiveTools } from './tool-status.js';
5
- import { patchJob } from './jobs.js';
5
+ import { patchJob, buildLastOutcome } from './jobs.js';
6
6
 
7
7
  /**
8
8
  * Every child process spawned by an agent, tracked so we can reap them all
@@ -67,11 +67,24 @@ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd =
67
67
  const jobSlug = process.env.ORCH_JOB_SLUG ?? activeJobSlug;
68
68
  if (jobSlug) {
69
69
  try {
70
- patchJob(jobCwd, jobSlug, {
70
+ const exitCode = exitCodeForSignal(signal);
71
+ const finishedAt = new Date().toISOString();
72
+ patchJob(jobCwd, jobSlug, (current) => ({
71
73
  state: 'stopped',
72
- exitCode: exitCodeForSignal(signal),
73
- finishedAt: new Date().toISOString(),
74
- });
74
+ exitCode,
75
+ finishedAt,
76
+ lastOutcome: buildLastOutcome({
77
+ state: 'stopped',
78
+ phase: current.phase,
79
+ stage: current.stage,
80
+ round: current.round,
81
+ exitCode,
82
+ finishedAt,
83
+ task: current.task,
84
+ summary: '',
85
+ error: null,
86
+ }),
87
+ }));
75
88
  } catch {
76
89
  // Best-effort: don't let a job-state write failure block shutdown.
77
90
  }
package/lib/config.js ADDED
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const VALID_AGENTS = new Set(['cursor', 'claude', 'agn']);
6
+
7
+ /** Absolute path to the global orch config file. */
8
+ export function globalConfigPath({ homedir = os.homedir() } = {}) {
9
+ return path.join(homedir, '.orch', 'config');
10
+ }
11
+
12
+ /** Absolute path to the project-local orch config file under `cwd`. */
13
+ export function localConfigPath(cwd) {
14
+ return path.join(cwd, '.orch', 'config');
15
+ }
16
+
17
+ /**
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.
21
+ */
22
+ export function loadConfig(configPath, displayPath = configPath) {
23
+ if (!fs.existsSync(configPath)) return {};
24
+
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(configPath, 'utf8');
28
+ } catch (err) {
29
+ throw new Error(`could not parse ${displayPath}: ${err.message}`);
30
+ }
31
+
32
+ let data;
33
+ try {
34
+ data = JSON.parse(raw);
35
+ } catch (err) {
36
+ throw new Error(`could not parse ${displayPath}: ${err.message}`);
37
+ }
38
+
39
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
40
+ throw new Error(`could not parse ${displayPath}: expected a JSON object`);
41
+ }
42
+
43
+ if (!Object.prototype.hasOwnProperty.call(data, 'agent') || data.agent === undefined) {
44
+ return {};
45
+ }
46
+
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
+ );
51
+ }
52
+
53
+ return { agent: data.agent };
54
+ }
55
+
56
+ /**
57
+ * Effective agent for a run: `--agent` > local > global > `cursor`.
58
+ * Reads local then global when `cliAgent` is omitted; throws on bad files.
59
+ */
60
+ export function resolveAgent({ cliAgent, cwd, homedir = os.homedir() } = {}) {
61
+ if (cliAgent) return cliAgent;
62
+
63
+ const local = loadConfig(localConfigPath(cwd), '.orch/config');
64
+ if (local.agent) return local.agent;
65
+
66
+ const global = loadConfig(globalConfigPath({ homedir }), '~/.orch/config');
67
+ if (global.agent) return global.agent;
68
+
69
+ return 'cursor';
70
+ }
71
+
72
+ /**
73
+ * Create parent dirs if needed, overwrite `config` with pretty-printed JSON,
74
+ * return the path written.
75
+ */
76
+ export function writeConfig(configPath, { agent }) {
77
+ if (!VALID_AGENTS.has(agent)) {
78
+ throw new Error(
79
+ `invalid agent: ${JSON.stringify(agent)} (expected "cursor", "claude", or "agn")`,
80
+ );
81
+ }
82
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
83
+ fs.writeFileSync(configPath, `${JSON.stringify({ agent }, null, 2)}\n`);
84
+ return configPath;
85
+ }
86
+
87
+ /**
88
+ * Print effective agent and which file(s) contributed (stdout). Throws on
89
+ * invalid existing files — same fail-fast contract as a run.
90
+ */
91
+ export function printConfig({
92
+ cwd,
93
+ homedir = os.homedir(),
94
+ log = console.log,
95
+ } = {}) {
96
+ const localPath = localConfigPath(cwd);
97
+ const globalPath = globalConfigPath({ homedir });
98
+ const local = loadConfig(localPath, '.orch/config');
99
+ const global = loadConfig(globalPath, '~/.orch/config');
100
+
101
+ let agent = 'cursor';
102
+ let source = 'default (builtin)';
103
+ if (local.agent) {
104
+ agent = local.agent;
105
+ source = `local (${localPath})`;
106
+ } else if (global.agent) {
107
+ agent = global.agent;
108
+ source = `global (${globalPath})`;
109
+ }
110
+
111
+ log(`agent=${agent}`);
112
+ log(`source=${source}`);
113
+ log(`global=${global.agent ?? 'unset'} (${globalPath})`);
114
+ log(`local=${local.agent ?? 'unset'} (${localPath})`);
115
+ }
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJob, reconcileJob, jobPaths } from './jobs.js';
4
+
5
+ const CONTINUE_ELIGIBLE = new Set(['done', 'failed', 'stopped', 'crashed']);
6
+
7
+ /** Plain Error whose `toString()` is just the message (no `Error:` prefix), so
8
+ * `assert.throws(fn, /^exact message$/)` contracts match Node's RegExp check. */
9
+ function fail(message) {
10
+ const err = new Error(message);
11
+ err.name = '';
12
+ return err;
13
+ }
14
+
15
+ /**
16
+ * Pure eligibility gate for `orch continue`. Reconciles first; never mutates
17
+ * beyond reconcile's dead-pid → crashed rewrite. Throws plain Error messages
18
+ * (CLI wraps with `Error: ${err.message}`).
19
+ */
20
+ export function validateContinue(cwd, slug, { task, ask, quick } = {}) {
21
+ const existing = readJob(cwd, slug);
22
+ if (!existing) throw fail(`unknown run ${slug}`);
23
+
24
+ if (ask) throw fail('orch continue does not support --ask; use the default orch command');
25
+ if (quick) throw fail('orch continue does not support --quick; use the default orch command');
26
+
27
+ if (typeof task !== 'string' || !task.trim()) {
28
+ throw fail('task cannot be empty');
29
+ }
30
+
31
+ const record = reconcileJob(cwd, slug, existing);
32
+
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
+ if (record.role === 'coordinator') {
40
+ throw fail(
41
+ `cannot continue coordinator ${slug}; continue each failed worker slug, then orch --integrate ${slug}`,
42
+ );
43
+ }
44
+
45
+ if (record.role === 'integration') {
46
+ const parent = record.parent ?? '<parent-slug>';
47
+ throw fail(
48
+ `cannot continue integration ${slug}; use orch --integrate ${parent}`,
49
+ );
50
+ }
51
+
52
+ if (!record.worktree || !record.branch) {
53
+ throw fail(`${slug} has no worktree; continue only applies to complex runs`);
54
+ }
55
+
56
+ if (!fs.existsSync(record.worktree)) {
57
+ throw fail(`worktree missing at ${record.worktree}; cannot continue ${slug}`);
58
+ }
59
+
60
+ return record;
61
+ }
62
+
63
+ /**
64
+ * Snapshot prior outcome for continue reopen. Prefer `record.lastOutcome`;
65
+ * otherwise synthesize from terminal fields (+ best-effort status.md scrape).
66
+ * Never throws.
67
+ */
68
+ export function snapshotPriorOutcome(cwd, slug, record) {
69
+ if (record?.lastOutcome) return record.lastOutcome;
70
+
71
+ let summary = '';
72
+ try {
73
+ const statusPath = path.join(jobPaths(cwd, slug).dir, 'status.md');
74
+ if (fs.existsSync(statusPath)) {
75
+ const content = fs.readFileSync(statusPath, 'utf8');
76
+ const matches = [...content.matchAll(/^- Summary:\s*(.*)$/gm)];
77
+ if (matches.length > 0) {
78
+ summary = (matches[matches.length - 1][1] || '').trim();
79
+ }
80
+ }
81
+ } catch {
82
+ summary = '';
83
+ }
84
+
85
+ return {
86
+ state: record.state,
87
+ phase: record.phase ?? null,
88
+ stage: record.stage ?? null,
89
+ round: record.round ?? null,
90
+ exitCode: record.exitCode ?? null,
91
+ finishedAt: record.finishedAt ?? null,
92
+ task: record.task ?? null,
93
+ summary,
94
+ error: null,
95
+ };
96
+ }
97
+
98
+ function displayOrNone(value) {
99
+ if (value == null || value === '') return '(none recorded)';
100
+ return String(value);
101
+ }
102
+
103
+ /**
104
+ * Build the `[Prior run outcome]…[/Prior run outcome]` block injected into
105
+ * research/planner prompts on continue.
106
+ */
107
+ export function buildPriorOutcomeText(prior, {
108
+ slug,
109
+ continuation,
110
+ worktreePath,
111
+ branch,
112
+ parentSlug,
113
+ workerId,
114
+ } = {}) {
115
+ const p = prior ?? {};
116
+ const lines = [
117
+ '[Prior run outcome]',
118
+ `- Continuation: this is continue ${continuation} on slug ${slug}`,
119
+ `- Prior state: ${displayOrNone(p.state)}`,
120
+ `- Prior phase: ${displayOrNone(p.phase)}`,
121
+ `- Prior stage: ${displayOrNone(p.stage)}`,
122
+ `- Prior round: ${p.round == null ? 'null' : p.round}`,
123
+ `- Prior task: ${displayOrNone(p.task)}`,
124
+ `- Summary: ${displayOrNone(p.summary)}`,
125
+ `- Error: ${displayOrNone(p.error)}`,
126
+ `- Worktree: ${worktreePath} (branch ${branch} tip is the starting point; do not assume a clean tree)`,
127
+ ];
128
+ if (parentSlug) lines.push(`- Fan-out parent: ${parentSlug}`);
129
+ if (workerId) lines.push(`- Worker id: ${workerId}`);
130
+ lines.push('[/Prior run outcome]');
131
+ return lines.join('\n');
132
+ }
package/lib/jobs.js CHANGED
@@ -5,6 +5,34 @@ import crypto from 'node:crypto';
5
5
  const ACTIVE_LIVE_STATES = ['running', 'pausing', 'paused'];
6
6
  const TERMINAL_STATES = ['done', 'failed', 'stopped', 'crashed'];
7
7
 
8
+ /**
9
+ * Compact outcome written onto `run.json` with every terminal state transition.
10
+ * Shared so all call sites (pipelines, shutdown, reconcile) keep one shape.
11
+ */
12
+ export function buildLastOutcome({
13
+ state,
14
+ phase = null,
15
+ stage = null,
16
+ round = null,
17
+ exitCode = null,
18
+ finishedAt,
19
+ task = null,
20
+ summary = '',
21
+ error = null,
22
+ }) {
23
+ return {
24
+ state,
25
+ phase: phase ?? null,
26
+ stage: stage ?? null,
27
+ round: round ?? null,
28
+ exitCode: exitCode ?? null,
29
+ finishedAt,
30
+ task: task ?? null,
31
+ summary: summary ?? '',
32
+ error: error ?? null,
33
+ };
34
+ }
35
+
8
36
  /** Absolute paths for a job's on-disk artifacts under `<cwd>/.orch/<slug>/`. */
9
37
  export function jobPaths(cwd, slug) {
10
38
  const dir = path.join(path.resolve(cwd), '.orch', slug);
@@ -136,10 +164,59 @@ export function patchJob(cwd, slug, patchFnOrObject) {
136
164
  export function reconcileJob(cwd, slug, record) {
137
165
  if (!ACTIVE_LIVE_STATES.includes(record.state)) return record;
138
166
  if (isPidAlive(record.pid)) return record;
167
+ return patchJob(cwd, slug, (current) => {
168
+ const finishedAt = new Date().toISOString();
169
+ return {
170
+ state: 'crashed',
171
+ finishedAt,
172
+ exitCode: null,
173
+ lastOutcome: buildLastOutcome({
174
+ state: 'crashed',
175
+ phase: current.phase,
176
+ stage: current.stage,
177
+ round: current.round,
178
+ exitCode: null,
179
+ finishedAt,
180
+ task: current.task,
181
+ summary: '',
182
+ error: null,
183
+ }),
184
+ };
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Reopen an existing terminal job for `orch continue`. Patches in place —
190
+ * never allocates a new slug/directory. Bumps `continuation`, appends
191
+ * `continuations[]`, clears live `lastOutcome`, resets to running/research.
192
+ */
193
+ export function reopenJob(cwd, slug, { task, agent, maxRounds, pid, prior, startedAt } = {}) {
194
+ const existing = readJob(cwd, slug);
195
+ if (!existing) throw new Error(`reopenJob: unknown job ${slug}`);
196
+
197
+ const started = startedAt ?? new Date().toISOString();
198
+ const continuation = (existing.continuation ?? 1) + 1;
199
+ const continuations = Array.isArray(existing.continuations)
200
+ ? [...existing.continuations]
201
+ : [];
202
+ continuations.push({ n: continuation, task, startedAt: started, prior: prior ?? null });
203
+
139
204
  return patchJob(cwd, slug, {
140
- state: 'crashed',
141
- finishedAt: new Date().toISOString(),
205
+ task,
206
+ agent,
207
+ maxRounds,
208
+ pid,
209
+ startedAt: started,
210
+ state: 'running',
211
+ phase: 'research',
212
+ stage: null,
213
+ round: null,
214
+ finishedAt: null,
142
215
  exitCode: null,
216
+ pauseRequested: false,
217
+ lastOutcome: null,
218
+ continuation,
219
+ continuations,
143
220
  });
144
221
  }
145
222
 
package/main.js CHANGED
@@ -33,7 +33,14 @@ import {
33
33
  stopJob,
34
34
  cleanJobs,
35
35
  isPidAlive,
36
+ reopenJob,
37
+ buildLastOutcome,
36
38
  } from './lib/jobs.js';
39
+ import {
40
+ validateContinue,
41
+ snapshotPriorOutcome,
42
+ buildPriorOutcomeText,
43
+ } from './lib/continue.js';
37
44
  import { askAgentArgs } from './agents/ask.js';
38
45
  import { triageAgentArgs } from './agents/triage.js';
39
46
  import { quickFixAgentArgs } from './agents/quick-fix.js';
@@ -67,6 +74,13 @@ import {
67
74
  conflictedFiles,
68
75
  hasConflictMarkers,
69
76
  } from './lib/integrate.js';
77
+ import {
78
+ resolveAgent,
79
+ writeConfig,
80
+ printConfig,
81
+ globalConfigPath,
82
+ localConfigPath,
83
+ } from './lib/config.js';
70
84
 
71
85
  const __filename = fileURLToPath(import.meta.url);
72
86
  const __dirname = path.dirname(__filename);
@@ -211,6 +225,18 @@ export function formatStatus(cwd, record) {
211
225
  lines.splice(1, 0, `parent: ${record.parent}`);
212
226
  }
213
227
 
228
+ if (record.continuation > 1) {
229
+ const stateIdx = lines.findIndex((line) => line.startsWith('state:'));
230
+ lines.splice(stateIdx + 1, 0, `continuation: ${record.continuation}`);
231
+ }
232
+
233
+ if (record.lastOutcome) {
234
+ const o = record.lastOutcome;
235
+ lines.push(`outcome: ${o.phase ?? '-'} / ${o.stage ?? '-'} (round ${o.round ?? '-'})`);
236
+ if (o.summary) lines.push(`summary: ${o.summary}`);
237
+ if (o.error) lines.push(`error: ${o.error}`);
238
+ }
239
+
214
240
  const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
215
241
  if (fs.existsSync(statusPath)) {
216
242
  const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
@@ -275,6 +301,28 @@ function defaultExecFile(command, args, options = {}) {
275
301
  return execFileSync(command, args, { encoding: 'utf8', ...options });
276
302
  }
277
303
 
304
+ /** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
305
+ function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
306
+ if (!jobSlug) return;
307
+ const finishedAt = new Date().toISOString();
308
+ patchJobFn(jobCwd, jobSlug, (current) => ({
309
+ state,
310
+ exitCode,
311
+ finishedAt,
312
+ lastOutcome: buildLastOutcome({
313
+ state,
314
+ phase: current.phase,
315
+ stage: current.stage,
316
+ round: current.round,
317
+ exitCode,
318
+ finishedAt,
319
+ task: task ?? current.task,
320
+ summary,
321
+ error,
322
+ }),
323
+ }));
324
+ }
325
+
278
326
  /** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
279
327
  async function runTestLoop({
280
328
  prompt,
@@ -729,7 +777,7 @@ export async function runPipeline(prompt, options) {
729
777
  .filter(Boolean)
730
778
  .join('\n');
731
779
 
732
- await runCodeLoop({
780
+ const codeAccepted = await runCodeLoop({
733
781
  prompt,
734
782
  worktreePath: worktree.worktreePath,
735
783
  branch: worktree.branch,
@@ -762,6 +810,7 @@ export async function runPipeline(prompt, options) {
762
810
  );
763
811
  console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
764
812
  console.log(`merge: git merge ${commitResult.branch}`);
813
+ console.log(`next: orch continue ${runContext.slug} "…"`);
765
814
  } else {
766
815
  fs.appendFileSync(
767
816
  runContext.statusPath,
@@ -770,20 +819,29 @@ export async function runPipeline(prompt, options) {
770
819
  console.log(`commit: no changes on ${commitResult.branch}`);
771
820
  }
772
821
 
773
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
822
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
823
+ state: 'done',
824
+ exitCode: 0,
825
+ summary: codeAccepted?.verdict?.summary ?? '',
826
+ error: null,
827
+ task: prompt,
828
+ });
774
829
  } catch (err) {
775
830
  console.error(`Error: ${err.message}`);
776
831
  if (jobSlug) {
777
832
  try {
778
- patchJobFn(jobCwd, jobSlug, {
833
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
779
834
  state: 'failed',
780
835
  exitCode: 1,
781
- finishedAt: new Date().toISOString(),
836
+ summary: '',
837
+ error: err.message,
838
+ task: prompt,
782
839
  });
783
840
  } catch {
784
841
  // Best-effort: don't let a job-state write failure mask the real error.
785
842
  }
786
843
  }
844
+ console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
787
845
  process.exit(1);
788
846
  }
789
847
  }
@@ -846,6 +904,311 @@ export async function runDetached(prompt, options = {}) {
846
904
  exit(0);
847
905
  }
848
906
 
907
+ /**
908
+ * Continue pipeline: full complex stages on an existing worktree/branch.
909
+ * Skips triage and `createWorktree`. Injects prior-outcome text into
910
+ * research/planner only. See `.spec/continue.md`.
911
+ */
912
+ export async function runContinuePipeline(prompt, options = {}) {
913
+ const verbose = Boolean(options.verbose);
914
+ const maxRounds = options.maxRounds ?? 5;
915
+ const backend = AGENT_BACKENDS[options.agent];
916
+ if (!backend) {
917
+ throw new Error(`Unknown agent backend: ${options.agent}`);
918
+ }
919
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
920
+ const cwd = options.cwd ?? process.cwd();
921
+ const {
922
+ slug,
923
+ worktreePath,
924
+ branch,
925
+ role,
926
+ parentSlug,
927
+ workerId,
928
+ priorOutcome,
929
+ continuation,
930
+ } = options;
931
+
932
+ const createRunContextFn = options.createRunContext ?? createRunContext;
933
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
934
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
935
+ const patchWorkerFn = options.patchWorker ?? patchWorker;
936
+ const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
937
+ const execFileFn = options.execFile;
938
+
939
+ const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
940
+ const jobCwd = options.jobCwd ?? cwd;
941
+ const patchJobFn = options.patchJob ?? patchJob;
942
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
943
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
944
+
945
+ const jobPatch = (fields) => {
946
+ if (!jobSlug) return;
947
+ patchJobFn(jobCwd, jobSlug, fields);
948
+ };
949
+ const jobCheckpoint = async () => {
950
+ if (!jobSlug) return;
951
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
952
+ };
953
+
954
+ if (!options.AgentClass) {
955
+ ensureBinaryOnPath(backend.binary, options.agent);
956
+ }
957
+
958
+ const priorBlock = buildPriorOutcomeText(priorOutcome, {
959
+ slug,
960
+ continuation,
961
+ worktreePath,
962
+ branch,
963
+ parentSlug,
964
+ workerId,
965
+ });
966
+ const researchPlannerPrompt = `${priorBlock}\n\nUser follow-up:\n${prompt}`;
967
+
968
+ try {
969
+ const runContext = createRunContextFn({ cwd, slug });
970
+
971
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
972
+ const prior = priorOutcome ?? {};
973
+ const startedIso = new Date().toISOString();
974
+ let continueSection = `\n## Continue ${continuation}\n\n`;
975
+ continueSection += `- Task: ${prompt.split('\n')[0]}\n`;
976
+ continueSection += `- Started: ${startedIso}\n`;
977
+ continueSection += `- Branch: \`${branch}\`\n`;
978
+ continueSection += `- Worktree: \`${worktreePath}\`\n`;
979
+ if (parentSlug) continueSection += `- Fan-out parent: \`${parentSlug}\`\n`;
980
+ if (workerId) continueSection += `- Worker id: \`${workerId}\`\n`;
981
+ continueSection += `\n### Prior outcome\n\n`;
982
+ continueSection += `- State: ${prior.state ?? '(none recorded)'}\n`;
983
+ continueSection += `- Phase: ${prior.phase ?? '(none recorded)'}\n`;
984
+ continueSection += `- Stage: ${prior.stage ?? '(none recorded)'}\n`;
985
+ continueSection += `- Round: ${prior.round ?? 'null'}\n`;
986
+ continueSection += `- Summary: ${prior.summary || '(none recorded)'}\n`;
987
+ if (prior.error) continueSection += `- Error: ${prior.error}\n`;
988
+ fs.appendFileSync(runContext.statusPath, continueSection);
989
+
990
+ jobPatch({ phase: 'research', stage: 'research', round: null });
991
+ const research = researchAgentArgs({
992
+ prompt: researchPlannerPrompt,
993
+ cwd: worktreePath,
994
+ researchPath: runContext.researchPath,
995
+ });
996
+ const researchAgent = new AgentClass(
997
+ research.name,
998
+ research.instructions,
999
+ research.prompt,
1000
+ research.options,
1001
+ );
1002
+ const researchResult = await researchAgent.run({ verbose });
1003
+ await jobCheckpoint();
1004
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1005
+ printStageSummary('research', researchSummary);
1006
+
1007
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
1008
+ const planner = plannerAgentArgs({
1009
+ prompt: researchPlannerPrompt,
1010
+ cwd: worktreePath,
1011
+ researchPath: runContext.researchPath,
1012
+ taskPath: runContext.taskPath,
1013
+ researchOutput: researchContent,
1014
+ });
1015
+ const plannerAgent = new AgentClass(
1016
+ planner.name,
1017
+ planner.instructions,
1018
+ planner.prompt,
1019
+ planner.options,
1020
+ );
1021
+ const plannerResult = await plannerAgent.run({ verbose });
1022
+ await jobCheckpoint();
1023
+ const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
1024
+ printStageSummary('planner', plannerSummary);
1025
+
1026
+ const testAccepted = await runTestLoop({
1027
+ prompt,
1028
+ worktreePath,
1029
+ branch,
1030
+ taskPath: runContext.taskPath,
1031
+ statusPath: runContext.statusPath,
1032
+ maxRounds,
1033
+ AgentClass,
1034
+ verbose,
1035
+ jobPatch,
1036
+ jobCheckpoint,
1037
+ });
1038
+
1039
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1040
+ .filter(Boolean)
1041
+ .join('\n');
1042
+
1043
+ const codeAccepted = await runCodeLoop({
1044
+ prompt,
1045
+ worktreePath,
1046
+ branch,
1047
+ taskPath: runContext.taskPath,
1048
+ statusPath: runContext.statusPath,
1049
+ maxRounds,
1050
+ AgentClass,
1051
+ verbose,
1052
+ jobPatch,
1053
+ jobCheckpoint,
1054
+ acceptedVerification,
1055
+ });
1056
+
1057
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1058
+ const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
1059
+ const worktreeChanges = collectWorktreeChangesFn({ worktreePath });
1060
+ printFilesChanged(worktreeChanges);
1061
+ const commitResult = commitWorktreeFn({
1062
+ worktreePath,
1063
+ branch,
1064
+ message,
1065
+ });
1066
+
1067
+ if (commitResult.committed) {
1068
+ fs.appendFileSync(
1069
+ runContext.statusPath,
1070
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1071
+ );
1072
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1073
+ console.log(`merge: git merge ${commitResult.branch}`);
1074
+ } else {
1075
+ fs.appendFileSync(
1076
+ runContext.statusPath,
1077
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1078
+ );
1079
+ console.log(`commit: no changes on ${commitResult.branch}`);
1080
+ }
1081
+
1082
+ if (role === 'worker' && parentSlug && workerId) {
1083
+ let changedFiles = [];
1084
+ try {
1085
+ changedFiles = recordChangedFilesFn({
1086
+ repoRoot: cwd,
1087
+ base: undefined,
1088
+ branch,
1089
+ execFile: execFileFn,
1090
+ });
1091
+ } catch {
1092
+ // Best-effort.
1093
+ }
1094
+ if (commitResult.committed) {
1095
+ patchWorkerFn(cwd, parentSlug, workerId, {
1096
+ state: 'done',
1097
+ sha: commitResult.sha,
1098
+ changedFiles,
1099
+ });
1100
+ console.log(`next: orch --integrate ${parentSlug}`);
1101
+ }
1102
+ }
1103
+
1104
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1105
+ state: 'done',
1106
+ exitCode: 0,
1107
+ summary: codeAccepted?.verdict?.summary ?? '',
1108
+ error: null,
1109
+ task: prompt,
1110
+ });
1111
+ } catch (err) {
1112
+ console.error(`Error: ${err.message}`);
1113
+ if (role === 'worker' && parentSlug && workerId) {
1114
+ try {
1115
+ patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1116
+ } catch {
1117
+ // Best-effort.
1118
+ }
1119
+ }
1120
+ if (jobSlug) {
1121
+ try {
1122
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1123
+ state: 'failed',
1124
+ exitCode: 1,
1125
+ summary: '',
1126
+ error: err.message,
1127
+ task: prompt,
1128
+ });
1129
+ } catch {
1130
+ // Best-effort.
1131
+ }
1132
+ }
1133
+ process.exit(1);
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Detach-parent path for `orch continue`: validate, PATH-check, spawn a
1139
+ * `--detach`-stripped re-invocation, reopen the existing slug with the child
1140
+ * pid, print `started`, exit. Never runs pipeline stages itself.
1141
+ */
1142
+ export async function runContinueDetached(slug, prompt, options = {}) {
1143
+ const {
1144
+ agent,
1145
+ maxRounds = 5,
1146
+ verbose,
1147
+ cwd = process.cwd(),
1148
+ spawn: spawnFn = spawn,
1149
+ exit = (code) => process.exit(code),
1150
+ validateContinue: validateContinueFn = validateContinue,
1151
+ reopenJob: reopenJobFn = reopenJob,
1152
+ snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
1153
+ } = options;
1154
+
1155
+ const backend = AGENT_BACKENDS[agent];
1156
+ if (!backend) {
1157
+ throw new Error(`Unknown agent backend: ${agent}`);
1158
+ }
1159
+
1160
+ let record;
1161
+ try {
1162
+ record = validateContinueFn(cwd, slug, { task: prompt });
1163
+ } catch (err) {
1164
+ console.error(`Error: ${err.message}`);
1165
+ exit(1);
1166
+ return;
1167
+ }
1168
+
1169
+ if (!isBinaryOnPath(backend.binary)) {
1170
+ console.error(binaryMissingHint(agent));
1171
+ exit(1);
1172
+ return;
1173
+ }
1174
+
1175
+ const prior = snapshotPriorOutcomeFn(cwd, slug, record);
1176
+ const { logPath } = jobPaths(cwd, slug);
1177
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1178
+ const logFd = fs.openSync(logPath, 'a');
1179
+
1180
+ const childArgs = [
1181
+ __filename,
1182
+ 'continue',
1183
+ slug,
1184
+ prompt,
1185
+ '--agent',
1186
+ agent,
1187
+ '--max-rounds',
1188
+ String(maxRounds),
1189
+ ];
1190
+ if (verbose) childArgs.push('--verbose');
1191
+
1192
+ const child = spawnFn(process.execPath, childArgs, {
1193
+ cwd,
1194
+ env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
1195
+ detached: true,
1196
+ stdio: ['ignore', logFd, logFd],
1197
+ });
1198
+ child.unref();
1199
+
1200
+ const updated = reopenJobFn(cwd, slug, {
1201
+ task: prompt,
1202
+ agent,
1203
+ maxRounds,
1204
+ pid: child.pid,
1205
+ prior,
1206
+ });
1207
+
1208
+ console.log(`started ${slug} (pid ${child.pid}, continuation ${updated.continuation})`);
1209
+ exit(0);
1210
+ }
1211
+
849
1212
  /**
850
1213
  * The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
851
1214
  * worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
@@ -954,7 +1317,7 @@ export async function runWorkerPipeline(prompt, options = {}) {
954
1317
  .filter(Boolean)
955
1318
  .join('\n');
956
1319
 
957
- await runCodeLoop({
1320
+ const codeAccepted = await runCodeLoop({
958
1321
  prompt,
959
1322
  worktreePath: worktree.worktreePath,
960
1323
  branch: worktree.branch,
@@ -1009,7 +1372,13 @@ export async function runWorkerPipeline(prompt, options = {}) {
1009
1372
  sha: commitResult.sha,
1010
1373
  changedFiles,
1011
1374
  });
1012
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1375
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1376
+ state: 'done',
1377
+ exitCode: 0,
1378
+ summary: codeAccepted?.verdict?.summary ?? '',
1379
+ error: null,
1380
+ task: prompt,
1381
+ });
1013
1382
  } catch (err) {
1014
1383
  console.error(`Error: ${err.message}`);
1015
1384
  try {
@@ -1019,15 +1388,19 @@ export async function runWorkerPipeline(prompt, options = {}) {
1019
1388
  }
1020
1389
  if (jobSlug) {
1021
1390
  try {
1022
- patchJobFn(jobCwd, jobSlug, {
1391
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1023
1392
  state: 'failed',
1024
1393
  exitCode: 1,
1025
- finishedAt: new Date().toISOString(),
1394
+ summary: '',
1395
+ error: err.message,
1396
+ task: prompt,
1026
1397
  });
1027
1398
  } catch {
1028
1399
  // Best-effort: don't let a job-state write failure mask the real error.
1029
1400
  }
1030
1401
  }
1402
+ console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
1403
+ if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
1031
1404
  process.exit(1);
1032
1405
  }
1033
1406
  }
@@ -1217,7 +1590,7 @@ export async function runIntegratePipeline(options = {}) {
1217
1590
  }
1218
1591
 
1219
1592
  // --- runner-first verify loop: test-runner first, code-writer only on failure ---
1220
- await runCodeLoop({
1593
+ const codeAccepted = await runCodeLoop({
1221
1594
  prompt: fanout.task,
1222
1595
  worktreePath: worktree.worktreePath,
1223
1596
  branch: worktree.branch,
@@ -1253,7 +1626,13 @@ export async function runIntegratePipeline(options = {}) {
1253
1626
  merged,
1254
1627
  skipped,
1255
1628
  });
1256
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1629
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1630
+ state: 'done',
1631
+ exitCode: 0,
1632
+ summary: codeAccepted?.verdict?.summary ?? '',
1633
+ error: null,
1634
+ task: fanout.task,
1635
+ });
1257
1636
  } catch (err) {
1258
1637
  console.error(`Error: ${err.message}`);
1259
1638
  try {
@@ -1268,10 +1647,12 @@ export async function runIntegratePipeline(options = {}) {
1268
1647
  }
1269
1648
  if (jobSlug) {
1270
1649
  try {
1271
- patchJobFn(jobCwd, jobSlug, {
1650
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1272
1651
  state: 'failed',
1273
1652
  exitCode: 1,
1274
- finishedAt: new Date().toISOString(),
1653
+ summary: '',
1654
+ error: err.message,
1655
+ task: fanout?.task,
1275
1656
  });
1276
1657
  } catch {
1277
1658
  // Best-effort: don't let a job-state write failure mask the real error.
@@ -1392,11 +1773,24 @@ export async function runFanoutPipeline(prompt, options = {}) {
1392
1773
  }
1393
1774
  if (jobSlug) {
1394
1775
  try {
1395
- patchJobFn(jobCwd, jobSlug, {
1776
+ const exitCode = exitCodeForSignal(signal);
1777
+ const finishedAt = new Date().toISOString();
1778
+ patchJobFn(jobCwd, jobSlug, (current) => ({
1396
1779
  state: 'stopped',
1397
- exitCode: exitCodeForSignal(signal),
1398
- finishedAt: new Date().toISOString(),
1399
- });
1780
+ exitCode,
1781
+ finishedAt,
1782
+ lastOutcome: buildLastOutcome({
1783
+ state: 'stopped',
1784
+ phase: current.phase,
1785
+ stage: current.stage,
1786
+ round: current.round,
1787
+ exitCode,
1788
+ finishedAt,
1789
+ task: prompt,
1790
+ summary: '',
1791
+ error: null,
1792
+ }),
1793
+ }));
1400
1794
  } catch {
1401
1795
  // Best-effort: don't let a job-state write failure block shutdown.
1402
1796
  }
@@ -1942,17 +2336,31 @@ export async function runFanoutPipeline(prompt, options = {}) {
1942
2336
  console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
1943
2337
  }
1944
2338
 
1945
- jobPatch({ state: success ? 'done' : 'failed', exitCode: success ? 0 : 1, finishedAt: new Date().toISOString() });
2339
+ const finishedAt = new Date().toISOString();
2340
+ const summary = success
2341
+ ? `fan-out complete: ${doneWorkers.length} worker${doneWorkers.length === 1 ? '' : 's'} integrated`
2342
+ : (failedWorkers.length > 0
2343
+ ? `${failedWorkers.length} worker(s) failed`
2344
+ : 'fan-out failed');
2345
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2346
+ state: success ? 'done' : 'failed',
2347
+ exitCode: success ? 0 : 1,
2348
+ summary,
2349
+ error: success ? null : summary,
2350
+ task: prompt,
2351
+ });
1946
2352
  exitFn(success ? 0 : 1);
1947
2353
  } catch (err) {
1948
2354
  if (err instanceof FanoutInterrupted) return;
1949
2355
  console.error(`Error: ${err.message}`);
1950
2356
  if (jobSlug) {
1951
2357
  try {
1952
- patchJobFn(jobCwd, jobSlug, {
2358
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1953
2359
  state: 'failed',
1954
2360
  exitCode: 1,
1955
- finishedAt: new Date().toISOString(),
2361
+ summary: '',
2362
+ error: err.message,
2363
+ task: prompt,
1956
2364
  });
1957
2365
  } catch {
1958
2366
  // Best-effort: don't let a job-state write failure mask the real error.
@@ -2093,6 +2501,17 @@ function positiveIntParser(flagName) {
2093
2501
  };
2094
2502
  }
2095
2503
 
2504
+ /** Resolve the effective agent (CLI > local > global > cursor) or exit 1. */
2505
+ function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
2506
+ try {
2507
+ return resolveAgent({ cliAgent, cwd });
2508
+ } catch (err) {
2509
+ console.error(`Error: ${err.message}`);
2510
+ process.exit(1);
2511
+ return undefined;
2512
+ }
2513
+ }
2514
+
2096
2515
  const program = new Command();
2097
2516
 
2098
2517
  program
@@ -2110,9 +2529,8 @@ program
2110
2529
  .option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
2111
2530
  .option('--max-concurrency <n>', 'Optional hard ceiling on in-flight fan-out workers at once (only meaningful with --fan-out; default: coordinator chooses)', positiveIntParser('--max-concurrency'))
2112
2531
  .addOption(
2113
- new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI)')
2114
- .choices(['cursor', 'claude', 'agn'])
2115
- .default('cursor'),
2532
+ new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI). Omitting uses local then global config, else cursor')
2533
+ .choices(['cursor', 'claude', 'agn']),
2116
2534
  )
2117
2535
  .addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
2118
2536
  .addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
@@ -2126,13 +2544,18 @@ Examples:
2126
2544
  $ orch --ask "where is the CLI entrypoint?" --agent claude
2127
2545
  $ orch --quick "fix the typo in the README" --agent claude
2128
2546
  $ orch "noop" --dry-run --agent cursor
2547
+ $ orch config # print effective agent
2548
+ $ orch config --agent claude # pin global default
2549
+ $ orch config --agent agn --local # pin project default
2129
2550
 
2130
2551
  Headless runs:
2131
2552
  $ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
2132
2553
  $ orch list # show all tracked runs
2133
2554
  $ orch status [slug] # show full status (defaults to most recent)
2134
2555
  $ orch pause <slug> # request a pause at the next stage boundary
2135
- $ orch resume <slug> # resume a paused/pausing run
2556
+ $ orch resume <slug> # unpause a paused/pausing run
2557
+ $ orch continue <slug> "new task" # new work on a finished run's worktree
2558
+ # (workers: same command; then re-integrate the parent)
2136
2559
  $ orch stop <slug> # send SIGTERM to a running job
2137
2560
  $ orch logs <slug> [-f] # print (or follow) a run's log file
2138
2561
 
@@ -2149,6 +2572,8 @@ Fan-out:
2149
2572
  return;
2150
2573
  }
2151
2574
 
2575
+ options.agent = resolveAgentOrExit(options.agent);
2576
+
2152
2577
  if (options.fanOut) {
2153
2578
  const conflicts = ['ask', 'quick', 'dryRun']
2154
2579
  .filter((key) => options[key])
@@ -2235,6 +2660,178 @@ Fan-out:
2235
2660
  await runPipeline(prompt, options);
2236
2661
  });
2237
2662
 
2663
+ program
2664
+ .command('continue')
2665
+ .description(
2666
+ 'Start new complex work on a finished run\'s existing worktree (not the same as resume, which only unpauses). Workers: continue the worker slug, then re-integrate the parent',
2667
+ )
2668
+ .argument('<slug>', 'Existing run slug under .orch/')
2669
+ .argument('<task...>', 'New task prompt for this continue iteration')
2670
+ .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
2671
+ .option('--dry-run', 'Validate eligibility and agent PATH; do not reopen the job or run agents')
2672
+ .option('--ask', 'Rejected: continue does not support --ask')
2673
+ .option('--quick', 'Rejected: continue does not support --quick')
2674
+ .option('--detach', 'Run the continue in the background under the same slug')
2675
+ .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
2676
+ .addOption(
2677
+ new Option('--agent <agent>', 'Agent backend: "cursor", "claude", or "agn". Omitting uses local then global config, else cursor')
2678
+ .choices(['cursor', 'claude', 'agn']),
2679
+ )
2680
+ .action(async (slug, taskParts, options, command) => {
2681
+ // Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
2682
+ // --max-rounds; when those flags appear after the continue arguments,
2683
+ // Commander attaches them to the parent. Merge via optsWithGlobals.
2684
+ const opts = typeof command.optsWithGlobals === 'function'
2685
+ ? command.optsWithGlobals()
2686
+ : { ...program.opts(), ...options };
2687
+ const prompt = taskParts.join(' ').trim();
2688
+ const cwd = process.cwd();
2689
+
2690
+ opts.agent = resolveAgentOrExit(opts.agent, cwd);
2691
+
2692
+ let record;
2693
+ try {
2694
+ record = validateContinue(cwd, slug, {
2695
+ task: prompt,
2696
+ ask: opts.ask,
2697
+ quick: opts.quick,
2698
+ });
2699
+ } catch (err) {
2700
+ console.error(`Error: ${err.message}`);
2701
+ process.exit(1);
2702
+ return;
2703
+ }
2704
+
2705
+ if (opts.detach) {
2706
+ await runContinueDetached(slug, prompt, {
2707
+ agent: opts.agent,
2708
+ maxRounds: opts.maxRounds,
2709
+ verbose: opts.verbose,
2710
+ cwd,
2711
+ });
2712
+ return;
2713
+ }
2714
+
2715
+ if (opts.dryRun) {
2716
+ const backend = AGENT_BACKENDS[opts.agent];
2717
+ if (!isBinaryOnPath(backend.binary)) {
2718
+ console.error(binaryMissingHint(opts.agent));
2719
+ process.exit(1);
2720
+ return;
2721
+ }
2722
+ console.log(`dry-run: continue ${slug} ok`);
2723
+ return;
2724
+ }
2725
+
2726
+ const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
2727
+ let priorOutcome;
2728
+ let continuation;
2729
+ let worktreePath = record.worktree;
2730
+ let branch = record.branch;
2731
+ let role = record.role;
2732
+ let parentSlug = record.parent;
2733
+ let workerId = record.workerId;
2734
+
2735
+ if (!alreadyReopened) {
2736
+ priorOutcome = snapshotPriorOutcome(cwd, slug, record);
2737
+ const updated = reopenJob(cwd, slug, {
2738
+ task: prompt,
2739
+ agent: opts.agent,
2740
+ maxRounds: opts.maxRounds,
2741
+ pid: process.pid,
2742
+ prior: priorOutcome,
2743
+ });
2744
+ continuation = updated.continuation;
2745
+ setJobSlug(slug);
2746
+ } else {
2747
+ const live = readJob(cwd, slug) ?? record;
2748
+ continuation = live.continuation ?? 2;
2749
+ const entries = Array.isArray(live.continuations) ? live.continuations : [];
2750
+ const last = entries[entries.length - 1];
2751
+ priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
2752
+ worktreePath = live.worktree ?? worktreePath;
2753
+ branch = live.branch ?? branch;
2754
+ role = live.role ?? role;
2755
+ parentSlug = live.parent ?? parentSlug;
2756
+ workerId = live.workerId ?? workerId;
2757
+ setJobSlug(slug);
2758
+ }
2759
+
2760
+ // PATH check after reopen (foreground) so empty-PATH tests can observe the bump.
2761
+ const backend = AGENT_BACKENDS[opts.agent];
2762
+ ensureBinaryOnPath(backend.binary, opts.agent);
2763
+
2764
+ await runContinuePipeline(prompt, {
2765
+ agent: opts.agent,
2766
+ maxRounds: opts.maxRounds,
2767
+ verbose: opts.verbose,
2768
+ cwd,
2769
+ slug,
2770
+ worktreePath,
2771
+ branch,
2772
+ role,
2773
+ parentSlug,
2774
+ workerId,
2775
+ priorOutcome,
2776
+ continuation,
2777
+ jobSlug: slug,
2778
+ jobCwd: cwd,
2779
+ });
2780
+ });
2781
+
2782
+ program
2783
+ .command('config')
2784
+ .description('Print or set the default agent (global ~/.orch/config or local .orch/config)')
2785
+ .addOption(
2786
+ new Option('--agent <agent>', 'Set the default agent backend')
2787
+ .choices(['cursor', 'claude', 'agn']),
2788
+ )
2789
+ .option('--global', 'Write the global config (~/.orch/config); default when --agent is set')
2790
+ .option('--local', 'Write the project-local config (.orch/config)')
2791
+ .action((options, command) => {
2792
+ // Parent also defines --agent; flags after `config` may land on the
2793
+ // parent. Merge so either placement works.
2794
+ const opts = typeof command.optsWithGlobals === 'function'
2795
+ ? command.optsWithGlobals()
2796
+ : { ...program.opts(), ...options };
2797
+ const cwd = process.cwd();
2798
+
2799
+ if (opts.global && opts.local) {
2800
+ console.error('Error: --global and --local are mutually exclusive');
2801
+ process.exit(1);
2802
+ return;
2803
+ }
2804
+
2805
+ if ((opts.global || opts.local) && !opts.agent) {
2806
+ console.error('Error: --global/--local require --agent (omit flags to print config)');
2807
+ process.exit(1);
2808
+ return;
2809
+ }
2810
+
2811
+ if (opts.agent) {
2812
+ const targetPath = opts.local
2813
+ ? localConfigPath(cwd)
2814
+ : globalConfigPath();
2815
+ try {
2816
+ writeConfig(targetPath, { agent: opts.agent });
2817
+ } catch (err) {
2818
+ console.error(`Error: ${err.message}`);
2819
+ process.exit(1);
2820
+ return;
2821
+ }
2822
+ console.log(`wrote ${targetPath}`);
2823
+ process.stdout.write(`${JSON.stringify({ agent: opts.agent }, null, 2)}\n`);
2824
+ return;
2825
+ }
2826
+
2827
+ try {
2828
+ printConfig({ cwd });
2829
+ } catch (err) {
2830
+ console.error(`Error: ${err.message}`);
2831
+ process.exit(1);
2832
+ }
2833
+ });
2834
+
2238
2835
  program
2239
2836
  .command('list')
2240
2837
  .description('List all runs (active and finished) tracked under .orch/ in this directory')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@welluable/orch",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI orchestrator: triage → research → plan → implement",
5
5
  "type": "module",
6
6
  "main": "main.js",