@welluable/orch 1.1.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/main.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, Option } from 'commander';
3
- import { execFileSync } from 'child_process';
3
+ import { execFileSync, spawn } from 'child_process';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
+ import readline from 'node:readline/promises';
7
+ import { stdin as input, stdout as output } from 'node:process';
6
8
 
7
9
  import { fileURLToPath } from 'url';
8
10
  import { AgentCursor } from './lib/agent-cursor.js';
@@ -13,7 +15,32 @@ import { parseVerdict } from './lib/parse-verdict.js';
13
15
  import { splitStageSummary, printStageSummary } from './lib/stage-summary.js';
14
16
  import { createRunContext } from './lib/run-context.js';
15
17
  import { createWorktree } from './lib/worktree.js';
16
- import { commitWorktree } from './lib/commit.js';
18
+ import { commitWorktree, collectWorktreeChanges, printFilesChanged } from './lib/commit.js';
19
+ import { FileTracker } from './lib/file-tracker.js';
20
+ import { allocateJob } from './lib/job-lifecycle.js';
21
+ import { setJobSlug, exitCodeForSignal, formatElapsed } from './lib/agent.js';
22
+ import {
23
+ jobPaths,
24
+ readJob,
25
+ patchJob,
26
+ listJobs,
27
+ reconcileJob,
28
+ checkpointPause,
29
+ requestPause,
30
+ requestResume,
31
+ cascadePause,
32
+ cascadeResume,
33
+ stopJob,
34
+ cleanJobs,
35
+ isPidAlive,
36
+ reopenJob,
37
+ buildLastOutcome,
38
+ } from './lib/jobs.js';
39
+ import {
40
+ validateContinue,
41
+ snapshotPriorOutcome,
42
+ buildPriorOutcomeText,
43
+ } from './lib/continue.js';
17
44
  import { askAgentArgs } from './agents/ask.js';
18
45
  import { triageAgentArgs } from './agents/triage.js';
19
46
  import { quickFixAgentArgs } from './agents/quick-fix.js';
@@ -23,6 +50,37 @@ import { testWriterAgentArgs } from './agents/test-writer.js';
23
50
  import { testCriticAgentArgs } from './agents/test-critic.js';
24
51
  import { codeWriterAgentArgs } from './agents/code-writer.js';
25
52
  import { testRunnerAgentArgs } from './agents/test-runner.js';
53
+ import { integratorAgentArgs } from './agents/integrator.js';
54
+ import { boundariesAgentArgs } from './agents/boundaries.js';
55
+ import { decomposerAgentArgs } from './agents/decomposer.js';
56
+ import {
57
+ readFanout,
58
+ writeFanout,
59
+ patchWorker,
60
+ patchIntegration,
61
+ recordChangedFiles,
62
+ buildWorkerEnvelope,
63
+ buildIntegrationEnvelope,
64
+ validateDecomposition,
65
+ planLayers,
66
+ chooseConcurrency,
67
+ detectOverlaps,
68
+ ensureScaffoldSubtask,
69
+ } from './lib/fanout.js';
70
+ import { parseDecomposition } from './lib/parse-decomposition.js';
71
+ import {
72
+ mergeBranches,
73
+ abortMerge,
74
+ conflictedFiles,
75
+ hasConflictMarkers,
76
+ } from './lib/integrate.js';
77
+ import {
78
+ resolveAgent,
79
+ writeConfig,
80
+ printConfig,
81
+ globalConfigPath,
82
+ localConfigPath,
83
+ } from './lib/config.js';
26
84
 
27
85
  const __filename = fileURLToPath(import.meta.url);
28
86
  const __dirname = path.dirname(__filename);
@@ -71,6 +129,149 @@ function ensureBinaryOnPath(binary, agentName) {
71
129
  }
72
130
  }
73
131
 
132
+ const TERMINAL_JOB_STATES = ['done', 'failed', 'stopped', 'crashed'];
133
+
134
+ function formatRelativeTime(iso) {
135
+ if (!iso) return '-';
136
+ const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
137
+ if (sec < 60) return `${sec}s ago`;
138
+ const min = Math.floor(sec / 60);
139
+ if (min < 60) return `${min}m ago`;
140
+ const hr = Math.floor(min / 60);
141
+ if (hr < 24) return `${hr}h ago`;
142
+ return `${Math.floor(hr / 24)}d ago`;
143
+ }
144
+
145
+ function jobDuration(job) {
146
+ if (!job.startedAt) return '-';
147
+ const start = new Date(job.startedAt).getTime();
148
+ if (Number.isNaN(start)) return '-';
149
+ const end = job.finishedAt ? new Date(job.finishedAt).getTime() : Date.now();
150
+ if (Number.isNaN(end)) return '-';
151
+ return formatElapsed(Math.max(0, end - start));
152
+ }
153
+
154
+ function displayJobRole(role) {
155
+ if (role === 'integration') return 'integrate';
156
+ if (role == null) return '-';
157
+ return role;
158
+ }
159
+
160
+ /** Workers first (startedAt ascending), then the integration child last. */
161
+ function compareFanoutChildren(a, b) {
162
+ const aIntegrate = a.role === 'integration' ? 1 : 0;
163
+ const bIntegrate = b.role === 'integration' ? 1 : 0;
164
+ if (aIntegrate !== bIntegrate) return aIntegrate - bIntegrate;
165
+ return new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime();
166
+ }
167
+
168
+ export function formatJobsTable(jobs) {
169
+ const header = ['SLUG', 'ROLE', 'STATE', 'PHASE', 'AGENT', 'STARTED', 'DURATION', 'PID'];
170
+ const childrenByParent = new Map();
171
+ const topLevel = [];
172
+ for (const job of jobs) {
173
+ if (job.parent) {
174
+ if (!childrenByParent.has(job.parent)) childrenByParent.set(job.parent, []);
175
+ childrenByParent.get(job.parent).push(job);
176
+ } else {
177
+ topLevel.push(job);
178
+ }
179
+ }
180
+ topLevel.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
181
+
182
+ const ordered = [];
183
+ for (const parent of topLevel) {
184
+ ordered.push({ job: parent, indent: '' });
185
+ const children = (childrenByParent.get(parent.slug) || []).slice().sort(compareFanoutChildren);
186
+ for (const child of children) ordered.push({ job: child, indent: ' ' });
187
+ }
188
+
189
+ const rows = ordered.map(({ job, indent }) => [
190
+ `${indent}${job.slug}`,
191
+ displayJobRole(job.role),
192
+ job.state,
193
+ job.phase ?? '-',
194
+ job.agent ?? '-',
195
+ formatRelativeTime(job.startedAt),
196
+ jobDuration(job),
197
+ TERMINAL_JOB_STATES.includes(job.state) ? '-' : (job.pid ?? '-'),
198
+ ]);
199
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
200
+ const formatRow = (cols) => cols.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd();
201
+ return [formatRow(header), ...rows.map(formatRow)].join('\n');
202
+ }
203
+
204
+ function lastNonEmptyLine(content) {
205
+ const lines = content.split('\n').map((line) => line.trim()).filter(Boolean);
206
+ return lines[lines.length - 1];
207
+ }
208
+
209
+ export function formatStatus(cwd, record) {
210
+ const lines = [
211
+ `slug: ${record.slug}`,
212
+ `state: ${record.state}`,
213
+ `phase: ${record.phase ?? '-'}`,
214
+ `stage: ${record.stage ?? '-'}`,
215
+ `agent: ${record.agent ?? '-'}`,
216
+ `started: ${record.startedAt} (${formatRelativeTime(record.startedAt)})`,
217
+ `finished: ${record.finishedAt ?? '-'}`,
218
+ `branch: ${record.branch ?? '-'}`,
219
+ `worktree: ${record.worktree ?? '-'}`,
220
+ `exitCode: ${record.exitCode ?? '-'}`,
221
+ `log: ${record.logPath ?? '-'}`,
222
+ ];
223
+
224
+ if (record.parent) {
225
+ lines.splice(1, 0, `parent: ${record.parent}`);
226
+ }
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
+
240
+ const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
241
+ if (fs.existsSync(statusPath)) {
242
+ const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
243
+ if (last) lines.push(`status: ${last}`);
244
+ }
245
+
246
+ // Child view: parent line only — do not expand siblings.
247
+ // Read children from disk without reconcile so status reflects recorded
248
+ // state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
249
+ if (!record.parent) {
250
+ const orchDir = path.join(path.resolve(cwd), '.orch');
251
+ const children = [];
252
+ if (fs.existsSync(orchDir)) {
253
+ for (const name of fs.readdirSync(orchDir)) {
254
+ const child = readJob(cwd, name);
255
+ if (child?.parent === record.slug) children.push(child);
256
+ }
257
+ }
258
+ children.sort(compareFanoutChildren);
259
+ if (record.role === 'coordinator' || children.length > 0) {
260
+ for (const child of children) {
261
+ lines.push(` ${child.slug} ${child.state} ${child.phase ?? '-'} ${child.branch ?? '-'}`);
262
+ }
263
+ }
264
+ }
265
+
266
+ return lines.join('\n');
267
+ }
268
+
269
+ /** True when pause/resume/stop should cascade to children. */
270
+ function isCascadeParent(cwd, record) {
271
+ if (record?.role === 'coordinator') return true;
272
+ return listJobs(cwd).some((job) => job.parent === record.slug);
273
+ }
274
+
74
275
  function formatVerdictFeedback(verdict, rawResult) {
75
276
  const lines = [];
76
277
  if (verdict.summary) lines.push(verdict.summary);
@@ -96,6 +297,268 @@ function roundLabel(role, round, maxRounds) {
96
297
  return `${role} ${round}/${maxRounds}`;
97
298
  }
98
299
 
300
+ function defaultExecFile(command, args, options = {}) {
301
+ return execFileSync(command, args, { encoding: 'utf8', ...options });
302
+ }
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
+
326
+ /** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
327
+ async function runTestLoop({
328
+ prompt,
329
+ worktreePath,
330
+ branch,
331
+ taskPath,
332
+ statusPath,
333
+ maxRounds,
334
+ AgentClass,
335
+ verbose,
336
+ jobPatch,
337
+ jobCheckpoint,
338
+ }) {
339
+ let testAccepted = null;
340
+ let criticFeedback = null;
341
+ let testRound = 0;
342
+ let testSummary = '';
343
+
344
+ for (let round = 1; round <= maxRounds; round++) {
345
+ testRound = round;
346
+
347
+ jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
348
+ const testWriterArgs = testWriterAgentArgs({
349
+ prompt,
350
+ cwd: worktreePath,
351
+ worktreePath,
352
+ branch,
353
+ taskPath,
354
+ statusPath,
355
+ criticFeedback,
356
+ });
357
+ const testWriterTracker = new FileTracker({ cwd: worktreePath });
358
+ const testWriter = new AgentClass(
359
+ roundLabel('test-writer', round, maxRounds),
360
+ testWriterArgs.instructions,
361
+ testWriterArgs.prompt,
362
+ { ...testWriterArgs.options, fileTracker: testWriterTracker },
363
+ );
364
+
365
+ const testOut = await testWriter.run({ verbose });
366
+ await jobCheckpoint();
367
+ const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
368
+ printStageSummary(
369
+ roundLabel('test-writer', round, maxRounds),
370
+ testWriterSummary,
371
+ testWriterTracker.getFiles(),
372
+ );
373
+ if (!testOut.ok) {
374
+ appendLoopStatus(statusPath, 'Test loop', {
375
+ round: testRound,
376
+ maxRounds,
377
+ passed: false,
378
+ summary: 'test-writer failed',
379
+ });
380
+ throw new Error('test-writer failed; stopping before code-writer');
381
+ }
382
+
383
+ jobPatch({ phase: 'test-loop', stage: 'test-critic', round });
384
+ const testCriticArgs = testCriticAgentArgs({
385
+ prompt,
386
+ cwd: worktreePath,
387
+ worktreePath,
388
+ branch,
389
+ taskPath,
390
+ statusPath,
391
+ testWriterOutput: testWriterContent,
392
+ });
393
+ const testCritic = new AgentClass(
394
+ roundLabel('test-critic', round, maxRounds),
395
+ testCriticArgs.instructions,
396
+ testCriticArgs.prompt,
397
+ testCriticArgs.options,
398
+ );
399
+
400
+ const criticOut = await testCritic.run({ verbose });
401
+ await jobCheckpoint();
402
+ const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
403
+ printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
404
+ if (!criticOut.ok) {
405
+ appendLoopStatus(statusPath, 'Test loop', {
406
+ round: testRound,
407
+ maxRounds,
408
+ passed: false,
409
+ summary: 'test-critic failed',
410
+ });
411
+ throw new Error('test-critic failed; stopping before code-writer');
412
+ }
413
+
414
+ const verdict = parseVerdict(testCriticContent);
415
+ testSummary = verdict.summary;
416
+ if (verdict.passed) {
417
+ testAccepted = { writerContent: testWriterContent, criticOut, verdict, round };
418
+ break;
419
+ }
420
+ criticFeedback = formatVerdictFeedback(verdict, testCriticContent);
421
+ }
422
+
423
+ appendLoopStatus(statusPath, 'Test loop', {
424
+ round: testAccepted?.round ?? testRound,
425
+ maxRounds,
426
+ passed: Boolean(testAccepted),
427
+ summary: testAccepted?.verdict.summary ?? testSummary,
428
+ });
429
+
430
+ if (!testAccepted) {
431
+ throw new Error(`test loop exhausted after ${maxRounds} rounds`);
432
+ }
433
+
434
+ return testAccepted;
435
+ }
436
+
437
+ /**
438
+ * The code-writer ⇄ test-runner loop shared by `runPipeline`, `runWorkerPipeline`, and
439
+ * `runIntegratePipeline`. `runnerFirst` (used by `--integrate`'s verify loop) skips
440
+ * `code-writer` on round 1 only; if that lone `test-runner` attempt fails, rounds 2+
441
+ * alternate `code-writer` → `test-runner` exactly like the default writer-first shape.
442
+ */
443
+ async function runCodeLoop({
444
+ prompt,
445
+ worktreePath,
446
+ branch,
447
+ taskPath,
448
+ statusPath,
449
+ maxRounds,
450
+ AgentClass,
451
+ verbose,
452
+ jobPatch,
453
+ jobCheckpoint,
454
+ acceptedVerification,
455
+ runnerFirst = false,
456
+ loopTitle = 'Code loop',
457
+ }) {
458
+ let codeAccepted = null;
459
+ let runnerFeedback = null;
460
+ let codeRound = 0;
461
+ let codeSummary = '';
462
+ let codeWriterContent = null;
463
+
464
+ for (let round = 1; round <= maxRounds; round++) {
465
+ codeRound = round;
466
+ const skipWriter = runnerFirst && round === 1;
467
+
468
+ if (!skipWriter) {
469
+ jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
470
+ const codeWriterArgs = codeWriterAgentArgs({
471
+ prompt,
472
+ cwd: worktreePath,
473
+ worktreePath,
474
+ branch,
475
+ taskPath,
476
+ statusPath,
477
+ round,
478
+ acceptedVerification,
479
+ runnerFeedback,
480
+ });
481
+ const codeWriterTracker = new FileTracker({ cwd: worktreePath });
482
+ const codeWriter = new AgentClass(
483
+ roundLabel('code-writer', round, maxRounds),
484
+ codeWriterArgs.instructions,
485
+ codeWriterArgs.prompt,
486
+ { ...codeWriterArgs.options, fileTracker: codeWriterTracker },
487
+ );
488
+
489
+ const codeOut = await codeWriter.run({ verbose });
490
+ await jobCheckpoint();
491
+ const { content, summary } = splitStageSummary(codeOut.result);
492
+ codeWriterContent = content;
493
+ printStageSummary(
494
+ roundLabel('code-writer', round, maxRounds),
495
+ summary,
496
+ codeWriterTracker.getFiles(),
497
+ );
498
+ if (!codeOut.ok) {
499
+ appendLoopStatus(statusPath, loopTitle, {
500
+ round: codeRound,
501
+ maxRounds,
502
+ passed: false,
503
+ summary: 'code-writer failed',
504
+ });
505
+ throw new Error('code-writer failed; stopping before commit');
506
+ }
507
+ }
508
+
509
+ jobPatch({ phase: 'code-loop', stage: 'test-runner', round });
510
+ const testRunnerArgs = testRunnerAgentArgs({
511
+ prompt,
512
+ cwd: worktreePath,
513
+ worktreePath,
514
+ branch,
515
+ statusPath,
516
+ codeWriterOutput: codeWriterContent,
517
+ });
518
+ const testRunner = new AgentClass(
519
+ roundLabel('test-runner', round, maxRounds),
520
+ testRunnerArgs.instructions,
521
+ testRunnerArgs.prompt,
522
+ testRunnerArgs.options,
523
+ );
524
+
525
+ const runnerOut = await testRunner.run({ verbose });
526
+ await jobCheckpoint();
527
+ const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
528
+ printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
529
+ if (!runnerOut.ok) {
530
+ appendLoopStatus(statusPath, loopTitle, {
531
+ round: codeRound,
532
+ maxRounds,
533
+ passed: false,
534
+ summary: 'test-runner failed',
535
+ });
536
+ throw new Error('test-runner failed; stopping before commit');
537
+ }
538
+
539
+ const verdict = parseVerdict(testRunnerContent);
540
+ codeSummary = verdict.summary;
541
+ if (verdict.passed) {
542
+ codeAccepted = { writerContent: codeWriterContent, verdict, round };
543
+ break;
544
+ }
545
+ runnerFeedback = formatVerdictFeedback(verdict, testRunnerContent);
546
+ }
547
+
548
+ appendLoopStatus(statusPath, loopTitle, {
549
+ round: codeAccepted?.round ?? codeRound,
550
+ maxRounds,
551
+ passed: Boolean(codeAccepted),
552
+ summary: codeAccepted?.verdict.summary ?? codeSummary,
553
+ });
554
+
555
+ if (!codeAccepted) {
556
+ throw new Error(`code loop exhausted after ${maxRounds} rounds`);
557
+ }
558
+
559
+ return codeAccepted;
560
+ }
561
+
99
562
  export async function runPipeline(prompt, options) {
100
563
  const verbose = Boolean(options.verbose);
101
564
  const maxRounds = options.maxRounds ?? 5;
@@ -108,8 +571,24 @@ export async function runPipeline(prompt, options) {
108
571
  const createRunContextFn = options.createRunContext ?? createRunContext;
109
572
  const createWorktreeFn = options.createWorktree ?? createWorktree;
110
573
  const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
574
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
111
575
  const invocationCwd = process.cwd();
112
576
 
577
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
578
+ const jobCwd = options.jobCwd ?? invocationCwd;
579
+ const patchJobFn = options.patchJob ?? patchJob;
580
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
581
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
582
+
583
+ const jobPatch = (fields) => {
584
+ if (!jobSlug) return;
585
+ patchJobFn(jobCwd, jobSlug, fields);
586
+ };
587
+ const jobCheckpoint = async () => {
588
+ if (!jobSlug) return;
589
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
590
+ };
591
+
113
592
  console.log(`cwd: ${invocationCwd}`);
114
593
  console.log(`agent: ${options.agent}`);
115
594
 
@@ -129,6 +608,7 @@ export async function runPipeline(prompt, options) {
129
608
  console.log();
130
609
 
131
610
  if (options.ask) {
611
+ jobPatch({ phase: 'ask' });
132
612
  const ask = askAgentArgs({ prompt, cwd: invocationCwd });
133
613
  const askAgent = new AgentClass(ask.name, ask.instructions, ask.prompt, ask.options);
134
614
 
@@ -136,37 +616,47 @@ export async function runPipeline(prompt, options) {
136
616
  const askResult = await askAgent.run({ verbose });
137
617
  if (!askResult.ok) {
138
618
  console.error(`Error: ask agent failed`);
619
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
139
620
  process.exit(1);
140
621
  return;
141
622
  }
142
623
  const { content, summary } = splitStageSummary(askResult.result);
143
624
  printStageSummary('ask', summary);
144
625
  console.log(content);
626
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
145
627
  } catch (err) {
146
628
  console.error(`Error: ${err.message}`);
629
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
147
630
  process.exit(1);
148
631
  }
149
632
  return;
150
633
  }
151
634
 
152
635
  if (options.quick) {
636
+ jobPatch({ phase: 'quick-fix' });
153
637
  const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd });
638
+ const quickFixTracker = new FileTracker({ cwd: invocationCwd });
154
639
  const quickFixAgent = new AgentClass(
155
640
  quickFix.name,
156
641
  quickFix.instructions,
157
642
  quickFix.prompt,
158
- quickFix.options,
643
+ { ...quickFix.options, fileTracker: quickFixTracker },
159
644
  );
160
645
 
161
646
  try {
162
647
  const quickFixResult = await quickFixAgent.run({ verbose });
163
648
  if (!quickFixResult.ok) {
164
649
  console.error(`Error: quick-fix agent failed`);
650
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
165
651
  process.exit(1);
166
652
  return;
167
653
  }
654
+ const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
655
+ printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
656
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
168
657
  } catch (err) {
169
658
  console.error(`Error: ${err.message}`);
659
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
170
660
  process.exit(1);
171
661
  }
172
662
  return;
@@ -181,33 +671,43 @@ export async function runPipeline(prompt, options) {
181
671
  );
182
672
 
183
673
  try {
674
+ jobPatch({ phase: 'triage', stage: 'triage', round: null });
675
+ await jobCheckpoint();
184
676
  const triageResult = await triageAgent.run({ verbose });
677
+ await jobCheckpoint();
185
678
  const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
186
679
  printStageSummary('triage', triageSummary);
187
680
  const parsed = parseTriageJson(triageContent);
188
681
 
189
682
  if (parsed?.simple === true) {
683
+ jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
190
684
  const quickFix = quickFixAgentArgs({
191
685
  prompt,
192
686
  cwd: invocationCwd,
193
687
  fix_plan: parsed.fix_plan,
194
688
  });
689
+ const quickFixTracker = new FileTracker({ cwd: invocationCwd });
195
690
  const quickFixAgent = new AgentClass(
196
691
  quickFix.name,
197
692
  quickFix.instructions,
198
693
  quickFix.prompt,
199
- quickFix.options,
694
+ { ...quickFix.options, fileTracker: quickFixTracker },
200
695
  );
201
696
 
202
697
  const quickFixResult = await quickFixAgent.run({ verbose });
698
+ await jobCheckpoint();
203
699
  const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
204
- printStageSummary('quick-fix', quickFixSummary);
700
+ printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
701
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
205
702
  return;
206
703
  }
207
704
 
208
- const runContext = createRunContextFn({ cwd: invocationCwd });
705
+ const runContext = createRunContextFn(
706
+ jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd },
707
+ );
209
708
  console.log(`task ${runContext.slug} is started`);
210
709
 
710
+ jobPatch({ phase: 'research', stage: 'research', round: null });
211
711
  const research = researchAgentArgs({
212
712
  prompt,
213
713
  cwd: invocationCwd,
@@ -221,9 +721,11 @@ export async function runPipeline(prompt, options) {
221
721
  );
222
722
 
223
723
  const result = await researchAgent.run({ verbose });
724
+ await jobCheckpoint();
224
725
  const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
225
726
  printStageSummary('research', researchSummary);
226
727
 
728
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
227
729
  const planner = plannerAgentArgs({
228
730
  prompt,
229
731
  cwd: invocationCwd,
@@ -239,10 +741,13 @@ export async function runPipeline(prompt, options) {
239
741
  );
240
742
 
241
743
  const plannerResult = await plannerAgent.run({ verbose });
744
+ await jobCheckpoint();
242
745
  const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
243
746
  printStageSummary('planner', plannerSummary);
244
747
 
748
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
245
749
  const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
750
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
246
751
 
247
752
  fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
248
753
  fs.writeFileSync(
@@ -251,98 +756,20 @@ export async function runPipeline(prompt, options) {
251
756
  );
252
757
 
253
758
  // --- test loop: test-writer ⇄ test-critic ---
254
- let testAccepted = null;
255
- let criticFeedback = null;
256
- let testRound = 0;
257
- let testSummary = '';
258
-
259
- for (let round = 1; round <= maxRounds; round++) {
260
- testRound = round;
261
-
262
- const testWriterArgs = testWriterAgentArgs({
263
- prompt,
264
- cwd: worktree.worktreePath,
265
- worktreePath: worktree.worktreePath,
266
- branch: worktree.branch,
267
- taskPath: runContext.taskPath,
268
- statusPath: runContext.statusPath,
269
- criticFeedback,
270
- });
271
- const testWriter = new AgentClass(
272
- roundLabel('test-writer', round, maxRounds),
273
- testWriterArgs.instructions,
274
- testWriterArgs.prompt,
275
- testWriterArgs.options,
276
- );
277
-
278
- const testOut = await testWriter.run({ verbose });
279
- const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
280
- printStageSummary(roundLabel('test-writer', round, maxRounds), testWriterSummary);
281
- if (!testOut.ok) {
282
- appendLoopStatus(runContext.statusPath, 'Test loop', {
283
- round: testRound,
284
- maxRounds,
285
- passed: false,
286
- summary: 'test-writer failed',
287
- });
288
- throw new Error('test-writer failed; stopping before code-writer');
289
- }
290
-
291
- const testCriticArgs = testCriticAgentArgs({
292
- prompt,
293
- cwd: worktree.worktreePath,
294
- worktreePath: worktree.worktreePath,
295
- branch: worktree.branch,
296
- taskPath: runContext.taskPath,
297
- statusPath: runContext.statusPath,
298
- testWriterOutput: testWriterContent,
299
- });
300
- const testCritic = new AgentClass(
301
- roundLabel('test-critic', round, maxRounds),
302
- testCriticArgs.instructions,
303
- testCriticArgs.prompt,
304
- testCriticArgs.options,
305
- );
306
-
307
- const criticOut = await testCritic.run({ verbose });
308
- const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
309
- printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
310
- if (!criticOut.ok) {
311
- appendLoopStatus(runContext.statusPath, 'Test loop', {
312
- round: testRound,
313
- maxRounds,
314
- passed: false,
315
- summary: 'test-critic failed',
316
- });
317
- throw new Error('test-critic failed; stopping before code-writer');
318
- }
319
-
320
- const verdict = parseVerdict(testCriticContent);
321
- testSummary = verdict.summary;
322
- if (verdict.passed) {
323
- testAccepted = { writerContent: testWriterContent, criticOut, verdict, round };
324
- break;
325
- }
326
- criticFeedback = formatVerdictFeedback(verdict, testCriticContent);
327
- }
328
-
329
- appendLoopStatus(runContext.statusPath, 'Test loop', {
330
- round: testAccepted?.round ?? testRound,
759
+ const testAccepted = await runTestLoop({
760
+ prompt,
761
+ worktreePath: worktree.worktreePath,
762
+ branch: worktree.branch,
763
+ taskPath: runContext.taskPath,
764
+ statusPath: runContext.statusPath,
331
765
  maxRounds,
332
- passed: Boolean(testAccepted),
333
- summary: testAccepted?.verdict.summary ?? testSummary,
766
+ AgentClass,
767
+ verbose,
768
+ jobPatch,
769
+ jobCheckpoint,
334
770
  });
335
771
 
336
- if (!testAccepted) {
337
- throw new Error(`test loop exhausted after ${maxRounds} rounds`);
338
- }
339
-
340
772
  // --- code loop: code-writer ⇄ test-runner ---
341
- let codeAccepted = null;
342
- let runnerFeedback = null;
343
- let codeRound = 0;
344
- let codeSummary = '';
345
-
346
773
  const acceptedVerification = [
347
774
  testAccepted.verdict.summary,
348
775
  testAccepted.writerContent,
@@ -350,89 +777,26 @@ export async function runPipeline(prompt, options) {
350
777
  .filter(Boolean)
351
778
  .join('\n');
352
779
 
353
- for (let round = 1; round <= maxRounds; round++) {
354
- codeRound = round;
355
-
356
- const codeWriterArgs = codeWriterAgentArgs({
357
- prompt,
358
- cwd: worktree.worktreePath,
359
- worktreePath: worktree.worktreePath,
360
- branch: worktree.branch,
361
- taskPath: runContext.taskPath,
362
- statusPath: runContext.statusPath,
363
- round,
364
- acceptedVerification,
365
- runnerFeedback,
366
- });
367
- const codeWriter = new AgentClass(
368
- roundLabel('code-writer', round, maxRounds),
369
- codeWriterArgs.instructions,
370
- codeWriterArgs.prompt,
371
- codeWriterArgs.options,
372
- );
373
-
374
- const codeOut = await codeWriter.run({ verbose });
375
- const { content: codeWriterContent, summary: codeWriterSummary } = splitStageSummary(codeOut.result);
376
- printStageSummary(roundLabel('code-writer', round, maxRounds), codeWriterSummary);
377
- if (!codeOut.ok) {
378
- appendLoopStatus(runContext.statusPath, 'Code loop', {
379
- round: codeRound,
380
- maxRounds,
381
- passed: false,
382
- summary: 'code-writer failed',
383
- });
384
- throw new Error('code-writer failed; stopping before commit');
385
- }
386
-
387
- const testRunnerArgs = testRunnerAgentArgs({
388
- prompt,
389
- cwd: worktree.worktreePath,
390
- worktreePath: worktree.worktreePath,
391
- branch: worktree.branch,
392
- statusPath: runContext.statusPath,
393
- codeWriterOutput: codeWriterContent,
394
- });
395
- const testRunner = new AgentClass(
396
- roundLabel('test-runner', round, maxRounds),
397
- testRunnerArgs.instructions,
398
- testRunnerArgs.prompt,
399
- testRunnerArgs.options,
400
- );
401
-
402
- const runnerOut = await testRunner.run({ verbose });
403
- const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
404
- printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
405
- if (!runnerOut.ok) {
406
- appendLoopStatus(runContext.statusPath, 'Code loop', {
407
- round: codeRound,
408
- maxRounds,
409
- passed: false,
410
- summary: 'test-runner failed',
411
- });
412
- throw new Error('test-runner failed; stopping before commit');
413
- }
414
-
415
- const verdict = parseVerdict(testRunnerContent);
416
- codeSummary = verdict.summary;
417
- if (verdict.passed) {
418
- codeAccepted = { writerContent: codeWriterContent, verdict, round };
419
- break;
420
- }
421
- runnerFeedback = formatVerdictFeedback(verdict, testRunnerContent);
422
- }
423
-
424
- appendLoopStatus(runContext.statusPath, 'Code loop', {
425
- round: codeAccepted?.round ?? codeRound,
780
+ const codeAccepted = await runCodeLoop({
781
+ prompt,
782
+ worktreePath: worktree.worktreePath,
783
+ branch: worktree.branch,
784
+ taskPath: runContext.taskPath,
785
+ statusPath: runContext.statusPath,
426
786
  maxRounds,
427
- passed: Boolean(codeAccepted),
428
- summary: codeAccepted?.verdict.summary ?? codeSummary,
787
+ AgentClass,
788
+ verbose,
789
+ jobPatch,
790
+ jobCheckpoint,
791
+ acceptedVerification,
429
792
  });
430
793
 
431
- if (!codeAccepted) {
432
- throw new Error(`code loop exhausted after ${maxRounds} rounds`);
433
- }
434
-
794
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
435
795
  const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
796
+ const worktreeChanges = collectWorktreeChangesFn({
797
+ worktreePath: worktree.worktreePath,
798
+ });
799
+ printFilesChanged(worktreeChanges);
436
800
  const commitResult = commitWorktreeFn({
437
801
  worktreePath: worktree.worktreePath,
438
802
  branch: worktree.branch,
@@ -446,6 +810,7 @@ export async function runPipeline(prompt, options) {
446
810
  );
447
811
  console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
448
812
  console.log(`merge: git merge ${commitResult.branch}`);
813
+ console.log(`next: orch continue ${runContext.slug} "…"`);
449
814
  } else {
450
815
  fs.appendFileSync(
451
816
  runContext.statusPath,
@@ -453,54 +818,2214 @@ export async function runPipeline(prompt, options) {
453
818
  );
454
819
  console.log(`commit: no changes on ${commitResult.branch}`);
455
820
  }
821
+
822
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
823
+ state: 'done',
824
+ exitCode: 0,
825
+ summary: codeAccepted?.verdict?.summary ?? '',
826
+ error: null,
827
+ task: prompt,
828
+ });
456
829
  } catch (err) {
457
830
  console.error(`Error: ${err.message}`);
831
+ if (jobSlug) {
832
+ try {
833
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
834
+ state: 'failed',
835
+ exitCode: 1,
836
+ summary: '',
837
+ error: err.message,
838
+ task: prompt,
839
+ });
840
+ } catch {
841
+ // Best-effort: don't let a job-state write failure mask the real error.
842
+ }
843
+ }
844
+ console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
458
845
  process.exit(1);
459
846
  }
460
847
  }
461
848
 
462
- const program = new Command();
849
+ /**
850
+ * The detach-PARENT path: allocates a run directory, writes an initial
851
+ * `run.json`, spawns a `--detach`-stripped re-invocation of this CLI with
852
+ * `ORCH_JOB_SLUG`/`ORCH_DETACHED` set, patches in the child's pid, and
853
+ * returns immediately. `runPipeline` (the child/pipeline-running path) never
854
+ * runs in this process.
855
+ */
856
+ export async function runDetached(prompt, options = {}) {
857
+ const {
858
+ agent,
859
+ maxRounds = 5,
860
+ verbose,
861
+ cwd = process.cwd(),
862
+ createRunContext: createRunContextFn = createRunContext,
863
+ spawn: spawnFn = spawn,
864
+ exit = (code) => process.exit(code),
865
+ } = options;
463
866
 
464
- program
465
- .name('orch')
466
- .version(version)
467
- .description('The Orchestrator: triage → research → plan → implement pipeline against a task')
468
- .argument('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
469
- .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
470
- .option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
471
- .option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
472
- .option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
473
- .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', (value) => {
474
- const n = Number.parseInt(value, 10);
475
- if (!Number.isFinite(n) || n < 1) {
476
- throw new Error('--max-rounds must be a positive integer');
477
- }
478
- return n;
479
- }, 5)
480
- .addOption(
481
- new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI)')
482
- .choices(['cursor', 'claude', 'agn'])
483
- .default('cursor'),
484
- )
485
- .addHelpText(
486
- 'after',
487
- `
488
- Examples:
489
- $ orch "fix the typo in the README" --agent claude
490
- $ orch "fix the bug described in task.md" --agent cursor -v
491
- $ orch "implement the local spec" --agent agn -v
492
- $ orch --ask "where is the CLI entrypoint?" --agent claude
493
- $ orch --quick "fix the typo in the README" --agent claude
494
- $ orch "noop" --dry-run --agent cursor
495
- `,
496
- )
497
- .action(async (task, options) => {
498
- const prompt = task.join(' ').trim();
499
- if (!prompt) {
500
- console.error('Error: task cannot be empty');
501
- process.exit(1);
502
- }
503
- await runPipeline(prompt, options);
867
+ const backend = AGENT_BACKENDS[agent];
868
+ if (!backend) {
869
+ throw new Error(`Unknown agent backend: ${agent}`);
870
+ }
871
+
872
+ if (!isBinaryOnPath(backend.binary)) {
873
+ console.error(binaryMissingHint(agent));
874
+ exit(1);
875
+ return;
876
+ }
877
+
878
+ const { slug } = allocateJob({
879
+ cwd,
880
+ prompt,
881
+ agent,
882
+ maxRounds,
883
+ state: 'starting',
884
+ createRunContext: createRunContextFn,
885
+ });
886
+ const { logPath } = jobPaths(cwd, slug);
887
+
888
+ const logFd = fs.openSync(logPath, 'a');
889
+
890
+ const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
891
+ if (verbose) childArgs.push('--verbose');
892
+
893
+ const child = spawnFn(process.execPath, childArgs, {
894
+ cwd,
895
+ env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
896
+ detached: true,
897
+ stdio: ['ignore', logFd, logFd],
898
+ });
899
+ child.unref();
900
+
901
+ patchJob(cwd, slug, { pid: child.pid, state: 'running' });
902
+
903
+ console.log(`started ${slug} (pid ${child.pid})`);
904
+ exit(0);
905
+ }
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
+
1212
+ /**
1213
+ * The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
1214
+ * worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
1215
+ * commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
1216
+ * with the envelope already appended by the CLI wiring. On success, patches the parent's
1217
+ * `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
1218
+ * patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
1219
+ */
1220
+ export async function runWorkerPipeline(prompt, options = {}) {
1221
+ const verbose = Boolean(options.verbose);
1222
+ const maxRounds = options.maxRounds ?? 5;
1223
+ const backend = AGENT_BACKENDS[options.agent];
1224
+ if (!backend) {
1225
+ throw new Error(`Unknown agent backend: ${options.agent}`);
1226
+ }
1227
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
1228
+ const cwd = options.cwd ?? process.cwd();
1229
+ const { parentSlug, workerId, base } = options;
1230
+
1231
+ const createRunContextFn = options.createRunContext ?? createRunContext;
1232
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
1233
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1234
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
1235
+ const patchWorkerFn = options.patchWorker ?? patchWorker;
1236
+ const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
1237
+ const execFileFn = options.execFile;
1238
+
1239
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1240
+ const jobCwd = options.jobCwd ?? cwd;
1241
+ const patchJobFn = options.patchJob ?? patchJob;
1242
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1243
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1244
+
1245
+ const jobPatch = (fields) => {
1246
+ if (!jobSlug) return;
1247
+ patchJobFn(jobCwd, jobSlug, fields);
1248
+ };
1249
+ const jobCheckpoint = async () => {
1250
+ if (!jobSlug) return;
1251
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
1252
+ };
1253
+
1254
+ if (!options.AgentClass) {
1255
+ ensureBinaryOnPath(backend.binary, options.agent);
1256
+ }
1257
+
1258
+ try {
1259
+ const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
1260
+
1261
+ jobPatch({ phase: 'research', stage: 'research', round: null });
1262
+ const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
1263
+ const researchAgent = new AgentClass(
1264
+ research.name,
1265
+ research.instructions,
1266
+ research.prompt,
1267
+ research.options,
1268
+ );
1269
+ const researchResult = await researchAgent.run({ verbose });
1270
+ await jobCheckpoint();
1271
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1272
+ printStageSummary('research', researchSummary);
1273
+
1274
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
1275
+ const planner = plannerAgentArgs({
1276
+ prompt,
1277
+ cwd,
1278
+ researchPath: runContext.researchPath,
1279
+ taskPath: runContext.taskPath,
1280
+ researchOutput: researchContent,
1281
+ });
1282
+ const plannerAgent = new AgentClass(
1283
+ planner.name,
1284
+ planner.instructions,
1285
+ planner.prompt,
1286
+ planner.options,
1287
+ );
1288
+ const plannerResult = await plannerAgent.run({ verbose });
1289
+ await jobCheckpoint();
1290
+ const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
1291
+ printStageSummary('planner', plannerSummary);
1292
+
1293
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1294
+ const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
1295
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1296
+
1297
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1298
+ fs.writeFileSync(
1299
+ runContext.statusPath,
1300
+ `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
1301
+ );
1302
+
1303
+ const testAccepted = await runTestLoop({
1304
+ prompt,
1305
+ worktreePath: worktree.worktreePath,
1306
+ branch: worktree.branch,
1307
+ taskPath: runContext.taskPath,
1308
+ statusPath: runContext.statusPath,
1309
+ maxRounds,
1310
+ AgentClass,
1311
+ verbose,
1312
+ jobPatch,
1313
+ jobCheckpoint,
1314
+ });
1315
+
1316
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1317
+ .filter(Boolean)
1318
+ .join('\n');
1319
+
1320
+ const codeAccepted = await runCodeLoop({
1321
+ prompt,
1322
+ worktreePath: worktree.worktreePath,
1323
+ branch: worktree.branch,
1324
+ taskPath: runContext.taskPath,
1325
+ statusPath: runContext.statusPath,
1326
+ maxRounds,
1327
+ AgentClass,
1328
+ verbose,
1329
+ jobPatch,
1330
+ jobCheckpoint,
1331
+ acceptedVerification,
1332
+ });
1333
+
1334
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1335
+ const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
1336
+ const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
1337
+ printFilesChanged(worktreeChanges);
1338
+ const commitResult = commitWorktreeFn({
1339
+ worktreePath: worktree.worktreePath,
1340
+ branch: worktree.branch,
1341
+ message,
1342
+ });
1343
+
1344
+ if (commitResult.committed) {
1345
+ fs.appendFileSync(
1346
+ runContext.statusPath,
1347
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1348
+ );
1349
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1350
+ } else {
1351
+ fs.appendFileSync(
1352
+ runContext.statusPath,
1353
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1354
+ );
1355
+ console.log(`commit: no changes on ${commitResult.branch}`);
1356
+ }
1357
+
1358
+ let changedFiles = [];
1359
+ try {
1360
+ changedFiles = recordChangedFilesFn({
1361
+ repoRoot: worktree.repoRoot,
1362
+ base,
1363
+ branch: worktree.branch,
1364
+ execFile: execFileFn,
1365
+ });
1366
+ } catch {
1367
+ // Best-effort: changedFiles is informational only, never masks a successful commit.
1368
+ }
1369
+
1370
+ patchWorkerFn(cwd, parentSlug, workerId, {
1371
+ state: 'done',
1372
+ sha: commitResult.sha,
1373
+ changedFiles,
1374
+ });
1375
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1376
+ state: 'done',
1377
+ exitCode: 0,
1378
+ summary: codeAccepted?.verdict?.summary ?? '',
1379
+ error: null,
1380
+ task: prompt,
1381
+ });
1382
+ } catch (err) {
1383
+ console.error(`Error: ${err.message}`);
1384
+ try {
1385
+ patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1386
+ } catch {
1387
+ // Best-effort: don't let a fanout-state write failure mask the real error.
1388
+ }
1389
+ if (jobSlug) {
1390
+ try {
1391
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1392
+ state: 'failed',
1393
+ exitCode: 1,
1394
+ summary: '',
1395
+ error: err.message,
1396
+ task: prompt,
1397
+ });
1398
+ } catch {
1399
+ // Best-effort: don't let a job-state write failure mask the real error.
1400
+ }
1401
+ }
1402
+ console.error(`next: orch continue ${jobSlug ?? '<slug>'} "fix the failure and finish"`);
1403
+ if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
1404
+ process.exit(1);
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
1410
+ * by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
1411
+ * via the `integrator` agent, one conflict at a time), then runs a runner-first verify
1412
+ * loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
1413
+ * Appends every step to `.orch/<job-slug>/integration.md` as it happens.
1414
+ */
1415
+ export async function runIntegratePipeline(options = {}) {
1416
+ const verbose = Boolean(options.verbose);
1417
+ const maxRounds = options.maxRounds ?? 5;
1418
+ const backend = AGENT_BACKENDS[options.agent];
1419
+ if (!backend) {
1420
+ throw new Error(`Unknown agent backend: ${options.agent}`);
1421
+ }
1422
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
1423
+ const cwd = options.cwd ?? process.cwd();
1424
+ const { parentSlug } = options;
1425
+
1426
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
1427
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1428
+ const readFanoutFn = options.readFanout ?? readFanout;
1429
+ const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
1430
+ const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
1431
+ const abortMergeFn = options.abortMerge ?? abortMerge;
1432
+ const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
1433
+ const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
1434
+ const execFileFn = options.execFile ?? defaultExecFile;
1435
+
1436
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1437
+ const jobCwd = options.jobCwd ?? cwd;
1438
+ const patchJobFn = options.patchJob ?? patchJob;
1439
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1440
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1441
+
1442
+ const jobPatch = (fields) => {
1443
+ if (!jobSlug) return;
1444
+ patchJobFn(jobCwd, jobSlug, fields);
1445
+ };
1446
+ const jobCheckpoint = async () => {
1447
+ if (!jobSlug) return;
1448
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
1449
+ };
1450
+
1451
+ if (!options.AgentClass) {
1452
+ ensureBinaryOnPath(backend.binary, options.agent);
1453
+ }
1454
+
1455
+ const fanout = readFanoutFn(cwd, parentSlug);
1456
+ if (!fanout) {
1457
+ console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
1458
+ process.exit(1);
1459
+ return;
1460
+ }
1461
+
1462
+ const integrationSlug = jobSlug ?? parentSlug;
1463
+ const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
1464
+ const integrationMdPath = path.join(integrationDir, 'integration.md');
1465
+ const logIntegration = (line) => {
1466
+ fs.mkdirSync(integrationDir, { recursive: true });
1467
+ fs.appendFileSync(integrationMdPath, `${line}\n`);
1468
+ };
1469
+
1470
+ let merged = [...fanout.integration.merged];
1471
+ let skipped = [...(fanout.integration.skipped ?? [])];
1472
+
1473
+ try {
1474
+ fs.mkdirSync(integrationDir, { recursive: true });
1475
+ fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
1476
+
1477
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1478
+
1479
+ const reuseWorktreePath = `${cwd}-${parentSlug}`;
1480
+ const expectedBranch = `orch/${parentSlug}`;
1481
+ let worktree = null;
1482
+
1483
+ if (fs.existsSync(reuseWorktreePath)) {
1484
+ const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
1485
+ if (currentBranch === expectedBranch) {
1486
+ worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
1487
+ }
1488
+ }
1489
+
1490
+ const reused = Boolean(worktree);
1491
+ if (!worktree) {
1492
+ worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
1493
+ }
1494
+
1495
+ logIntegration(
1496
+ reused
1497
+ ? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
1498
+ : `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
1499
+ );
1500
+
1501
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1502
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
1503
+ worktree: current.worktree ?? worktree.worktreePath,
1504
+ branch: current.branch ?? worktree.branch,
1505
+ }));
1506
+
1507
+ let remaining = fanout.integration.candidates.filter(
1508
+ (branch) => !merged.includes(branch) && !skipped.includes(branch),
1509
+ );
1510
+
1511
+ while (remaining.length > 0) {
1512
+ const results = mergeBranchesFn({
1513
+ cwd: worktree.worktreePath,
1514
+ candidates: remaining,
1515
+ merged,
1516
+ overlappingFiles: fanout.integration.overlappingFiles,
1517
+ execFile: execFileFn,
1518
+ });
1519
+
1520
+ for (const result of results) {
1521
+ if (result.status === 'skipped') continue;
1522
+
1523
+ if (result.status === 'merged') {
1524
+ merged.push(result.branch);
1525
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
1526
+ merged: [...current.merged, result.branch],
1527
+ }));
1528
+ logIntegration(`- Merged \`${result.branch}\` cleanly.`);
1529
+ continue;
1530
+ }
1531
+
1532
+ // status === 'conflict'
1533
+ logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
1534
+ patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
1535
+
1536
+ const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1537
+ const involvedWorkers = fanout.workers
1538
+ .filter((worker) => worker.branch === result.branch)
1539
+ .map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
1540
+
1541
+ jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
1542
+ const integratorArgs = integratorAgentArgs({
1543
+ prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
1544
+ cwd: worktree.worktreePath,
1545
+ conflictedFiles: conflicted,
1546
+ mergeOutput: result.output,
1547
+ involvedWorkers,
1548
+ });
1549
+ const integratorAgent = new AgentClass(
1550
+ 'integrator',
1551
+ integratorArgs.instructions,
1552
+ integratorArgs.prompt,
1553
+ integratorArgs.options,
1554
+ );
1555
+
1556
+ let integratorOk = false;
1557
+ try {
1558
+ const integratorOut = await integratorAgent.run({ verbose });
1559
+ await jobCheckpoint();
1560
+ const { summary: integratorSummary } = splitStageSummary(integratorOut.result);
1561
+ printStageSummary('integrator', integratorSummary);
1562
+ integratorOk = Boolean(integratorOut.ok);
1563
+ } catch (err) {
1564
+ logIntegration(`- Integrator agent errored: ${err.message}`);
1565
+ integratorOk = false;
1566
+ }
1567
+
1568
+ const stillConflicted = integratorOk
1569
+ ? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
1570
+ : true;
1571
+
1572
+ if (!stillConflicted) {
1573
+ execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
1574
+ merged.push(result.branch);
1575
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
1576
+ merged: [...current.merged, result.branch],
1577
+ }));
1578
+ logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
1579
+ } else {
1580
+ abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1581
+ skipped.push(result.branch);
1582
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
1583
+ skipped: [...(current.skipped ?? []), result.branch],
1584
+ }));
1585
+ logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
1586
+ }
1587
+ }
1588
+
1589
+ remaining = remaining.slice(results.length);
1590
+ }
1591
+
1592
+ // --- runner-first verify loop: test-runner first, code-writer only on failure ---
1593
+ const codeAccepted = await runCodeLoop({
1594
+ prompt: fanout.task,
1595
+ worktreePath: worktree.worktreePath,
1596
+ branch: worktree.branch,
1597
+ taskPath: path.join(integrationDir, 'task.md'),
1598
+ statusPath: integrationMdPath,
1599
+ maxRounds,
1600
+ AgentClass,
1601
+ verbose,
1602
+ jobPatch,
1603
+ jobCheckpoint,
1604
+ acceptedVerification: '',
1605
+ runnerFirst: true,
1606
+ loopTitle: 'Verify loop',
1607
+ });
1608
+
1609
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1610
+ const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
1611
+ const commitResult = commitWorktreeFn({
1612
+ worktreePath: worktree.worktreePath,
1613
+ branch: `orch/${parentSlug}`,
1614
+ message,
1615
+ });
1616
+
1617
+ logIntegration(
1618
+ commitResult.committed
1619
+ ? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
1620
+ : `- No changes to commit on \`${commitResult.branch}\`.`,
1621
+ );
1622
+
1623
+ patchIntegrationFn(cwd, parentSlug, {
1624
+ state: 'done',
1625
+ sha: commitResult.sha,
1626
+ merged,
1627
+ skipped,
1628
+ });
1629
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1630
+ state: 'done',
1631
+ exitCode: 0,
1632
+ summary: codeAccepted?.verdict?.summary ?? '',
1633
+ error: null,
1634
+ task: fanout.task,
1635
+ });
1636
+ } catch (err) {
1637
+ console.error(`Error: ${err.message}`);
1638
+ try {
1639
+ logIntegration(`- Error: ${err.message}`);
1640
+ } catch {
1641
+ // Best-effort: don't let a log write failure mask the real error.
1642
+ }
1643
+ try {
1644
+ patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
1645
+ } catch {
1646
+ // Best-effort: don't let a fanout-state write failure mask the real error.
1647
+ }
1648
+ if (jobSlug) {
1649
+ try {
1650
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1651
+ state: 'failed',
1652
+ exitCode: 1,
1653
+ summary: '',
1654
+ error: err.message,
1655
+ task: fanout?.task,
1656
+ });
1657
+ } catch {
1658
+ // Best-effort: don't let a job-state write failure mask the real error.
1659
+ }
1660
+ }
1661
+ process.exit(1);
1662
+ }
1663
+ }
1664
+
1665
+ function sleep(ms) {
1666
+ return new Promise((resolve) => setTimeout(resolve, ms));
1667
+ }
1668
+
1669
+ /** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
1670
+ * cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
1671
+ class FanoutInterrupted extends Error {}
1672
+
1673
+ /**
1674
+ * SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
1675
+ * integration session's own pid via its `run.json`, filters through `isPidAlive`, and
1676
+ * SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
1677
+ * `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
1678
+ * both this and the normal in-process agent-CLI reaping.
1679
+ */
1680
+ export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1681
+ const fanout = readFanout(cwd, parentSlug);
1682
+ if (!fanout) return;
1683
+
1684
+ const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
1685
+ if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
1686
+
1687
+ for (const slug of slugs) {
1688
+ const record = readJob(cwd, slug);
1689
+ if (!record) continue;
1690
+ if (isPidAliveFn(record.pid)) {
1691
+ kill(record.pid, 'SIGTERM');
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * CLI / management cascade stop: SIGTERM the parent pid if alive, then every
1698
+ * live child pid (via `cascadeStopFanoutChildren`). The coordinator signal
1699
+ * handler keeps calling the child-only helper so it does not re-signal itself.
1700
+ */
1701
+ export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1702
+ const parent = readJob(cwd, parentSlug);
1703
+ if (parent && isPidAliveFn(parent.pid)) {
1704
+ kill(parent.pid, 'SIGTERM');
1705
+ }
1706
+ cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
1707
+ }
1708
+
1709
+ /**
1710
+ * The `--fan-out` coordinator: triage → boundaries → decompose → schedule workers →
1711
+ * overlap detection → spawn integrate → report. Never creates its own worktree or runs
1712
+ * implementer stages itself — those only happen on the decline path (today's
1713
+ * single-worktree pipeline, reusing `runTestLoop`/`runCodeLoop`) or inside spawned
1714
+ * children. See `.spec/fanout-3-coordinator.md` and `.spec/fanout.md`.
1715
+ */
1716
+ export async function runFanoutPipeline(prompt, options = {}) {
1717
+ const verbose = Boolean(options.verbose);
1718
+ const maxRounds = options.maxRounds ?? 5;
1719
+ const maxWorkers = options.maxWorkers ?? 4;
1720
+ const maxConcurrency = options.maxConcurrency ?? null;
1721
+ const backend = AGENT_BACKENDS[options.agent];
1722
+ if (!backend) {
1723
+ throw new Error(`Unknown agent backend: ${options.agent}`);
1724
+ }
1725
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
1726
+ const invocationCwd = options.cwd ?? process.cwd();
1727
+
1728
+ const createRunContextFn = options.createRunContext ?? createRunContext;
1729
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
1730
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1731
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
1732
+ const spawnFn = options.spawn ?? spawn;
1733
+ const execFileFn = options.execFile ?? defaultExecFile;
1734
+ const allocateJobFn = options.allocateJob ?? allocateJob;
1735
+ const reconcileJobFn = options.reconcileJob ?? reconcileJob;
1736
+ const readFanoutFn = options.readFanout ?? readFanout;
1737
+ const writeFanoutFn = options.writeFanout ?? writeFanout;
1738
+ const patchWorkerFn = options.patchWorker ?? patchWorker;
1739
+ const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
1740
+ const exitFn = options.exit ?? ((code) => process.exit(code));
1741
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1742
+
1743
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1744
+ const jobCwd = options.jobCwd ?? invocationCwd;
1745
+ const patchJobFn = options.patchJob ?? patchJob;
1746
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1747
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1748
+
1749
+ const jobPatch = (fields) => {
1750
+ if (!jobSlug) return;
1751
+ patchJobFn(jobCwd, jobSlug, fields);
1752
+ };
1753
+ const jobCheckpoint = async () => {
1754
+ if (!jobSlug) return;
1755
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
1756
+ };
1757
+
1758
+ if (!options.AgentClass) {
1759
+ ensureBinaryOnPath(backend.binary, options.agent);
1760
+ }
1761
+
1762
+ console.log(`cwd: ${invocationCwd}`);
1763
+ console.log(`agent: ${options.agent}`);
1764
+ console.log();
1765
+
1766
+ let interrupted = false;
1767
+ const onSignal = (signal) => {
1768
+ interrupted = true;
1769
+ try {
1770
+ cascadeStopFanoutChildren(invocationCwd, jobSlug);
1771
+ } catch {
1772
+ // Best-effort: never let the cascade itself block shutdown.
1773
+ }
1774
+ if (jobSlug) {
1775
+ try {
1776
+ const exitCode = exitCodeForSignal(signal);
1777
+ const finishedAt = new Date().toISOString();
1778
+ patchJobFn(jobCwd, jobSlug, (current) => ({
1779
+ state: 'stopped',
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
+ }));
1794
+ } catch {
1795
+ // Best-effort: don't let a job-state write failure block shutdown.
1796
+ }
1797
+ }
1798
+ exitFn(exitCodeForSignal(signal));
1799
+ };
1800
+ process.on('SIGINT', () => onSignal('SIGINT'));
1801
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
1802
+ process.on('SIGHUP', () => onSignal('SIGHUP'));
1803
+
1804
+ try {
1805
+ jobPatch({ phase: 'triage', stage: 'triage', round: null });
1806
+ const triage = triageAgentArgs({ prompt, cwd: invocationCwd });
1807
+ const triageAgent = new AgentClass(triage.name, triage.instructions, triage.prompt, triage.options);
1808
+ const triageResult = await triageAgent.run({ verbose });
1809
+ await jobCheckpoint();
1810
+ const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
1811
+ printStageSummary('triage', triageSummary);
1812
+ const parsed = parseTriageJson(triageContent);
1813
+
1814
+ if (parsed?.simple === true) {
1815
+ console.log('triage: simple — fan-out skipped (quick-fix)');
1816
+ jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
1817
+ const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd, fix_plan: parsed.fix_plan });
1818
+ const quickFixTracker = new FileTracker({ cwd: invocationCwd });
1819
+ const quickFixAgent = new AgentClass(
1820
+ quickFix.name,
1821
+ quickFix.instructions,
1822
+ quickFix.prompt,
1823
+ { ...quickFix.options, fileTracker: quickFixTracker },
1824
+ );
1825
+ const quickFixResult = await quickFixAgent.run({ verbose });
1826
+ await jobCheckpoint();
1827
+ const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
1828
+ printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
1829
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1830
+ exitFn(0);
1831
+ return;
1832
+ }
1833
+
1834
+ console.log('triage: complex — fan-out requested');
1835
+
1836
+ // --- boundaries: partitionability research only, runs exactly once ---
1837
+ const boundariesPath = path.join(jobCwd, '.orch', jobSlug, 'boundaries.md');
1838
+ jobPatch({ phase: 'boundaries', stage: 'boundaries', round: null });
1839
+ const boundaries = boundariesAgentArgs({ prompt, cwd: invocationCwd, boundariesPath });
1840
+ const boundariesAgent = new AgentClass(boundaries.name, boundaries.instructions, boundaries.prompt, boundaries.options);
1841
+ const boundariesResult = await boundariesAgent.run({ verbose });
1842
+ await jobCheckpoint();
1843
+ const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
1844
+ printStageSummary('boundaries', boundariesSummary);
1845
+ console.log(`boundaries: ${boundariesSummary || 'done'}`);
1846
+
1847
+ // --- decomposer: up to two repair round-trips on validation failure ---
1848
+ let feedback;
1849
+ let decision = null;
1850
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1851
+ jobPatch({ phase: 'decompose', stage: 'decomposer', round: attempt });
1852
+ const decomposer = decomposerAgentArgs({
1853
+ prompt, cwd: invocationCwd, boundariesOutput, maxWorkers, feedback,
1854
+ });
1855
+ const decomposerAgent = new AgentClass(decomposer.name, decomposer.instructions, decomposer.prompt, decomposer.options);
1856
+ const decomposerResult = await decomposerAgent.run({ verbose });
1857
+ await jobCheckpoint();
1858
+ const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
1859
+ printStageSummary('decomposer', decomposerSummary);
1860
+
1861
+ const parsedDecomposition = parseDecomposition(decomposerContent);
1862
+ if (!parsedDecomposition) {
1863
+ feedback = ['decomposer output was not valid JSON'];
1864
+ if (attempt === 3) decision = { decline: true, why: 'decomposer did not return valid JSON after repair attempts' };
1865
+ continue;
1866
+ }
1867
+ if (parsedDecomposition.decomposable === false) {
1868
+ decision = { decline: true, why: parsedDecomposition.why };
1869
+ break;
1870
+ }
1871
+
1872
+ const violations = validateDecomposition(parsedDecomposition, { maxWorkers });
1873
+ if (violations.length === 0) {
1874
+ decision = { decline: false, decomposition: parsedDecomposition };
1875
+ break;
1876
+ }
1877
+ feedback = violations;
1878
+ if (attempt === 3) {
1879
+ decision = { decline: true, why: `decomposition still invalid after repairs: ${violations.join('; ')}` };
1880
+ }
1881
+ }
1882
+
1883
+ if (decision.decline) {
1884
+ console.log(`decomposer: declined — ${decision.why}`);
1885
+ console.log('falling through to the single-worktree pipeline');
1886
+
1887
+ const runContext = createRunContextFn(jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd });
1888
+
1889
+ jobPatch({ phase: 'research', stage: 'research', round: null });
1890
+ const research = researchAgentArgs({ prompt, cwd: invocationCwd, researchPath: runContext.researchPath });
1891
+ const researchAgent = new AgentClass(research.name, research.instructions, research.prompt, research.options);
1892
+ const researchResult = await researchAgent.run({ verbose });
1893
+ await jobCheckpoint();
1894
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1895
+ printStageSummary('research', researchSummary);
1896
+
1897
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
1898
+ const planner = plannerAgentArgs({
1899
+ prompt, cwd: invocationCwd, researchPath: runContext.researchPath, taskPath: runContext.taskPath, researchOutput: researchContent,
1900
+ });
1901
+ const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
1902
+ const plannerResult = await plannerAgent.run({ verbose });
1903
+ await jobCheckpoint();
1904
+ const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
1905
+ printStageSummary('planner', plannerSummary);
1906
+
1907
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1908
+ const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
1909
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1910
+
1911
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1912
+ fs.writeFileSync(
1913
+ runContext.statusPath,
1914
+ `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n`,
1915
+ );
1916
+
1917
+ const testAccepted = await runTestLoop({
1918
+ prompt,
1919
+ worktreePath: worktree.worktreePath,
1920
+ branch: worktree.branch,
1921
+ taskPath: runContext.taskPath,
1922
+ statusPath: runContext.statusPath,
1923
+ maxRounds,
1924
+ AgentClass,
1925
+ verbose,
1926
+ jobPatch,
1927
+ jobCheckpoint,
1928
+ });
1929
+
1930
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
1931
+
1932
+ await runCodeLoop({
1933
+ prompt,
1934
+ worktreePath: worktree.worktreePath,
1935
+ branch: worktree.branch,
1936
+ taskPath: runContext.taskPath,
1937
+ statusPath: runContext.statusPath,
1938
+ maxRounds,
1939
+ AgentClass,
1940
+ verbose,
1941
+ jobPatch,
1942
+ jobCheckpoint,
1943
+ acceptedVerification,
1944
+ });
1945
+
1946
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1947
+ const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
1948
+ const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
1949
+ printFilesChanged(worktreeChanges);
1950
+ const commitResult = commitWorktreeFn({ worktreePath: worktree.worktreePath, branch: worktree.branch, message });
1951
+
1952
+ if (commitResult.committed) {
1953
+ fs.appendFileSync(
1954
+ runContext.statusPath,
1955
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1956
+ );
1957
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1958
+ console.log(`merge: git merge ${commitResult.branch}`);
1959
+ } else {
1960
+ fs.appendFileSync(
1961
+ runContext.statusPath,
1962
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1963
+ );
1964
+ console.log(`commit: no changes on ${commitResult.branch}`);
1965
+ }
1966
+
1967
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1968
+ exitFn(0);
1969
+ return;
1970
+ }
1971
+
1972
+ // --- validated: bootstrap fanout.json, never give the coordinator a worktree ---
1973
+ const { decomposition } = decision;
1974
+ const base = execFileFn('git', ['-C', invocationCwd, 'rev-parse', 'HEAD']).trim();
1975
+
1976
+ const workers = decomposition.workers.map((worker) => ({
1977
+ id: worker.id,
1978
+ title: worker.title,
1979
+ subtask: worker.scaffold ? ensureScaffoldSubtask(worker.subtask) : worker.subtask,
1980
+ area: worker.area,
1981
+ owns: worker.owns ?? [],
1982
+ dependsOn: worker.dependsOn ?? [],
1983
+ scaffold: Boolean(worker.scaffold),
1984
+ slug: null,
1985
+ branch: null,
1986
+ state: 'pending',
1987
+ sha: null,
1988
+ changedFiles: [],
1989
+ overlaps: [],
1990
+ }));
1991
+
1992
+ writeFanoutFn(invocationCwd, jobSlug, {
1993
+ parentSlug: jobSlug,
1994
+ task: prompt,
1995
+ base,
1996
+ maxWorkers,
1997
+ maxConcurrency,
1998
+ concurrency: workers.length,
1999
+ state: 'running',
2000
+ workers,
2001
+ integration: {
2002
+ slug: null,
2003
+ pid: null,
2004
+ branch: null,
2005
+ worktree: null,
2006
+ candidates: [],
2007
+ merged: [],
2008
+ skipped: [],
2009
+ overlappingFiles: [],
2010
+ state: 'pending',
2011
+ sha: null,
2012
+ },
2013
+ startedAt: new Date().toISOString(),
2014
+ finishedAt: null,
2015
+ });
2016
+
2017
+ console.log(`decomposer: split into ${workers.length} worker${workers.length === 1 ? '' : 's'}`);
2018
+
2019
+ const spawnWorkerChild = (worker) => {
2020
+ const siblingTitles = workers.filter((w) => w.id !== worker.id).map((w) => w.title).filter(Boolean);
2021
+ const envelope = buildWorkerEnvelope({
2022
+ subtask: worker.subtask, area: worker.area, scaffold: worker.scaffold, siblingTitles,
2023
+ });
2024
+ const workerPrompt = `${worker.subtask}\n\n${envelope}`;
2025
+
2026
+ const { slug: workerSlug } = allocateJobFn({
2027
+ cwd: invocationCwd,
2028
+ prompt: workerPrompt,
2029
+ agent: options.agent,
2030
+ maxRounds,
2031
+ state: 'starting',
2032
+ parent: jobSlug,
2033
+ role: 'worker',
2034
+ workerId: worker.id,
2035
+ });
2036
+ patchWorkerFn(invocationCwd, jobSlug, worker.id, {
2037
+ slug: workerSlug,
2038
+ branch: `orch/${workerSlug}`,
2039
+ state: 'running',
2040
+ });
2041
+
2042
+ const { logPath } = jobPaths(invocationCwd, workerSlug);
2043
+ const logFd = fs.openSync(logPath, 'a');
2044
+ const childArgs = [
2045
+ __filename, workerPrompt,
2046
+ '--agent', options.agent,
2047
+ '--max-rounds', String(maxRounds),
2048
+ '--worker', `${jobSlug}:${worker.id}`,
2049
+ ];
2050
+ const child = spawnFn(process.execPath, childArgs, {
2051
+ cwd: invocationCwd,
2052
+ env: { ...process.env, ORCH_JOB_SLUG: workerSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
2053
+ detached: true,
2054
+ stdio: ['ignore', logFd, logFd],
2055
+ });
2056
+ child.unref();
2057
+ patchJobFn(invocationCwd, workerSlug, { pid: child.pid, state: 'running' });
2058
+ console.log(`[${worker.id} ${workerSlug}] running`);
2059
+ return workerSlug;
2060
+ };
2061
+
2062
+ // A worker child self-patches its fanout.json entry to 'done'/'failed' the moment it
2063
+ // finishes (see runWorkerPipeline) — that is the primary settlement signal. reconcileJob
2064
+ // (dead-pid detection) is only consulted once a worker has been in-flight past a grace
2065
+ // period, so a worker that simply hasn't self-reported yet is never mistaken for a crash.
2066
+ const CRASH_CHECK_GRACE_MS = Math.max(pollIntervalMs * 10, 200);
2067
+
2068
+ const settleWorker = (workerId, workerSlug, spawnedAt) => {
2069
+ const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === workerId);
2070
+ if (fanoutWorker.state === 'done' || fanoutWorker.state === 'failed') {
2071
+ console.log(`[${workerId} ${workerSlug}] ${fanoutWorker.state}`);
2072
+ return true;
2073
+ }
2074
+
2075
+ if (Date.now() - spawnedAt < CRASH_CHECK_GRACE_MS) return false;
2076
+
2077
+ const record = reconcileJobFn(invocationCwd, workerSlug, readJob(invocationCwd, workerSlug));
2078
+ if (!TERMINAL_JOB_STATES.includes(record.state)) return false;
2079
+
2080
+ if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
2081
+ patchWorkerFn(invocationCwd, jobSlug, workerId, { state: 'failed' });
2082
+ }
2083
+ console.log(`[${workerId} ${workerSlug}] failed`);
2084
+ return true;
2085
+ };
2086
+
2087
+ /** Spawns up to `concurrency` of `workerIds` at a time, polling to terminal state.
2088
+ * Honors `jobCheckpoint` before each spawn and on each poll tick; while paused does
2089
+ * not spawn or advance. Re-attaches to still-live children instead of re-spawning;
2090
+ * skips workers already `done`/`failed`/`skipped`. */
2091
+ const runWorkerGroup = async (workerIds, concurrency) => {
2092
+ const byId = new Map(workers.map((w) => [w.id, w]));
2093
+ const pending = [...workerIds];
2094
+ const active = new Map();
2095
+
2096
+ while (pending.length > 0 || active.size > 0) {
2097
+ while (active.size < concurrency && pending.length > 0) {
2098
+ await jobCheckpoint();
2099
+ if (interrupted) throw new FanoutInterrupted();
2100
+
2101
+ const id = pending.shift();
2102
+ const fanoutWorker = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === id);
2103
+
2104
+ if (fanoutWorker && ['done', 'failed', 'skipped'].includes(fanoutWorker.state)) {
2105
+ continue;
2106
+ }
2107
+
2108
+ if (fanoutWorker?.slug) {
2109
+ const existing = readJob(invocationCwd, fanoutWorker.slug);
2110
+ if (existing && !TERMINAL_JOB_STATES.includes(existing.state)) {
2111
+ // Re-attach to a still-live child — do not spawn a duplicate.
2112
+ active.set(id, { workerSlug: fanoutWorker.slug, spawnedAt: Date.now() });
2113
+ continue;
2114
+ }
2115
+ if (existing && TERMINAL_JOB_STATES.includes(existing.state)) {
2116
+ if (fanoutWorker.state !== 'done' && fanoutWorker.state !== 'failed') {
2117
+ patchWorkerFn(invocationCwd, jobSlug, id, { state: 'failed' });
2118
+ }
2119
+ continue;
2120
+ }
2121
+ }
2122
+
2123
+ // Spawn only still-pending workers (no slug / not live or terminal).
2124
+ const workerSlug = spawnWorkerChild(byId.get(id));
2125
+ active.set(id, { workerSlug, spawnedAt: Date.now() });
2126
+ }
2127
+ if (active.size === 0) break;
2128
+
2129
+ await sleep(pollIntervalMs);
2130
+ if (interrupted) throw new FanoutInterrupted();
2131
+ await jobCheckpoint();
2132
+
2133
+ for (const [id, { workerSlug, spawnedAt }] of [...active]) {
2134
+ if (settleWorker(id, workerSlug, spawnedAt)) active.delete(id);
2135
+ }
2136
+ }
2137
+ };
2138
+
2139
+ const scaffoldWorker = workers.find((w) => w.scaffold);
2140
+ let aborted = false;
2141
+
2142
+ if (scaffoldWorker) {
2143
+ await runWorkerGroup([scaffoldWorker.id], 1);
2144
+ const scaffoldEntry = readFanoutFn(invocationCwd, jobSlug).workers.find((w) => w.id === scaffoldWorker.id);
2145
+ if (scaffoldEntry.state === 'done') {
2146
+ const current = readFanoutFn(invocationCwd, jobSlug);
2147
+ writeFanoutFn(invocationCwd, jobSlug, { ...current, base: scaffoldEntry.sha });
2148
+ console.log(`[${scaffoldWorker.id}] done — base updated to ${scaffoldEntry.sha.slice(0, 7)}`);
2149
+ } else {
2150
+ aborted = true;
2151
+ for (const worker of workers) {
2152
+ if (worker.id === scaffoldWorker.id) continue;
2153
+ patchWorkerFn(invocationCwd, jobSlug, worker.id, { state: 'skipped' });
2154
+ }
2155
+ console.log('scaffold worker failed; aborting fan-out before any parallel worker spawns');
2156
+ }
2157
+ }
2158
+
2159
+ if (!aborted) {
2160
+ const remaining = workers.filter((w) => !scaffoldWorker || w.id !== scaffoldWorker.id);
2161
+ const forLayering = remaining.map((w) => ({
2162
+ ...w,
2163
+ dependsOn: (w.dependsOn || []).filter((dep) => !scaffoldWorker || dep !== scaffoldWorker.id),
2164
+ }));
2165
+ const layers = planLayers(forLayering);
2166
+
2167
+ for (const layerIds of layers) {
2168
+ if (interrupted) throw new FanoutInterrupted();
2169
+ const currentDoc = readFanoutFn(invocationCwd, jobSlug);
2170
+ const byId = new Map(workers.map((w) => [w.id, w]));
2171
+ const toRun = [];
2172
+ for (const id of layerIds) {
2173
+ const worker = byId.get(id);
2174
+ const depFailed = (worker.dependsOn || []).some(
2175
+ (dep) => currentDoc.workers.find((w) => w.id === dep)?.state === 'failed',
2176
+ );
2177
+ if (depFailed) {
2178
+ patchWorkerFn(invocationCwd, jobSlug, id, { state: 'skipped' });
2179
+ console.log(`[${id}] skipped — dependency failed`);
2180
+ } else {
2181
+ toRun.push(id);
2182
+ }
2183
+ }
2184
+ if (toRun.length === 0) continue;
2185
+
2186
+ const concurrency = chooseConcurrency({ layerSize: toRun.length, maxConcurrency });
2187
+ const currentForConcurrency = readFanoutFn(invocationCwd, jobSlug);
2188
+ writeFanoutFn(invocationCwd, jobSlug, { ...currentForConcurrency, concurrency });
2189
+ console.log(`schedule: concurrency ${concurrency}`);
2190
+
2191
+ await runWorkerGroup(toRun, concurrency);
2192
+ }
2193
+ }
2194
+
2195
+ // --- overlap detection + integration candidates ---
2196
+ await jobCheckpoint();
2197
+ const settledDoc = readFanoutFn(invocationCwd, jobSlug);
2198
+ const overlapUnion = detectOverlaps(settledDoc.workers);
2199
+ for (const worker of settledDoc.workers) {
2200
+ if (worker.overlaps && worker.overlaps.length > 0) {
2201
+ patchWorkerFn(invocationCwd, jobSlug, worker.id, { overlaps: worker.overlaps });
2202
+ }
2203
+ }
2204
+ console.log(`overlaps: ${overlapUnion.length > 0 ? overlapUnion.join(', ') : 'none'}`);
2205
+
2206
+ const doneWorkers = settledDoc.workers.filter((w) => w.state === 'done');
2207
+ const failedWorkers = settledDoc.workers.filter((w) => w.state === 'failed');
2208
+ patchIntegrationFn(invocationCwd, jobSlug, {
2209
+ candidates: doneWorkers.map((w) => w.branch),
2210
+ overlappingFiles: overlapUnion,
2211
+ });
2212
+
2213
+ let integrationDone = false;
2214
+ await jobCheckpoint();
2215
+ const integrationGate = readFanoutFn(invocationCwd, jobSlug).integration;
2216
+ const integrationAlreadyDone = integrationGate?.state === 'done';
2217
+ let integrationSlug = integrationGate?.slug ?? null;
2218
+ let integrationLive = false;
2219
+ if (integrationSlug && !integrationAlreadyDone) {
2220
+ const existingIntegrate = readJob(invocationCwd, integrationSlug);
2221
+ integrationLive = Boolean(existingIntegrate && !TERMINAL_JOB_STATES.includes(existingIntegrate.state));
2222
+ }
2223
+
2224
+ if (integrationAlreadyDone) {
2225
+ integrationDone = true;
2226
+ console.log(`[integrate ${integrationSlug ?? 'done'}] already done — skipping spawn`);
2227
+ } else if (integrationLive) {
2228
+ // Re-attach to an already-live integrate child; do not spawn a duplicate.
2229
+ console.log(`[integrate ${integrationSlug}] re-attached`);
2230
+ const integrateSpawnedAt = Date.now();
2231
+ for (;;) {
2232
+ await sleep(pollIntervalMs);
2233
+ if (interrupted) throw new FanoutInterrupted();
2234
+ await jobCheckpoint();
2235
+
2236
+ const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
2237
+ if (integrationState === 'done') { integrationDone = true; break; }
2238
+ if (integrationState === 'failed') break;
2239
+
2240
+ if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
2241
+ const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
2242
+ if (TERMINAL_JOB_STATES.includes(record.state)) {
2243
+ if (record.state === 'done') integrationDone = true;
2244
+ else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
2245
+ break;
2246
+ }
2247
+ }
2248
+
2249
+ const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
2250
+ if (finalIntegration.state === 'done' && finalIntegration.sha) {
2251
+ console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
2252
+ console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
2253
+ console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
2254
+ } else {
2255
+ console.log(`[integrate ${integrationSlug}] failed`);
2256
+ }
2257
+ } else if (doneWorkers.length > 0) {
2258
+ const envelope = buildIntegrationEnvelope({
2259
+ task: prompt,
2260
+ branches: doneWorkers.map((w) => w.branch),
2261
+ overlappingFiles: overlapUnion,
2262
+ });
2263
+
2264
+ const allocated = allocateJobFn({
2265
+ cwd: invocationCwd,
2266
+ prompt: envelope,
2267
+ agent: options.agent,
2268
+ maxRounds,
2269
+ state: 'starting',
2270
+ parent: jobSlug,
2271
+ role: 'integration',
2272
+ });
2273
+ integrationSlug = allocated.slug;
2274
+ patchIntegrationFn(invocationCwd, jobSlug, { slug: integrationSlug });
2275
+
2276
+ const { logPath } = jobPaths(invocationCwd, integrationSlug);
2277
+ const logFd = fs.openSync(logPath, 'a');
2278
+ const childArgs = [
2279
+ __filename, envelope,
2280
+ '--agent', options.agent,
2281
+ '--max-rounds', String(maxRounds),
2282
+ '--integrate', jobSlug,
2283
+ ];
2284
+ const child = spawnFn(process.execPath, childArgs, {
2285
+ cwd: invocationCwd,
2286
+ env: { ...process.env, ORCH_JOB_SLUG: integrationSlug, ORCH_DETACHED: '1', ORCH_FANOUT_DEPTH: '1' },
2287
+ detached: true,
2288
+ stdio: ['ignore', logFd, logFd],
2289
+ });
2290
+ child.unref();
2291
+ patchJobFn(invocationCwd, integrationSlug, { pid: child.pid, state: 'running' });
2292
+ patchIntegrationFn(invocationCwd, jobSlug, { pid: child.pid });
2293
+ console.log(`[integrate ${integrationSlug}] running`);
2294
+
2295
+ const integrateSpawnedAt = Date.now();
2296
+ for (;;) {
2297
+ await sleep(pollIntervalMs);
2298
+ if (interrupted) throw new FanoutInterrupted();
2299
+ await jobCheckpoint();
2300
+
2301
+ const integrationState = readFanoutFn(invocationCwd, jobSlug).integration.state;
2302
+ if (integrationState === 'done') { integrationDone = true; break; }
2303
+ if (integrationState === 'failed') break;
2304
+
2305
+ if (Date.now() - integrateSpawnedAt < CRASH_CHECK_GRACE_MS) continue;
2306
+ const record = reconcileJobFn(invocationCwd, integrationSlug, readJob(invocationCwd, integrationSlug));
2307
+ if (TERMINAL_JOB_STATES.includes(record.state)) {
2308
+ if (record.state === 'done') integrationDone = true;
2309
+ else patchIntegrationFn(invocationCwd, jobSlug, (current) => (current.state === 'done' ? {} : { state: 'failed' }));
2310
+ break;
2311
+ }
2312
+ }
2313
+
2314
+ const finalIntegration = readFanoutFn(invocationCwd, jobSlug).integration;
2315
+ if (finalIntegration.state === 'done' && finalIntegration.sha) {
2316
+ console.log(`[integrate ${integrationSlug}] merged ${doneWorkers.length} branch${doneWorkers.length === 1 ? '' : 'es'}`);
2317
+ console.log(`commit: ${finalIntegration.sha.slice(0, 7)} on ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
2318
+ console.log(`merge: git merge ${finalIntegration.branch ?? `orch/${jobSlug}`}`);
2319
+ } else {
2320
+ console.log(`[integrate ${integrationSlug}] failed`);
2321
+ }
2322
+ } else {
2323
+ console.log('no worker reached done; skipping integration');
2324
+ }
2325
+
2326
+ const finalDoc = readFanoutFn(invocationCwd, jobSlug);
2327
+ const success = failedWorkers.length === 0 && integrationDone && Boolean(finalDoc.integration.sha);
2328
+ writeFanoutFn(invocationCwd, jobSlug, {
2329
+ ...finalDoc,
2330
+ state: success ? 'done' : 'failed',
2331
+ finishedAt: new Date().toISOString(),
2332
+ });
2333
+
2334
+ if (failedWorkers.length > 0) {
2335
+ console.log(`${failedWorkers.length} worker${failedWorkers.length === 1 ? '' : 's'} failed: ${failedWorkers.map((w) => w.id).join(', ')}`);
2336
+ console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
2337
+ }
2338
+
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
+ });
2352
+ exitFn(success ? 0 : 1);
2353
+ } catch (err) {
2354
+ if (err instanceof FanoutInterrupted) return;
2355
+ console.error(`Error: ${err.message}`);
2356
+ if (jobSlug) {
2357
+ try {
2358
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2359
+ state: 'failed',
2360
+ exitCode: 1,
2361
+ summary: '',
2362
+ error: err.message,
2363
+ task: prompt,
2364
+ });
2365
+ } catch {
2366
+ // Best-effort: don't let a job-state write failure mask the real error.
2367
+ }
2368
+ }
2369
+ exitFn(1);
2370
+ }
2371
+ }
2372
+
2373
+ /** Splits `--worker`'s `<parent-slug>:<worker-id>` value on the first colon. */
2374
+ function splitParentWorker(value) {
2375
+ const idx = value.indexOf(':');
2376
+ if (idx === -1) return { parentSlug: null, workerId: null };
2377
+ return { parentSlug: value.slice(0, idx), workerId: value.slice(idx + 1) };
2378
+ }
2379
+
2380
+ /** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
2381
+ * envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
2382
+ async function runWorkerFromCli(options) {
2383
+ const cwd = process.cwd();
2384
+ const { parentSlug, workerId } = splitParentWorker(options.worker);
2385
+ if (!parentSlug || !workerId) {
2386
+ console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
2387
+ process.exit(1);
2388
+ return;
2389
+ }
2390
+
2391
+ // ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
2392
+ process.env.ORCH_FANOUT_DEPTH = '1';
2393
+
2394
+ const fanout = readFanout(cwd, parentSlug);
2395
+ if (!fanout) {
2396
+ console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
2397
+ process.exit(1);
2398
+ return;
2399
+ }
2400
+
2401
+ const worker = fanout.workers.find((w) => w.id === workerId);
2402
+ if (!worker) {
2403
+ console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
2404
+ process.exit(1);
2405
+ return;
2406
+ }
2407
+
2408
+ const siblingTitles = fanout.workers
2409
+ .filter((w) => w.id !== workerId)
2410
+ .map((w) => w.title)
2411
+ .filter(Boolean);
2412
+ const envelope = buildWorkerEnvelope({
2413
+ subtask: worker.subtask,
2414
+ area: worker.area,
2415
+ scaffold: worker.scaffold,
2416
+ siblingTitles,
2417
+ });
2418
+ const workerPrompt = `${worker.subtask}\n\n${envelope}`;
2419
+
2420
+ let jobSlug = process.env.ORCH_JOB_SLUG;
2421
+ if (!jobSlug) {
2422
+ const alloc = allocateJob({
2423
+ cwd,
2424
+ prompt: workerPrompt,
2425
+ agent: options.agent,
2426
+ maxRounds: options.maxRounds,
2427
+ state: 'running',
2428
+ pid: process.pid,
2429
+ parent: parentSlug,
2430
+ role: 'worker',
2431
+ workerId,
2432
+ });
2433
+ jobSlug = alloc.slug;
2434
+ }
2435
+ setJobSlug(jobSlug);
2436
+
2437
+ await runWorkerPipeline(workerPrompt, {
2438
+ agent: options.agent,
2439
+ maxRounds: options.maxRounds,
2440
+ verbose: options.verbose,
2441
+ cwd,
2442
+ parentSlug,
2443
+ workerId,
2444
+ base: fanout.base,
2445
+ jobSlug,
2446
+ jobCwd: cwd,
2447
+ });
2448
+ }
2449
+
2450
+ /** CLI glue for `--integrate`: resolves the parent fan-out, allocates (or reuses) the
2451
+ * integration job record, then calls `runIntegratePipeline`. */
2452
+ async function runIntegrateFromCli(options) {
2453
+ const cwd = process.cwd();
2454
+ const parentSlug = options.integrate;
2455
+
2456
+ // ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
2457
+ process.env.ORCH_FANOUT_DEPTH = '1';
2458
+
2459
+ const fanout = readFanout(cwd, parentSlug);
2460
+ if (!fanout) {
2461
+ console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
2462
+ process.exit(1);
2463
+ return;
2464
+ }
2465
+
2466
+ let jobSlug = process.env.ORCH_JOB_SLUG;
2467
+ if (!jobSlug) {
2468
+ const alloc = allocateJob({
2469
+ cwd,
2470
+ prompt: fanout.task,
2471
+ agent: options.agent,
2472
+ maxRounds: options.maxRounds,
2473
+ state: 'running',
2474
+ pid: process.pid,
2475
+ parent: parentSlug,
2476
+ role: 'integration',
2477
+ });
2478
+ jobSlug = alloc.slug;
2479
+ }
2480
+ setJobSlug(jobSlug);
2481
+
2482
+ await runIntegratePipeline({
2483
+ agent: options.agent,
2484
+ maxRounds: options.maxRounds,
2485
+ verbose: options.verbose,
2486
+ cwd,
2487
+ parentSlug,
2488
+ jobSlug,
2489
+ jobCwd: cwd,
2490
+ });
2491
+ }
2492
+
2493
+ /** Commander option parser shared by `--max-rounds`, `--max-workers`, and `--max-concurrency`. */
2494
+ function positiveIntParser(flagName) {
2495
+ return (value) => {
2496
+ const n = Number.parseInt(value, 10);
2497
+ if (!Number.isFinite(n) || n < 1) {
2498
+ throw new Error(`${flagName} must be a positive integer`);
2499
+ }
2500
+ return n;
2501
+ };
2502
+ }
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
+
2515
+ const program = new Command();
2516
+
2517
+ program
2518
+ .name('orch')
2519
+ .version(version)
2520
+ .description('The Orchestrator: triage → research → plan → implement pipeline against a task')
2521
+ .argument('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
2522
+ .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
2523
+ .option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
2524
+ .option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
2525
+ .option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
2526
+ .option('--detach', 'Run the pipeline in the background and return immediately; manage it with orch list/status/pause/resume/stop/logs. Cannot be combined with --ask, --quick, or --dry-run')
2527
+ .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', positiveIntParser('--max-rounds'), 5)
2528
+ .option('--fan-out', 'Decompose the task into parallel workers coordinated by this process (see README Fan-out section). Cannot be combined with --ask, --quick, or --dry-run')
2529
+ .option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
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'))
2531
+ .addOption(
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']),
2534
+ )
2535
+ .addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
2536
+ .addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
2537
+ .addHelpText(
2538
+ 'after',
2539
+ `
2540
+ Examples:
2541
+ $ orch "fix the typo in the README" --agent claude
2542
+ $ orch "fix the bug described in task.md" --agent cursor -v
2543
+ $ orch "implement the local spec" --agent agn -v
2544
+ $ orch --ask "where is the CLI entrypoint?" --agent claude
2545
+ $ orch --quick "fix the typo in the README" --agent claude
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
2550
+
2551
+ Headless runs:
2552
+ $ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
2553
+ $ orch list # show all tracked runs
2554
+ $ orch status [slug] # show full status (defaults to most recent)
2555
+ $ orch pause <slug> # request a pause at the next stage boundary
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)
2559
+ $ orch stop <slug> # send SIGTERM to a running job
2560
+ $ orch logs <slug> [-f] # print (or follow) a run's log file
2561
+
2562
+ Fan-out:
2563
+ $ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
2564
+ $ orch "implement X" --fan-out --max-workers 6 --max-concurrency 3
2565
+ `,
2566
+ )
2567
+ .action(async (task, options) => {
2568
+ const prompt = task.join(' ').trim();
2569
+ if (!prompt) {
2570
+ console.error('Error: task cannot be empty');
2571
+ process.exit(1);
2572
+ return;
2573
+ }
2574
+
2575
+ options.agent = resolveAgentOrExit(options.agent);
2576
+
2577
+ if (options.fanOut) {
2578
+ const conflicts = ['ask', 'quick', 'dryRun']
2579
+ .filter((key) => options[key])
2580
+ .map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
2581
+ if (conflicts.length > 0) {
2582
+ console.error(`Error: --fan-out cannot be combined with ${conflicts.join(', ')}`);
2583
+ process.exit(1);
2584
+ return;
2585
+ }
2586
+ if (process.env.ORCH_FANOUT_DEPTH) {
2587
+ console.error('Error: --fan-out cannot be used inside a fan-out child (ORCH_FANOUT_DEPTH is already set)');
2588
+ process.exit(1);
2589
+ return;
2590
+ }
2591
+
2592
+ const cwd = process.cwd();
2593
+ const { slug } = allocateJob({
2594
+ cwd,
2595
+ prompt,
2596
+ agent: options.agent,
2597
+ maxRounds: options.maxRounds,
2598
+ state: 'running',
2599
+ pid: process.pid,
2600
+ role: 'coordinator',
2601
+ });
2602
+ setJobSlug(slug);
2603
+
2604
+ await runFanoutPipeline(prompt, {
2605
+ ...options,
2606
+ cwd,
2607
+ jobSlug: slug,
2608
+ jobCwd: cwd,
2609
+ maxWorkers: options.maxWorkers,
2610
+ maxConcurrency: options.maxConcurrency ?? null,
2611
+ });
2612
+ return;
2613
+ }
2614
+
2615
+ if (options.worker || options.integrate) {
2616
+ const flagName = options.worker ? '--worker' : '--integrate';
2617
+ const conflicts = ['ask', 'quick', 'detach', 'dryRun']
2618
+ .filter((key) => options[key])
2619
+ .map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
2620
+ if (conflicts.length > 0) {
2621
+ console.error(`Error: ${flagName} cannot be combined with ${conflicts.join(', ')}`);
2622
+ process.exit(1);
2623
+ return;
2624
+ }
2625
+
2626
+ if (options.worker) {
2627
+ await runWorkerFromCli(options);
2628
+ } else {
2629
+ await runIntegrateFromCli(options);
2630
+ }
2631
+ return;
2632
+ }
2633
+
2634
+ if (options.detach) {
2635
+ const conflicts = ['ask', 'quick', 'dryRun']
2636
+ .filter((key) => options[key])
2637
+ .map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
2638
+ if (conflicts.length > 0) {
2639
+ console.error(`Error: --detach cannot be combined with ${conflicts.join(', ')}`);
2640
+ process.exit(1);
2641
+ return;
2642
+ }
2643
+ await runDetached(prompt, options);
2644
+ return;
2645
+ }
2646
+
2647
+ if (!options.dryRun) {
2648
+ const { slug } = allocateJob({
2649
+ cwd: process.cwd(),
2650
+ prompt,
2651
+ agent: options.agent,
2652
+ maxRounds: options.ask || options.quick ? null : options.maxRounds,
2653
+ state: 'running',
2654
+ pid: process.pid,
2655
+ });
2656
+ options.jobSlug = slug;
2657
+ setJobSlug(slug);
2658
+ }
2659
+
2660
+ await runPipeline(prompt, options);
2661
+ });
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
+
2835
+ program
2836
+ .command('list')
2837
+ .description('List all runs (active and finished) tracked under .orch/ in this directory')
2838
+ .action(() => {
2839
+ const jobs = listJobs(process.cwd());
2840
+ if (jobs.length === 0) {
2841
+ console.log('no runs');
2842
+ return;
2843
+ }
2844
+ console.log(formatJobsTable(jobs));
2845
+ });
2846
+
2847
+ program
2848
+ .command('status')
2849
+ .argument('[slug]', 'Run slug to show; defaults to the most recently started run in this directory')
2850
+ .description('Show full status for a run')
2851
+ .action((slug) => {
2852
+ const cwd = process.cwd();
2853
+ let record;
2854
+ if (slug) {
2855
+ record = readJob(cwd, slug);
2856
+ if (!record) {
2857
+ console.error(`Error: unknown run ${slug}`);
2858
+ process.exit(1);
2859
+ return;
2860
+ }
2861
+ record = reconcileJob(cwd, slug, record);
2862
+ } else {
2863
+ const jobs = listJobs(cwd);
2864
+ if (jobs.length === 0) {
2865
+ console.error('Error: no runs found in this directory');
2866
+ process.exit(1);
2867
+ return;
2868
+ }
2869
+ [record] = jobs;
2870
+ }
2871
+ console.log(formatStatus(cwd, record));
2872
+ });
2873
+
2874
+ program
2875
+ .command('pause')
2876
+ .argument('<slug>', 'Run slug to pause')
2877
+ .description('Request a running job to pause at its next stage-boundary checkpoint')
2878
+ .action((slug) => {
2879
+ const cwd = process.cwd();
2880
+ try {
2881
+ const record = readJob(cwd, slug);
2882
+ if (!record) throw new Error(`requestPause: unknown job ${slug}`);
2883
+ if (isCascadeParent(cwd, record)) {
2884
+ const { childrenSignaled } = cascadePause(cwd, slug);
2885
+ console.log(`pause requested for ${slug} (${childrenSignaled} children signaled)`);
2886
+ } else {
2887
+ requestPause(cwd, slug);
2888
+ console.log(`pause requested for ${slug}`);
2889
+ }
2890
+ } catch (err) {
2891
+ console.error(`Error: ${err.message}`);
2892
+ process.exit(1);
2893
+ }
2894
+ });
2895
+
2896
+ program
2897
+ .command('resume')
2898
+ .argument('<slug>', 'Run slug to resume')
2899
+ .description('Resume a paused (or pausing) job')
2900
+ .action((slug) => {
2901
+ const cwd = process.cwd();
2902
+ try {
2903
+ const record = readJob(cwd, slug);
2904
+ if (!record) throw new Error(`requestResume: unknown job ${slug}`);
2905
+ if (isCascadeParent(cwd, record)) {
2906
+ cascadeResume(cwd, slug);
2907
+ console.log(`resumed ${slug}`);
2908
+ } else {
2909
+ requestResume(cwd, slug);
2910
+ console.log(`resumed ${slug}`);
2911
+ }
2912
+ } catch (err) {
2913
+ console.error(`Error: ${err.message}`);
2914
+ process.exit(1);
2915
+ }
2916
+ });
2917
+
2918
+ program
2919
+ .command('stop')
2920
+ .argument('<slug>', 'Run slug to stop')
2921
+ .description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
2922
+ .action((slug) => {
2923
+ const cwd = process.cwd();
2924
+ try {
2925
+ const record = readJob(cwd, slug);
2926
+ if (!record) throw new Error(`stopJob: unknown job ${slug}`);
2927
+ if (isCascadeParent(cwd, record)) {
2928
+ cascadeStop(cwd, slug);
2929
+ console.log(`stop signal sent to ${slug} and its children`);
2930
+ } else {
2931
+ const result = stopJob(cwd, slug);
2932
+ if (result.action === 'signaled') {
2933
+ console.log(`stop signal sent to ${slug} (pid ${result.record.pid})`);
2934
+ } else if (result.action === 'crashed') {
2935
+ console.log(`${slug} process was already gone; marked crashed`);
2936
+ } else {
2937
+ console.log(`${slug} is already ${result.record.state}`);
2938
+ }
2939
+ }
2940
+ } catch (err) {
2941
+ console.error(`Error: ${err.message}`);
2942
+ process.exit(1);
2943
+ }
2944
+ });
2945
+
2946
+ const jobsCmd = program
2947
+ .command('jobs')
2948
+ .description('Manage orch job artifacts under .orch/');
2949
+
2950
+ jobsCmd
2951
+ .command('clean')
2952
+ .description('Delete all orch jobs from the .orch folder')
2953
+ .action(async () => {
2954
+ const cwd = process.cwd();
2955
+ const orchDir = path.join(cwd, '.orch');
2956
+ if (!fs.existsSync(orchDir) || fs.readdirSync(orchDir).length === 0) {
2957
+ console.log('no jobs to clean');
2958
+ return;
2959
+ }
2960
+
2961
+ const rl = readline.createInterface({ input, output });
2962
+ let answer;
2963
+ try {
2964
+ answer = await rl.question('Are you sure? [y/N] ');
2965
+ } finally {
2966
+ rl.close();
2967
+ }
2968
+
2969
+ if (!/^y(es)?$/i.test(answer.trim())) {
2970
+ console.log('aborted');
2971
+ return;
2972
+ }
2973
+
2974
+ const removed = cleanJobs(cwd);
2975
+ console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
2976
+ });
2977
+
2978
+ program
2979
+ .command('logs')
2980
+ .argument('<slug>', 'Run slug to show logs for')
2981
+ .option('-f, --follow', 'Follow the log file until the job reaches a terminal state (Ctrl+C to stop)')
2982
+ .description("Print a run's orch.log")
2983
+ .action((slug, options) => {
2984
+ const cwd = process.cwd();
2985
+ const record = readJob(cwd, slug);
2986
+ if (!record) {
2987
+ console.error(`Error: unknown run ${slug}`);
2988
+ process.exit(1);
2989
+ return;
2990
+ }
2991
+ const { logPath } = jobPaths(cwd, slug);
2992
+ if (!fs.existsSync(logPath)) {
2993
+ console.error(`Error: no log file for ${slug}`);
2994
+ process.exit(1);
2995
+ return;
2996
+ }
2997
+
2998
+ if (!options.follow) {
2999
+ process.stdout.write(fs.readFileSync(logPath));
3000
+ return;
3001
+ }
3002
+
3003
+ const fd = fs.openSync(logPath, 'r');
3004
+ let position = 0;
3005
+ const pump = () => {
3006
+ const { size } = fs.fstatSync(fd);
3007
+ if (size > position) {
3008
+ const buf = Buffer.alloc(size - position);
3009
+ fs.readSync(fd, buf, 0, buf.length, position);
3010
+ process.stdout.write(buf);
3011
+ position = size;
3012
+ }
3013
+ };
3014
+ pump();
3015
+
3016
+ const stop = () => {
3017
+ clearInterval(interval);
3018
+ fs.closeSync(fd);
3019
+ process.exit(0);
3020
+ };
3021
+
3022
+ const interval = setInterval(() => {
3023
+ pump();
3024
+ const current = reconcileJob(cwd, slug, readJob(cwd, slug));
3025
+ if (TERMINAL_JOB_STATES.includes(current.state)) stop();
3026
+ }, 500);
3027
+
3028
+ process.once('SIGINT', stop);
504
3029
  });
505
3030
 
506
3031
  const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';