@welluable/orch 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js CHANGED
@@ -10,15 +10,16 @@ import { fileURLToPath } from 'url';
10
10
  import { AgentCursor } from './lib/agent-cursor.js';
11
11
  import { AgentClaude } from './lib/agent-claude.js';
12
12
  import { AgentAgn } from './lib/agent-agn.js';
13
+ import { AgentOpencode } from './lib/agent-opencode.js';
13
14
  import { parseTriageJson } from './lib/parse-triage-json.js';
14
15
  import { parseVerdict } from './lib/parse-verdict.js';
15
- import { splitStageSummary, printStageSummary } from './lib/stage-summary.js';
16
+ import { splitStageSummary, printStageSummary, resolveStageSummary } from './lib/stage-summary.js';
16
17
  import { createRunContext } from './lib/run-context.js';
17
18
  import { createWorktree } from './lib/worktree.js';
18
19
  import { commitWorktree, collectWorktreeChanges, printFilesChanged } from './lib/commit.js';
19
20
  import { FileTracker } from './lib/file-tracker.js';
20
21
  import { allocateJob } from './lib/job-lifecycle.js';
21
- import { setJobSlug, exitCodeForSignal, formatElapsed } from './lib/agent.js';
22
+ import { setJobSlug, exitCodeForSignal, formatElapsed, flushFailureLog, beginStageCapture } from './lib/agent.js';
22
23
  import {
23
24
  jobPaths,
24
25
  readJob,
@@ -32,8 +33,21 @@ import {
32
33
  cascadeResume,
33
34
  stopJob,
34
35
  cleanJobs,
36
+ liveSlugsBlockingClean,
35
37
  isPidAlive,
38
+ reopenJob,
39
+ buildLastOutcome,
36
40
  } from './lib/jobs.js';
41
+ import {
42
+ validateContinue,
43
+ snapshotPriorOutcome,
44
+ buildPriorOutcomeText,
45
+ } from './lib/continue.js';
46
+ import {
47
+ validateResume,
48
+ reopenForResume,
49
+ runRecover,
50
+ } from './lib/resume.js';
37
51
  import { askAgentArgs } from './agents/ask.js';
38
52
  import { triageAgentArgs } from './agents/triage.js';
39
53
  import { quickFixAgentArgs } from './agents/quick-fix.js';
@@ -46,6 +60,8 @@ import { testRunnerAgentArgs } from './agents/test-runner.js';
46
60
  import { integratorAgentArgs } from './agents/integrator.js';
47
61
  import { boundariesAgentArgs } from './agents/boundaries.js';
48
62
  import { decomposerAgentArgs } from './agents/decomposer.js';
63
+ import { seqDecomposerAgentArgs } from './agents/seq-decomposer.js';
64
+ import { adjustAgentArgs } from './agents/adjust.js';
49
65
  import {
50
66
  readFanout,
51
67
  writeFanout,
@@ -60,6 +76,17 @@ import {
60
76
  detectOverlaps,
61
77
  ensureScaffoldSubtask,
62
78
  } from './lib/fanout.js';
79
+ import {
80
+ readSeq,
81
+ writeSeq,
82
+ patchUnit,
83
+ patchTip,
84
+ appendAdjustment,
85
+ validateSeqDecomposition,
86
+ buildUnitEnvelope,
87
+ validateAdjustResult,
88
+ applyAdjustResult,
89
+ } from './lib/seq.js';
63
90
  import { parseDecomposition } from './lib/parse-decomposition.js';
64
91
  import {
65
92
  mergeBranches,
@@ -67,6 +94,15 @@ import {
67
94
  conflictedFiles,
68
95
  hasConflictMarkers,
69
96
  } from './lib/integrate.js';
97
+ import {
98
+ resolveAgent,
99
+ resolveNotify,
100
+ writeConfig,
101
+ printConfig,
102
+ globalConfigPath,
103
+ localConfigPath,
104
+ } from './lib/config.js';
105
+ import { setNotifyEnabled } from './lib/notify.js';
70
106
 
71
107
  const __filename = fileURLToPath(import.meta.url);
72
108
  const __dirname = path.dirname(__filename);
@@ -91,6 +127,12 @@ const AGENT_BACKENDS = {
91
127
  binary: 'agn',
92
128
  missingHint: 'agn not found; run npm install -g @welluable/agn-cli or use --agent cursor',
93
129
  },
130
+ opencode: {
131
+ AgentClass: AgentOpencode,
132
+ binary: 'opencode',
133
+ missingHint:
134
+ 'opencode not found; install OpenCode (https://opencode.ai) or use --agent cursor',
135
+ },
94
136
  };
95
137
 
96
138
  function isBinaryOnPath(binary) {
@@ -173,9 +215,9 @@ export function formatJobsTable(jobs) {
173
215
  }
174
216
 
175
217
  const rows = ordered.map(({ job, indent }) => [
176
- `${indent}${job.slug}`,
218
+ `${indent}${job.slug ?? '-'}`,
177
219
  displayJobRole(job.role),
178
- job.state,
220
+ job.state ?? '-',
179
221
  job.phase ?? '-',
180
222
  job.agent ?? '-',
181
223
  formatRelativeTime(job.startedAt),
@@ -192,6 +234,21 @@ function lastNonEmptyLine(content) {
192
234
  return lines[lines.length - 1];
193
235
  }
194
236
 
237
+ /** Status / list `next:` hint per `.spec/resume.md` decision 28. */
238
+ function nextHintForRecord(record) {
239
+ if (!record) return null;
240
+ if (record.state === 'paused' || record.state === 'pausing') {
241
+ return `orch resume ${record.slug}`;
242
+ }
243
+ if (record.state === 'failed' || record.state === 'stopped' || record.state === 'crashed') {
244
+ return `orch resume ${record.slug}`;
245
+ }
246
+ if (record.state === 'done') {
247
+ return `orch continue ${record.slug} "<follow-up>"`;
248
+ }
249
+ return null;
250
+ }
251
+
195
252
  export function formatStatus(cwd, record) {
196
253
  const lines = [
197
254
  `slug: ${record.slug}`,
@@ -211,12 +268,27 @@ export function formatStatus(cwd, record) {
211
268
  lines.splice(1, 0, `parent: ${record.parent}`);
212
269
  }
213
270
 
271
+ if (record.continuation > 1) {
272
+ const stateIdx = lines.findIndex((line) => line.startsWith('state:'));
273
+ lines.splice(stateIdx + 1, 0, `continuation: ${record.continuation}`);
274
+ }
275
+
276
+ if (record.lastOutcome) {
277
+ const o = record.lastOutcome;
278
+ lines.push(`outcome: ${o.phase ?? '-'} / ${o.stage ?? '-'} (round ${o.round ?? '-'})`);
279
+ if (o.summary) lines.push(`summary: ${o.summary}`);
280
+ if (o.error) lines.push(`error: ${o.error}`);
281
+ }
282
+
214
283
  const statusPath = path.join(jobPaths(cwd, record.slug).dir, 'status.md');
215
284
  if (fs.existsSync(statusPath)) {
216
285
  const last = lastNonEmptyLine(fs.readFileSync(statusPath, 'utf8'));
217
286
  if (last) lines.push(`status: ${last}`);
218
287
  }
219
288
 
289
+ const nextHint = nextHintForRecord(record);
290
+ if (nextHint) lines.push(`next: ${nextHint}`);
291
+
220
292
  // Child view: parent line only — do not expand siblings.
221
293
  // Read children from disk without reconcile so status reflects recorded
222
294
  // state/phase/branch (listJobs would rewrite dead-pid live states to crashed).
@@ -275,6 +347,58 @@ function defaultExecFile(command, args, options = {}) {
275
347
  return execFileSync(command, args, { encoding: 'utf8', ...options });
276
348
  }
277
349
 
350
+ /** Patch a job to a terminal state and write a matching `lastOutcome` in the same write. */
351
+ function patchTerminalJob(patchJobFn, jobCwd, jobSlug, { state, exitCode, summary = '', error = null, task }) {
352
+ if (!jobSlug) return null;
353
+ const finishedAt = new Date().toISOString();
354
+ let outcomeError = error;
355
+ if (state === 'failed') {
356
+ const pointer = flushFailureLog({
357
+ cwd: jobCwd,
358
+ slug: jobSlug,
359
+ state,
360
+ exitCode,
361
+ finishedAt,
362
+ task,
363
+ error,
364
+ });
365
+ if (pointer) {
366
+ outcomeError = pointer;
367
+ console.error(`error: ${pointer}`);
368
+ }
369
+ }
370
+ patchJobFn(jobCwd, jobSlug, (current) => ({
371
+ state,
372
+ exitCode,
373
+ finishedAt,
374
+ lastOutcome: buildLastOutcome({
375
+ state,
376
+ phase: current.phase,
377
+ stage: current.stage,
378
+ round: current.round,
379
+ exitCode,
380
+ finishedAt,
381
+ task: task ?? current.task,
382
+ summary,
383
+ error: outcomeError,
384
+ }),
385
+ }));
386
+ return outcomeError;
387
+ }
388
+
389
+ /** Patch live cursor fields and start stage-verbose capture when a job is active. */
390
+ function patchJobCursor(patchJobFn, jobCwd, jobSlug, fields) {
391
+ if (!jobSlug) return;
392
+ if ('phase' in fields || 'stage' in fields) {
393
+ beginStageCapture({
394
+ phase: fields.phase ?? null,
395
+ stage: fields.stage ?? null,
396
+ round: 'round' in fields ? fields.round : null,
397
+ });
398
+ }
399
+ return patchJobFn(jobCwd, jobSlug, fields);
400
+ }
401
+
278
402
  /** The test-writer ⇄ test-critic loop shared by `runPipeline` and `runWorkerPipeline`. */
279
403
  async function runTestLoop({
280
404
  prompt,
@@ -287,13 +411,17 @@ async function runTestLoop({
287
411
  verbose,
288
412
  jobPatch,
289
413
  jobCheckpoint,
414
+ startAt = null,
290
415
  }) {
291
416
  let testAccepted = null;
292
417
  let criticFeedback = null;
293
418
  let testRound = 0;
294
419
  let testSummary = '';
295
420
 
296
- for (let round = 1; round <= maxRounds; round++) {
421
+ // Resume at recorded round; always re-run writer for that round (need output for critic).
422
+ const startRound = Math.max(1, Number(startAt?.round) || 1);
423
+
424
+ for (let round = startRound; round <= maxRounds; round++) {
297
425
  testRound = round;
298
426
 
299
427
  jobPatch({ phase: 'test-loop', stage: 'test-writer', round });
@@ -319,7 +447,7 @@ async function runTestLoop({
319
447
  const { content: testWriterContent, summary: testWriterSummary } = splitStageSummary(testOut.result);
320
448
  printStageSummary(
321
449
  roundLabel('test-writer', round, maxRounds),
322
- testWriterSummary,
450
+ resolveStageSummary(roundLabel('test-writer', round, maxRounds), testWriterSummary, testWriterContent),
323
451
  testWriterTracker.getFiles(),
324
452
  );
325
453
  if (!testOut.ok) {
@@ -352,7 +480,10 @@ async function runTestLoop({
352
480
  const criticOut = await testCritic.run({ verbose });
353
481
  await jobCheckpoint();
354
482
  const { content: testCriticContent, summary: testCriticSummary } = splitStageSummary(criticOut.result);
355
- printStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary);
483
+ printStageSummary(
484
+ roundLabel('test-critic', round, maxRounds),
485
+ resolveStageSummary(roundLabel('test-critic', round, maxRounds), testCriticSummary, testCriticContent),
486
+ );
356
487
  if (!criticOut.ok) {
357
488
  appendLoopStatus(statusPath, 'Test loop', {
358
489
  round: testRound,
@@ -406,6 +537,7 @@ async function runCodeLoop({
406
537
  acceptedVerification,
407
538
  runnerFirst = false,
408
539
  loopTitle = 'Code loop',
540
+ startAt = null,
409
541
  }) {
410
542
  let codeAccepted = null;
411
543
  let runnerFeedback = null;
@@ -413,9 +545,14 @@ async function runCodeLoop({
413
545
  let codeSummary = '';
414
546
  let codeWriterContent = null;
415
547
 
416
- for (let round = 1; round <= maxRounds; round++) {
548
+ const startRound = Math.max(1, Number(startAt?.round) || 1);
549
+ // When resuming at test-runner, skip writer once for that round (runner-first style).
550
+ let resumeSkipWriter = startAt?.stage === 'test-runner';
551
+
552
+ for (let round = startRound; round <= maxRounds; round++) {
417
553
  codeRound = round;
418
- const skipWriter = runnerFirst && round === 1;
554
+ const skipWriter = (runnerFirst && round === 1) || (resumeSkipWriter && round === startRound);
555
+ if (resumeSkipWriter && round === startRound) resumeSkipWriter = false;
419
556
 
420
557
  if (!skipWriter) {
421
558
  jobPatch({ phase: 'code-loop', stage: 'code-writer', round });
@@ -444,7 +581,7 @@ async function runCodeLoop({
444
581
  codeWriterContent = content;
445
582
  printStageSummary(
446
583
  roundLabel('code-writer', round, maxRounds),
447
- summary,
584
+ resolveStageSummary(roundLabel('code-writer', round, maxRounds), summary, content),
448
585
  codeWriterTracker.getFiles(),
449
586
  );
450
587
  if (!codeOut.ok) {
@@ -477,7 +614,10 @@ async function runCodeLoop({
477
614
  const runnerOut = await testRunner.run({ verbose });
478
615
  await jobCheckpoint();
479
616
  const { content: testRunnerContent, summary: testRunnerSummary } = splitStageSummary(runnerOut.result);
480
- printStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary);
617
+ printStageSummary(
618
+ roundLabel('test-runner', round, maxRounds),
619
+ resolveStageSummary(roundLabel('test-runner', round, maxRounds), testRunnerSummary, testRunnerContent),
620
+ );
481
621
  if (!runnerOut.ok) {
482
622
  appendLoopStatus(statusPath, loopTitle, {
483
623
  round: codeRound,
@@ -534,7 +674,7 @@ export async function runPipeline(prompt, options) {
534
674
 
535
675
  const jobPatch = (fields) => {
536
676
  if (!jobSlug) return;
537
- patchJobFn(jobCwd, jobSlug, fields);
677
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
538
678
  };
539
679
  const jobCheckpoint = async () => {
540
680
  if (!jobSlug) return;
@@ -573,7 +713,7 @@ export async function runPipeline(prompt, options) {
573
713
  return;
574
714
  }
575
715
  const { content, summary } = splitStageSummary(askResult.result);
576
- printStageSummary('ask', summary);
716
+ printStageSummary('ask', resolveStageSummary('ask', summary, content));
577
717
  console.log(content);
578
718
  jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
579
719
  } catch (err) {
@@ -603,8 +743,8 @@ export async function runPipeline(prompt, options) {
603
743
  process.exit(1);
604
744
  return;
605
745
  }
606
- const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
607
- printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
746
+ const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
747
+ printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
608
748
  jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
609
749
  } catch (err) {
610
750
  console.error(`Error: ${err.message}`);
@@ -628,7 +768,7 @@ export async function runPipeline(prompt, options) {
628
768
  const triageResult = await triageAgent.run({ verbose });
629
769
  await jobCheckpoint();
630
770
  const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
631
- printStageSummary('triage', triageSummary);
771
+ printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
632
772
  const parsed = parseTriageJson(triageContent);
633
773
 
634
774
  if (parsed?.simple === true) {
@@ -648,8 +788,8 @@ export async function runPipeline(prompt, options) {
648
788
 
649
789
  const quickFixResult = await quickFixAgent.run({ verbose });
650
790
  await jobCheckpoint();
651
- const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
652
- printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
791
+ const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
792
+ printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
653
793
  jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
654
794
  return;
655
795
  }
@@ -675,7 +815,7 @@ export async function runPipeline(prompt, options) {
675
815
  const result = await researchAgent.run({ verbose });
676
816
  await jobCheckpoint();
677
817
  const { content: researchContent, summary: researchSummary } = splitStageSummary(result.result);
678
- printStageSummary('research', researchSummary);
818
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
679
819
 
680
820
  jobPatch({ phase: 'plan', stage: 'planner', round: null });
681
821
  const planner = plannerAgentArgs({
@@ -694,8 +834,8 @@ export async function runPipeline(prompt, options) {
694
834
 
695
835
  const plannerResult = await plannerAgent.run({ verbose });
696
836
  await jobCheckpoint();
697
- const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
698
- printStageSummary('planner', plannerSummary);
837
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
838
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
699
839
 
700
840
  jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
701
841
  const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
@@ -729,7 +869,7 @@ export async function runPipeline(prompt, options) {
729
869
  .filter(Boolean)
730
870
  .join('\n');
731
871
 
732
- await runCodeLoop({
872
+ const codeAccepted = await runCodeLoop({
733
873
  prompt,
734
874
  worktreePath: worktree.worktreePath,
735
875
  branch: worktree.branch,
@@ -762,6 +902,7 @@ export async function runPipeline(prompt, options) {
762
902
  );
763
903
  console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
764
904
  console.log(`merge: git merge ${commitResult.branch}`);
905
+ console.log(`next: orch continue ${runContext.slug} "…"`);
765
906
  } else {
766
907
  fs.appendFileSync(
767
908
  runContext.statusPath,
@@ -770,20 +911,29 @@ export async function runPipeline(prompt, options) {
770
911
  console.log(`commit: no changes on ${commitResult.branch}`);
771
912
  }
772
913
 
773
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
914
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
915
+ state: 'done',
916
+ exitCode: 0,
917
+ summary: codeAccepted?.verdict?.summary ?? '',
918
+ error: null,
919
+ task: prompt,
920
+ });
774
921
  } catch (err) {
775
922
  console.error(`Error: ${err.message}`);
776
923
  if (jobSlug) {
777
924
  try {
778
- patchJobFn(jobCwd, jobSlug, {
925
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
779
926
  state: 'failed',
780
927
  exitCode: 1,
781
- finishedAt: new Date().toISOString(),
928
+ summary: '',
929
+ error: err.message,
930
+ task: prompt,
782
931
  });
783
932
  } catch {
784
933
  // Best-effort: don't let a job-state write failure mask the real error.
785
934
  }
786
935
  }
936
+ console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
787
937
  process.exit(1);
788
938
  }
789
939
  }
@@ -801,6 +951,9 @@ export async function runDetached(prompt, options = {}) {
801
951
  maxRounds = 5,
802
952
  verbose,
803
953
  cwd = process.cwd(),
954
+ seq = false,
955
+ maxUnits = 8,
956
+ notify,
804
957
  createRunContext: createRunContextFn = createRunContext,
805
958
  spawn: spawnFn = spawn,
806
959
  exit = (code) => process.exit(code),
@@ -824,6 +977,7 @@ export async function runDetached(prompt, options = {}) {
824
977
  maxRounds,
825
978
  state: 'starting',
826
979
  createRunContext: createRunContextFn,
980
+ role: seq ? 'coordinator' : null,
827
981
  });
828
982
  const { logPath } = jobPaths(cwd, slug);
829
983
 
@@ -831,6 +985,10 @@ export async function runDetached(prompt, options = {}) {
831
985
 
832
986
  const childArgs = [__filename, prompt, '--agent', agent, '--max-rounds', String(maxRounds)];
833
987
  if (verbose) childArgs.push('--verbose');
988
+ if (seq) {
989
+ childArgs.push('--seq', '--max-units', String(maxUnits));
990
+ }
991
+ appendNotifyArgs(childArgs, notify);
834
992
 
835
993
  const child = spawnFn(process.execPath, childArgs, {
836
994
  cwd,
@@ -847,14 +1005,11 @@ export async function runDetached(prompt, options = {}) {
847
1005
  }
848
1006
 
849
1007
  /**
850
- * The `--worker <parent>:<workerId>` driver: skips triage and runs research planner →
851
- * worktree (from the fan-out's recorded `base`) test loop → code loop (writer-first) →
852
- * commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
853
- * with the envelope already appended by the CLI wiring. On success, patches the parent's
854
- * `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
855
- * patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
1008
+ * Continue pipeline: full complex stages on an existing worktree/branch.
1009
+ * Skips triage and `createWorktree`. Injects prior-outcome text into
1010
+ * research/planner only. See `.spec/continue.md`.
856
1011
  */
857
- export async function runWorkerPipeline(prompt, options = {}) {
1012
+ export async function runContinuePipeline(prompt, options = {}) {
858
1013
  const verbose = Boolean(options.verbose);
859
1014
  const maxRounds = options.maxRounds ?? 5;
860
1015
  const backend = AGENT_BACKENDS[options.agent];
@@ -863,25 +1018,35 @@ export async function runWorkerPipeline(prompt, options = {}) {
863
1018
  }
864
1019
  const AgentClass = options.AgentClass ?? backend.AgentClass;
865
1020
  const cwd = options.cwd ?? process.cwd();
866
- const { parentSlug, workerId, base } = options;
1021
+ const {
1022
+ slug,
1023
+ priorOutcome,
1024
+ continuation,
1025
+ } = options;
867
1026
 
868
1027
  const createRunContextFn = options.createRunContext ?? createRunContext;
869
- const createWorktreeFn = options.createWorktree ?? createWorktree;
870
1028
  const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
871
1029
  const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
872
1030
  const patchWorkerFn = options.patchWorker ?? patchWorker;
873
1031
  const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
874
1032
  const execFileFn = options.execFile;
875
1033
 
876
- const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1034
+ const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
877
1035
  const jobCwd = options.jobCwd ?? cwd;
878
1036
  const patchJobFn = options.patchJob ?? patchJob;
879
1037
  const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
880
1038
  const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
881
1039
 
1040
+ const jobRecord = (jobSlug && readJob(jobCwd, jobSlug)) || null;
1041
+ const worktreePath = options.worktreePath ?? jobRecord?.worktree;
1042
+ const branch = options.branch ?? jobRecord?.branch;
1043
+ const role = options.role ?? jobRecord?.role;
1044
+ const parentSlug = options.parentSlug ?? jobRecord?.parent;
1045
+ const workerId = options.workerId ?? jobRecord?.workerId;
1046
+
882
1047
  const jobPatch = (fields) => {
883
1048
  if (!jobSlug) return;
884
- patchJobFn(jobCwd, jobSlug, fields);
1049
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
885
1050
  };
886
1051
  const jobCheckpoint = async () => {
887
1052
  if (!jobSlug) return;
@@ -892,11 +1057,44 @@ export async function runWorkerPipeline(prompt, options = {}) {
892
1057
  ensureBinaryOnPath(backend.binary, options.agent);
893
1058
  }
894
1059
 
1060
+ const priorBlock = buildPriorOutcomeText(priorOutcome, {
1061
+ slug,
1062
+ continuation,
1063
+ worktreePath,
1064
+ branch,
1065
+ parentSlug,
1066
+ workerId,
1067
+ });
1068
+ const researchPlannerPrompt = `${priorBlock}\n\nUser follow-up:\n${prompt}`;
1069
+
895
1070
  try {
896
- const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
1071
+ const runContext = createRunContextFn({ cwd, slug });
1072
+
1073
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1074
+ const prior = priorOutcome ?? {};
1075
+ const startedIso = new Date().toISOString();
1076
+ let continueSection = `\n## Continue ${continuation}\n\n`;
1077
+ continueSection += `- Task: ${prompt.split('\n')[0]}\n`;
1078
+ continueSection += `- Started: ${startedIso}\n`;
1079
+ continueSection += `- Branch: \`${branch}\`\n`;
1080
+ continueSection += `- Worktree: \`${worktreePath}\`\n`;
1081
+ if (parentSlug) continueSection += `- Fan-out parent: \`${parentSlug}\`\n`;
1082
+ if (workerId) continueSection += `- Worker id: \`${workerId}\`\n`;
1083
+ continueSection += `\n### Prior outcome\n\n`;
1084
+ continueSection += `- State: ${prior.state ?? '(none recorded)'}\n`;
1085
+ continueSection += `- Phase: ${prior.phase ?? '(none recorded)'}\n`;
1086
+ continueSection += `- Stage: ${prior.stage ?? '(none recorded)'}\n`;
1087
+ continueSection += `- Round: ${prior.round ?? 'null'}\n`;
1088
+ continueSection += `- Summary: ${prior.summary || '(none recorded)'}\n`;
1089
+ if (prior.error) continueSection += `- Error: ${prior.error}\n`;
1090
+ fs.appendFileSync(runContext.statusPath, continueSection);
897
1091
 
898
1092
  jobPatch({ phase: 'research', stage: 'research', round: null });
899
- const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
1093
+ const research = researchAgentArgs({
1094
+ prompt: researchPlannerPrompt,
1095
+ cwd: worktreePath,
1096
+ researchPath: runContext.researchPath,
1097
+ });
900
1098
  const researchAgent = new AgentClass(
901
1099
  research.name,
902
1100
  research.instructions,
@@ -906,12 +1104,12 @@ export async function runWorkerPipeline(prompt, options = {}) {
906
1104
  const researchResult = await researchAgent.run({ verbose });
907
1105
  await jobCheckpoint();
908
1106
  const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
909
- printStageSummary('research', researchSummary);
1107
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
910
1108
 
911
1109
  jobPatch({ phase: 'plan', stage: 'planner', round: null });
912
1110
  const planner = plannerAgentArgs({
913
- prompt,
914
- cwd,
1111
+ prompt: researchPlannerPrompt,
1112
+ cwd: worktreePath,
915
1113
  researchPath: runContext.researchPath,
916
1114
  taskPath: runContext.taskPath,
917
1115
  researchOutput: researchContent,
@@ -924,23 +1122,13 @@ export async function runWorkerPipeline(prompt, options = {}) {
924
1122
  );
925
1123
  const plannerResult = await plannerAgent.run({ verbose });
926
1124
  await jobCheckpoint();
927
- const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
928
- printStageSummary('planner', plannerSummary);
929
-
930
- jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
931
- const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
932
- jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
933
-
934
- fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
935
- fs.writeFileSync(
936
- runContext.statusPath,
937
- `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
938
- );
1125
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
1126
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
939
1127
 
940
1128
  const testAccepted = await runTestLoop({
941
1129
  prompt,
942
- worktreePath: worktree.worktreePath,
943
- branch: worktree.branch,
1130
+ worktreePath,
1131
+ branch,
944
1132
  taskPath: runContext.taskPath,
945
1133
  statusPath: runContext.statusPath,
946
1134
  maxRounds,
@@ -954,10 +1142,10 @@ export async function runWorkerPipeline(prompt, options = {}) {
954
1142
  .filter(Boolean)
955
1143
  .join('\n');
956
1144
 
957
- await runCodeLoop({
1145
+ const codeAccepted = await runCodeLoop({
958
1146
  prompt,
959
- worktreePath: worktree.worktreePath,
960
- branch: worktree.branch,
1147
+ worktreePath,
1148
+ branch,
961
1149
  taskPath: runContext.taskPath,
962
1150
  statusPath: runContext.statusPath,
963
1151
  maxRounds,
@@ -969,12 +1157,12 @@ export async function runWorkerPipeline(prompt, options = {}) {
969
1157
  });
970
1158
 
971
1159
  jobPatch({ phase: 'commit', stage: 'commit', round: null });
972
- const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
973
- const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
1160
+ const message = `orch: ${slug} (continue ${continuation}): ${prompt.split('\n')[0]}`;
1161
+ const worktreeChanges = await collectWorktreeChangesFn({ worktreePath });
974
1162
  printFilesChanged(worktreeChanges);
975
- const commitResult = commitWorktreeFn({
976
- worktreePath: worktree.worktreePath,
977
- branch: worktree.branch,
1163
+ const commitResult = await commitWorktreeFn({
1164
+ worktreePath,
1165
+ branch,
978
1166
  message,
979
1167
  });
980
1168
 
@@ -984,6 +1172,7 @@ export async function runWorkerPipeline(prompt, options = {}) {
984
1172
  `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
985
1173
  );
986
1174
  console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1175
+ console.log(`merge: git merge ${commitResult.branch}`);
987
1176
  } else {
988
1177
  fs.appendFileSync(
989
1178
  runContext.statusPath,
@@ -992,40 +1181,71 @@ export async function runWorkerPipeline(prompt, options = {}) {
992
1181
  console.log(`commit: no changes on ${commitResult.branch}`);
993
1182
  }
994
1183
 
995
- let changedFiles = [];
996
- try {
997
- changedFiles = recordChangedFilesFn({
998
- repoRoot: worktree.repoRoot,
999
- base,
1000
- branch: worktree.branch,
1001
- execFile: execFileFn,
1002
- });
1003
- } catch {
1004
- // Best-effort: changedFiles is informational only, never masks a successful commit.
1184
+ if (role === 'worker' && parentSlug && workerId) {
1185
+ let changedFiles = [];
1186
+ try {
1187
+ changedFiles = recordChangedFilesFn({
1188
+ repoRoot: cwd,
1189
+ base: undefined,
1190
+ branch,
1191
+ execFile: execFileFn,
1192
+ });
1193
+ } catch {
1194
+ // Best-effort.
1195
+ }
1196
+ const seqDoc = readSeq(cwd, parentSlug);
1197
+ if (seqDoc) {
1198
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1199
+ patchUnitFn(cwd, parentSlug, workerId, {
1200
+ state: 'done',
1201
+ sha: commitResult.sha,
1202
+ changedFiles,
1203
+ slug,
1204
+ });
1205
+ console.log(`next: orch --seq-continue ${parentSlug}`);
1206
+ } else if (commitResult.committed) {
1207
+ patchWorkerFn(cwd, parentSlug, workerId, {
1208
+ state: 'done',
1209
+ sha: commitResult.sha,
1210
+ changedFiles,
1211
+ });
1212
+ console.log(`next: orch --integrate ${parentSlug}`);
1213
+ }
1005
1214
  }
1006
1215
 
1007
- patchWorkerFn(cwd, parentSlug, workerId, {
1216
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1008
1217
  state: 'done',
1009
- sha: commitResult.sha,
1010
- changedFiles,
1218
+ exitCode: 0,
1219
+ summary: codeAccepted?.verdict?.summary ?? '',
1220
+ error: null,
1221
+ task: prompt,
1011
1222
  });
1012
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1013
1223
  } catch (err) {
1014
1224
  console.error(`Error: ${err.message}`);
1015
- try {
1016
- patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1017
- } catch {
1018
- // Best-effort: don't let a fanout-state write failure mask the real error.
1225
+ if (role === 'worker' && parentSlug && workerId) {
1226
+ try {
1227
+ const seqDoc = readSeq(cwd, parentSlug);
1228
+ if (seqDoc) {
1229
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1230
+ patchUnitFn(cwd, parentSlug, workerId, { state: 'failed' });
1231
+ } else {
1232
+ patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1233
+ }
1234
+ } catch {
1235
+ // Best-effort.
1236
+ }
1019
1237
  }
1020
1238
  if (jobSlug) {
1021
1239
  try {
1022
- patchJobFn(jobCwd, jobSlug, {
1240
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1023
1241
  state: 'failed',
1024
1242
  exitCode: 1,
1025
- finishedAt: new Date().toISOString(),
1243
+ summary: '',
1244
+ error: err.message,
1245
+ task: prompt,
1026
1246
  });
1027
1247
  } catch {
1028
- // Best-effort: don't let a job-state write failure mask the real error.
1248
+ // Best-effort.
1029
1249
  }
1030
1250
  }
1031
1251
  process.exit(1);
@@ -1033,13 +1253,86 @@ export async function runWorkerPipeline(prompt, options = {}) {
1033
1253
  }
1034
1254
 
1035
1255
  /**
1036
- * The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
1037
- * by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
1038
- * via the `integrator` agent, one conflict at a time), then runs a runner-first verify
1039
- * loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
1040
- * Appends every step to `.orch/<job-slug>/integration.md` as it happens.
1256
+ * Detach-parent path for `orch continue`: validate, PATH-check, spawn a
1257
+ * `--detach`-stripped re-invocation, reopen the existing slug with the child
1258
+ * pid, print `started`, exit. Never runs pipeline stages itself.
1041
1259
  */
1042
- export async function runIntegratePipeline(options = {}) {
1260
+ export async function runContinueDetached(slug, prompt, options = {}) {
1261
+ const {
1262
+ agent,
1263
+ maxRounds = 5,
1264
+ verbose,
1265
+ cwd = process.cwd(),
1266
+ spawn: spawnFn = spawn,
1267
+ exit = (code) => process.exit(code),
1268
+ validateContinue: validateContinueFn = validateContinue,
1269
+ reopenJob: reopenJobFn = reopenJob,
1270
+ snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
1271
+ } = options;
1272
+
1273
+ const backend = AGENT_BACKENDS[agent];
1274
+ if (!backend) {
1275
+ throw new Error(`Unknown agent backend: ${agent}`);
1276
+ }
1277
+
1278
+ let record;
1279
+ try {
1280
+ record = validateContinueFn(cwd, slug, { task: prompt });
1281
+ } catch (err) {
1282
+ console.error(`Error: ${err.message}`);
1283
+ exit(1);
1284
+ return;
1285
+ }
1286
+
1287
+ if (!isBinaryOnPath(backend.binary)) {
1288
+ console.error(binaryMissingHint(agent));
1289
+ exit(1);
1290
+ return;
1291
+ }
1292
+
1293
+ const prior = snapshotPriorOutcomeFn(cwd, slug, record);
1294
+ const { logPath } = jobPaths(cwd, slug);
1295
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1296
+ const logFd = fs.openSync(logPath, 'a');
1297
+
1298
+ const childArgs = [
1299
+ __filename,
1300
+ 'continue',
1301
+ slug,
1302
+ prompt,
1303
+ '--agent',
1304
+ agent,
1305
+ '--max-rounds',
1306
+ String(maxRounds),
1307
+ ];
1308
+ if (verbose) childArgs.push('--verbose');
1309
+ appendNotifyArgs(childArgs, options.notify);
1310
+
1311
+ const child = spawnFn(process.execPath, childArgs, {
1312
+ cwd,
1313
+ env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
1314
+ detached: true,
1315
+ stdio: ['ignore', logFd, logFd],
1316
+ });
1317
+ child.unref();
1318
+
1319
+ const updated = reopenJobFn(cwd, slug, {
1320
+ task: prompt,
1321
+ agent,
1322
+ maxRounds,
1323
+ pid: child.pid,
1324
+ prior,
1325
+ });
1326
+
1327
+ console.log(`started ${slug} (pid ${child.pid}, continuation ${updated.continuation})`);
1328
+ exit(0);
1329
+ }
1330
+
1331
+ /**
1332
+ * Failure-resume pipeline: recover → re-enter unfinished stage → remaining phases.
1333
+ * Distinct from `runContinuePipeline` (new-task continue). See `.spec/resume.md`.
1334
+ */
1335
+ export async function runResumePipeline(options = {}) {
1043
1336
  const verbose = Boolean(options.verbose);
1044
1337
  const maxRounds = options.maxRounds ?? 5;
1045
1338
  const backend = AGENT_BACKENDS[options.agent];
@@ -1048,27 +1341,37 @@ export async function runIntegratePipeline(options = {}) {
1048
1341
  }
1049
1342
  const AgentClass = options.AgentClass ?? backend.AgentClass;
1050
1343
  const cwd = options.cwd ?? process.cwd();
1051
- const { parentSlug } = options;
1344
+ const {
1345
+ slug,
1346
+ priorOutcome,
1347
+ recoverBrief = '',
1348
+ } = options;
1052
1349
 
1350
+ const createRunContextFn = options.createRunContext ?? createRunContext;
1053
1351
  const createWorktreeFn = options.createWorktree ?? createWorktree;
1054
1352
  const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1055
- const readFanoutFn = options.readFanout ?? readFanout;
1056
- const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
1057
- const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
1058
- const abortMergeFn = options.abortMerge ?? abortMerge;
1059
- const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
1060
- const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
1061
- const execFileFn = options.execFile ?? defaultExecFile;
1353
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
1354
+ const patchWorkerFn = options.patchWorker ?? patchWorker;
1355
+ const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
1356
+ const execFileFn = options.execFile;
1062
1357
 
1063
- const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1358
+ const jobSlug = options.jobSlug ?? slug ?? process.env.ORCH_JOB_SLUG;
1064
1359
  const jobCwd = options.jobCwd ?? cwd;
1065
1360
  const patchJobFn = options.patchJob ?? patchJob;
1066
1361
  const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1067
1362
  const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1068
1363
 
1364
+ const jobRecord = (jobSlug && readJob(jobCwd, jobSlug)) || null;
1365
+ const worktreePath = options.worktreePath ?? jobRecord?.worktree;
1366
+ const branch = options.branch ?? jobRecord?.branch;
1367
+ const role = options.role ?? jobRecord?.role;
1368
+ const parentSlug = options.parentSlug ?? jobRecord?.parent;
1369
+ const workerId = options.workerId ?? jobRecord?.workerId;
1370
+ const prompt = options.prompt ?? jobRecord?.task ?? priorOutcome?.task ?? '';
1371
+
1069
1372
  const jobPatch = (fields) => {
1070
1373
  if (!jobSlug) return;
1071
- patchJobFn(jobCwd, jobSlug, fields);
1374
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
1072
1375
  };
1073
1376
  const jobCheckpoint = async () => {
1074
1377
  if (!jobSlug) return;
@@ -1079,250 +1382,2084 @@ export async function runIntegratePipeline(options = {}) {
1079
1382
  ensureBinaryOnPath(backend.binary, options.agent);
1080
1383
  }
1081
1384
 
1082
- const fanout = readFanoutFn(cwd, parentSlug);
1083
- if (!fanout) {
1084
- console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
1085
- process.exit(1);
1086
- return;
1087
- }
1088
-
1089
- const integrationSlug = jobSlug ?? parentSlug;
1090
- const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
1091
- const integrationMdPath = path.join(integrationDir, 'integration.md');
1092
- const logIntegration = (line) => {
1093
- fs.mkdirSync(integrationDir, { recursive: true });
1094
- fs.appendFileSync(integrationMdPath, `${line}\n`);
1095
- };
1385
+ const prior = priorOutcome ?? {};
1386
+ const phase = prior.phase ?? jobRecord?.phase ?? null;
1387
+ const stage = prior.stage ?? jobRecord?.stage ?? null;
1388
+ const round = prior.round ?? jobRecord?.round ?? null;
1096
1389
 
1097
- let merged = [...fanout.integration.merged];
1098
- let skipped = [...(fanout.integration.skipped ?? [])];
1390
+ const withRecover = (basePrompt) => (
1391
+ recoverBrief ? `${recoverBrief}\n\n${basePrompt}` : basePrompt
1392
+ );
1099
1393
 
1100
1394
  try {
1101
- fs.mkdirSync(integrationDir, { recursive: true });
1102
- fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
1103
-
1104
- jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1395
+ const runContext = createRunContextFn({ cwd, slug: jobSlug });
1396
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1105
1397
 
1106
- const reuseWorktreePath = `${cwd}-${parentSlug}`;
1107
- const expectedBranch = `orch/${parentSlug}`;
1108
- let worktree = null;
1398
+ const resumeSection = [
1399
+ '',
1400
+ '## Resume',
1401
+ '',
1402
+ `- Started: ${new Date().toISOString()}`,
1403
+ `- Prior state: ${prior.state ?? '(none)'}`,
1404
+ `- Reentry: ${phase ?? '-'}/${stage ?? '-'} (round ${round ?? '-'})`,
1405
+ `- Branch: \`${branch}\``,
1406
+ `- Worktree: \`${worktreePath}\``,
1407
+ '',
1408
+ ].join('\n');
1409
+ fs.appendFileSync(runContext.statusPath, resumeSection);
1410
+
1411
+ const hasResearch = fs.existsSync(runContext.researchPath);
1412
+ const hasTask = fs.existsSync(runContext.taskPath);
1413
+ const skipEarly = ['worktree', 'test-loop', 'code-loop', 'commit'].includes(phase)
1414
+ && hasResearch && hasTask;
1415
+
1416
+ let liveWorktree = worktreePath;
1417
+ let liveBranch = branch;
1418
+ let researchContent = hasResearch
1419
+ ? fs.readFileSync(runContext.researchPath, 'utf8')
1420
+ : '';
1421
+
1422
+ // --- research (only when cursor is research, or early artifacts missing) ---
1423
+ if (phase === 'research' || (!skipEarly && !hasResearch && ['research', 'plan', null].includes(phase))) {
1424
+ jobPatch({ phase: 'research', stage: 'research', round: null });
1425
+ const research = researchAgentArgs({
1426
+ prompt: withRecover(prompt),
1427
+ cwd: liveWorktree || cwd,
1428
+ researchPath: runContext.researchPath,
1429
+ });
1430
+ const researchAgent = new AgentClass(
1431
+ research.name,
1432
+ research.instructions,
1433
+ research.prompt,
1434
+ research.options,
1435
+ );
1436
+ const researchResult = await researchAgent.run({ verbose });
1437
+ await jobCheckpoint();
1438
+ const split = splitStageSummary(researchResult.result);
1439
+ researchContent = split.content;
1440
+ printStageSummary('research', resolveStageSummary('research', split.summary, researchContent));
1441
+ }
1109
1442
 
1110
- if (fs.existsSync(reuseWorktreePath)) {
1111
- const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
1112
- if (currentBranch === expectedBranch) {
1113
- worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
1114
- }
1443
+ // --- plan ---
1444
+ if (phase === 'plan' || phase === 'research' || (!skipEarly && !hasTask)) {
1445
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
1446
+ const plannerPrompt = phase === 'plan' ? withRecover(prompt) : prompt;
1447
+ const researchOut = researchContent
1448
+ || (hasResearch ? fs.readFileSync(runContext.researchPath, 'utf8') : '');
1449
+ const planner = plannerAgentArgs({
1450
+ prompt: plannerPrompt,
1451
+ cwd: liveWorktree || cwd,
1452
+ researchPath: runContext.researchPath,
1453
+ taskPath: runContext.taskPath,
1454
+ researchOutput: researchOut,
1455
+ });
1456
+ const plannerAgent = new AgentClass(
1457
+ planner.name,
1458
+ planner.instructions,
1459
+ planner.prompt,
1460
+ planner.options,
1461
+ );
1462
+ const plannerResult = await plannerAgent.run({ verbose });
1463
+ await jobCheckpoint();
1464
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
1465
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
1115
1466
  }
1116
1467
 
1117
- const reused = Boolean(worktree);
1118
- if (!worktree) {
1119
- worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
1468
+ // --- worktree ensure ---
1469
+ if (!liveWorktree || !fs.existsSync(liveWorktree) || phase === 'worktree') {
1470
+ if (liveWorktree && fs.existsSync(liveWorktree) && liveBranch) {
1471
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null, branch: liveBranch, worktree: liveWorktree });
1472
+ } else {
1473
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1474
+ const wt = createWorktreeFn({ cwd, slug: jobSlug });
1475
+ liveWorktree = wt.worktreePath;
1476
+ liveBranch = wt.branch;
1477
+ jobPatch({ branch: liveBranch, worktree: liveWorktree });
1478
+ }
1120
1479
  }
1121
1480
 
1122
- logIntegration(
1123
- reused
1124
- ? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
1125
- : `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
1126
- );
1481
+ let acceptedVerification = '';
1482
+ const enterTest = !phase || ['research', 'plan', 'worktree', 'test-loop'].includes(phase);
1483
+ const enterCode = !phase || ['research', 'plan', 'worktree', 'test-loop', 'code-loop'].includes(phase);
1484
+ const enterCommit = true;
1127
1485
 
1128
- jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1486
+ if (enterTest && phase !== 'code-loop' && phase !== 'commit') {
1487
+ const testStartAt = phase === 'test-loop'
1488
+ ? { stage: stage ?? 'test-writer', round: round ?? 1 }
1489
+ : null;
1490
+ const testPrompt = phase === 'test-loop' ? withRecover(prompt) : prompt;
1491
+ const testAccepted = await runTestLoop({
1492
+ prompt: testPrompt,
1493
+ worktreePath: liveWorktree,
1494
+ branch: liveBranch,
1495
+ taskPath: runContext.taskPath,
1496
+ statusPath: runContext.statusPath,
1497
+ maxRounds,
1498
+ AgentClass,
1499
+ verbose,
1500
+ jobPatch,
1501
+ jobCheckpoint,
1502
+ startAt: testStartAt,
1503
+ });
1504
+ acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1505
+ .filter(Boolean)
1506
+ .join('\n');
1507
+ } else if (hasTask) {
1508
+ // Code-loop / commit reentry: best-effort verification from task.md
1509
+ acceptedVerification = fs.readFileSync(runContext.taskPath, 'utf8').slice(0, 2000);
1510
+ }
1511
+
1512
+ if (enterCode && phase !== 'commit') {
1513
+ const codeStartAt = phase === 'code-loop'
1514
+ ? { stage: stage ?? 'code-writer', round: round ?? 1 }
1515
+ : null;
1516
+ const codePrompt = phase === 'code-loop' ? withRecover(prompt) : prompt;
1517
+ const codeAccepted = await runCodeLoop({
1518
+ prompt: codePrompt,
1519
+ worktreePath: liveWorktree,
1520
+ branch: liveBranch,
1521
+ taskPath: runContext.taskPath,
1522
+ statusPath: runContext.statusPath,
1523
+ maxRounds,
1524
+ AgentClass,
1525
+ verbose,
1526
+ jobPatch,
1527
+ jobCheckpoint,
1528
+ acceptedVerification,
1529
+ startAt: codeStartAt,
1530
+ });
1531
+
1532
+ if (enterCommit) {
1533
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1534
+ const message = `orch: ${jobSlug} (resume): ${String(prompt).split('\n')[0]}`;
1535
+ const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: liveWorktree });
1536
+ printFilesChanged(worktreeChanges);
1537
+ const commitResult = await commitWorktreeFn({
1538
+ worktreePath: liveWorktree,
1539
+ branch: liveBranch,
1540
+ message,
1541
+ });
1542
+
1543
+ if (commitResult.committed) {
1544
+ fs.appendFileSync(
1545
+ runContext.statusPath,
1546
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1547
+ );
1548
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1549
+ console.log(`merge: git merge ${commitResult.branch}`);
1550
+ console.log(`next: orch continue ${jobSlug} "…"`);
1551
+ } else {
1552
+ fs.appendFileSync(
1553
+ runContext.statusPath,
1554
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1555
+ );
1556
+ console.log(`commit: no changes on ${commitResult.branch}`);
1557
+ }
1558
+
1559
+ if (role === 'worker' && parentSlug && workerId) {
1560
+ let changedFiles = [];
1561
+ try {
1562
+ changedFiles = recordChangedFilesFn({
1563
+ repoRoot: cwd,
1564
+ base: undefined,
1565
+ branch: liveBranch,
1566
+ execFile: execFileFn,
1567
+ });
1568
+ } catch {
1569
+ // Best-effort.
1570
+ }
1571
+ const seqDoc = readSeq(cwd, parentSlug);
1572
+ if (seqDoc) {
1573
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1574
+ patchUnitFn(cwd, parentSlug, workerId, {
1575
+ state: 'done',
1576
+ sha: commitResult.sha,
1577
+ changedFiles,
1578
+ slug: jobSlug,
1579
+ });
1580
+ console.log(`next: orch --seq-continue ${parentSlug}`);
1581
+ } else if (commitResult.committed) {
1582
+ patchWorkerFn(cwd, parentSlug, workerId, {
1583
+ state: 'done',
1584
+ sha: commitResult.sha,
1585
+ changedFiles,
1586
+ });
1587
+ console.log(`next: orch --integrate ${parentSlug}`);
1588
+ }
1589
+ }
1590
+
1591
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1592
+ state: 'done',
1593
+ exitCode: 0,
1594
+ summary: codeAccepted?.verdict?.summary ?? '',
1595
+ error: null,
1596
+ task: prompt,
1597
+ });
1598
+ }
1599
+ return;
1600
+ }
1601
+
1602
+ // commit-only reentry
1603
+ if (phase === 'commit') {
1604
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1605
+ const message = `orch: ${jobSlug} (resume): ${String(prompt).split('\n')[0]}`;
1606
+ const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: liveWorktree });
1607
+ printFilesChanged(worktreeChanges);
1608
+ const commitResult = await commitWorktreeFn({
1609
+ worktreePath: liveWorktree,
1610
+ branch: liveBranch,
1611
+ message,
1612
+ });
1613
+ if (commitResult.committed) {
1614
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1615
+ console.log(`next: orch continue ${jobSlug} "…"`);
1616
+ } else {
1617
+ console.log(`commit: no changes on ${commitResult.branch}`);
1618
+ }
1619
+ if (role === 'worker' && parentSlug && workerId && commitResult.committed) {
1620
+ const seqDoc = readSeq(cwd, parentSlug);
1621
+ if (seqDoc) {
1622
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1623
+ patchUnitFn(cwd, parentSlug, workerId, {
1624
+ state: 'done',
1625
+ sha: commitResult.sha,
1626
+ changedFiles: [],
1627
+ slug: jobSlug,
1628
+ });
1629
+ } else {
1630
+ patchWorkerFn(cwd, parentSlug, workerId, {
1631
+ state: 'done',
1632
+ sha: commitResult.sha,
1633
+ changedFiles: [],
1634
+ });
1635
+ }
1636
+ }
1637
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1638
+ state: 'done',
1639
+ exitCode: 0,
1640
+ summary: '',
1641
+ error: null,
1642
+ task: prompt,
1643
+ });
1644
+ }
1645
+ } catch (err) {
1646
+ console.error(`Error: ${err.message}`);
1647
+ if (role === 'worker' && parentSlug && workerId) {
1648
+ try {
1649
+ const seqDoc = readSeq(cwd, parentSlug);
1650
+ if (seqDoc) {
1651
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1652
+ patchUnitFn(cwd, parentSlug, workerId, { state: 'failed' });
1653
+ } else {
1654
+ patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1655
+ }
1656
+ } catch {
1657
+ // Best-effort.
1658
+ }
1659
+ }
1660
+ if (jobSlug) {
1661
+ try {
1662
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1663
+ state: 'failed',
1664
+ exitCode: 1,
1665
+ summary: '',
1666
+ error: err.message,
1667
+ task: prompt,
1668
+ });
1669
+ } catch {
1670
+ // Best-effort.
1671
+ }
1672
+ }
1673
+ console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
1674
+ process.exit(1);
1675
+ }
1676
+ }
1677
+
1678
+ /**
1679
+ * Detach-parent path for failure `orch resume`: spawn background child with
1680
+ * ORCH_JOB_SLUG, reopen with child pid, print resumed, exit.
1681
+ */
1682
+ export async function runResumeDetached(slug, options = {}) {
1683
+ const {
1684
+ agent,
1685
+ maxRounds = 5,
1686
+ verbose,
1687
+ cwd = process.cwd(),
1688
+ spawn: spawnFn = spawn,
1689
+ exit = (code) => process.exit(code),
1690
+ validateResume: validateResumeFn = validateResume,
1691
+ reopenForResume: reopenForResumeFn = reopenForResume,
1692
+ snapshotPriorOutcome: snapshotPriorOutcomeFn = snapshotPriorOutcome,
1693
+ } = options;
1694
+
1695
+ const backend = AGENT_BACKENDS[agent];
1696
+ if (!backend) {
1697
+ throw new Error(`Unknown agent backend: ${agent}`);
1698
+ }
1699
+
1700
+ let validated;
1701
+ try {
1702
+ validated = validateResumeFn(cwd, slug, {});
1703
+ } catch (err) {
1704
+ console.error(`Error: ${err.message}`);
1705
+ exit(1);
1706
+ return;
1707
+ }
1708
+
1709
+ if (validated.mode !== 'failure') {
1710
+ console.error(`Error: --detach only applies to failure resume (got mode ${validated.mode})`);
1711
+ exit(1);
1712
+ return;
1713
+ }
1714
+
1715
+ if (!isBinaryOnPath(backend.binary)) {
1716
+ console.error(binaryMissingHint(agent));
1717
+ exit(1);
1718
+ return;
1719
+ }
1720
+
1721
+ const record = validated.record;
1722
+ const prior = snapshotPriorOutcomeFn(cwd, slug, record);
1723
+ const { logPath } = jobPaths(cwd, slug);
1724
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1725
+ const logFd = fs.openSync(logPath, 'a');
1726
+
1727
+ const childArgs = [
1728
+ __filename,
1729
+ 'resume',
1730
+ slug,
1731
+ '--agent',
1732
+ agent,
1733
+ '--max-rounds',
1734
+ String(maxRounds),
1735
+ ];
1736
+ if (verbose) childArgs.push('--verbose');
1737
+ appendNotifyArgs(childArgs, options.notify);
1738
+
1739
+ const child = spawnFn(process.execPath, childArgs, {
1740
+ cwd,
1741
+ env: { ...process.env, ORCH_JOB_SLUG: slug, ORCH_DETACHED: '1' },
1742
+ detached: true,
1743
+ stdio: ['ignore', logFd, logFd],
1744
+ });
1745
+ child.unref();
1746
+
1747
+ reopenForResumeFn(cwd, slug, {
1748
+ agent,
1749
+ maxRounds,
1750
+ pid: child.pid,
1751
+ prior,
1752
+ });
1753
+
1754
+ // Recover runs in the child; parent only prints the resume line.
1755
+ console.log(`resumed ${slug} (pid ${child.pid})`);
1756
+ exit(0);
1757
+ }
1758
+
1759
+ /**
1760
+ * The `--worker <parent>:<workerId>` driver: skips triage and runs research → planner →
1761
+ * worktree (from the fan-out's recorded `base`) → test loop → code loop (writer-first) →
1762
+ * commit, exactly like `runPipeline` minus triage. `prompt` is the worker's subtask text
1763
+ * with the envelope already appended by the CLI wiring. On success, patches the parent's
1764
+ * `fanout.json.workers[]` entry to `state:'done'` with `sha`/`changedFiles`; on failure,
1765
+ * patches it (and this job's own `run.json`) to `state:'failed'` before exiting non-zero.
1766
+ */
1767
+ export async function runWorkerPipeline(prompt, options = {}) {
1768
+ const verbose = Boolean(options.verbose);
1769
+ const maxRounds = options.maxRounds ?? 5;
1770
+ const backend = AGENT_BACKENDS[options.agent];
1771
+ if (!backend) {
1772
+ throw new Error(`Unknown agent backend: ${options.agent}`);
1773
+ }
1774
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
1775
+ const cwd = options.cwd ?? process.cwd();
1776
+ const { parentSlug, workerId, base } = options;
1777
+
1778
+ const createRunContextFn = options.createRunContext ?? createRunContext;
1779
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
1780
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1781
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
1782
+ const patchWorkerFn = options.patchWorker ?? patchWorker;
1783
+ const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
1784
+ const execFileFn = options.execFile;
1785
+
1786
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1787
+ const jobCwd = options.jobCwd ?? cwd;
1788
+ const patchJobFn = options.patchJob ?? patchJob;
1789
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1790
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1791
+
1792
+ const jobPatch = (fields) => {
1793
+ if (!jobSlug) return;
1794
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
1795
+ };
1796
+ const jobCheckpoint = async () => {
1797
+ if (!jobSlug) return;
1798
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
1799
+ };
1800
+
1801
+ if (!options.AgentClass) {
1802
+ ensureBinaryOnPath(backend.binary, options.agent);
1803
+ }
1804
+
1805
+ try {
1806
+ const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
1807
+
1808
+ jobPatch({ phase: 'research', stage: 'research', round: null });
1809
+ const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
1810
+ const researchAgent = new AgentClass(
1811
+ research.name,
1812
+ research.instructions,
1813
+ research.prompt,
1814
+ research.options,
1815
+ );
1816
+ const researchResult = await researchAgent.run({ verbose });
1817
+ await jobCheckpoint();
1818
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1819
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
1820
+
1821
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
1822
+ const planner = plannerAgentArgs({
1823
+ prompt,
1824
+ cwd,
1825
+ researchPath: runContext.researchPath,
1826
+ taskPath: runContext.taskPath,
1827
+ researchOutput: researchContent,
1828
+ });
1829
+ const plannerAgent = new AgentClass(
1830
+ planner.name,
1831
+ planner.instructions,
1832
+ planner.prompt,
1833
+ planner.options,
1834
+ );
1835
+ const plannerResult = await plannerAgent.run({ verbose });
1836
+ await jobCheckpoint();
1837
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
1838
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
1839
+
1840
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1841
+ const worktree = createWorktreeFn({ cwd, slug: runContext.slug, base });
1842
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1843
+
1844
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
1845
+ fs.writeFileSync(
1846
+ runContext.statusPath,
1847
+ `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${workerId}\`\n`,
1848
+ );
1849
+
1850
+ const testAccepted = await runTestLoop({
1851
+ prompt,
1852
+ worktreePath: worktree.worktreePath,
1853
+ branch: worktree.branch,
1854
+ taskPath: runContext.taskPath,
1855
+ statusPath: runContext.statusPath,
1856
+ maxRounds,
1857
+ AgentClass,
1858
+ verbose,
1859
+ jobPatch,
1860
+ jobCheckpoint,
1861
+ });
1862
+
1863
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
1864
+ .filter(Boolean)
1865
+ .join('\n');
1866
+
1867
+ const codeAccepted = await runCodeLoop({
1868
+ prompt,
1869
+ worktreePath: worktree.worktreePath,
1870
+ branch: worktree.branch,
1871
+ taskPath: runContext.taskPath,
1872
+ statusPath: runContext.statusPath,
1873
+ maxRounds,
1874
+ AgentClass,
1875
+ verbose,
1876
+ jobPatch,
1877
+ jobCheckpoint,
1878
+ acceptedVerification,
1879
+ });
1880
+
1881
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
1882
+ const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
1883
+ const worktreeChanges = collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
1884
+ printFilesChanged(worktreeChanges);
1885
+ const commitResult = commitWorktreeFn({
1886
+ worktreePath: worktree.worktreePath,
1887
+ branch: worktree.branch,
1888
+ message,
1889
+ });
1890
+
1891
+ if (commitResult.committed) {
1892
+ fs.appendFileSync(
1893
+ runContext.statusPath,
1894
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
1895
+ );
1896
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
1897
+ } else {
1898
+ fs.appendFileSync(
1899
+ runContext.statusPath,
1900
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
1901
+ );
1902
+ console.log(`commit: no changes on ${commitResult.branch}`);
1903
+ }
1904
+
1905
+ let changedFiles = [];
1906
+ try {
1907
+ changedFiles = recordChangedFilesFn({
1908
+ repoRoot: worktree.repoRoot,
1909
+ base,
1910
+ branch: worktree.branch,
1911
+ execFile: execFileFn,
1912
+ });
1913
+ } catch {
1914
+ // Best-effort: changedFiles is informational only, never masks a successful commit.
1915
+ }
1916
+
1917
+ patchWorkerFn(cwd, parentSlug, workerId, {
1918
+ state: 'done',
1919
+ sha: commitResult.sha,
1920
+ changedFiles,
1921
+ });
1922
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1923
+ state: 'done',
1924
+ exitCode: 0,
1925
+ summary: codeAccepted?.verdict?.summary ?? '',
1926
+ error: null,
1927
+ task: prompt,
1928
+ });
1929
+ } catch (err) {
1930
+ console.error(`Error: ${err.message}`);
1931
+ try {
1932
+ patchWorkerFn(cwd, parentSlug, workerId, { state: 'failed' });
1933
+ } catch {
1934
+ // Best-effort: don't let a fanout-state write failure mask the real error.
1935
+ }
1936
+ if (jobSlug) {
1937
+ try {
1938
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1939
+ state: 'failed',
1940
+ exitCode: 1,
1941
+ summary: '',
1942
+ error: err.message,
1943
+ task: prompt,
1944
+ });
1945
+ } catch {
1946
+ // Best-effort: don't let a job-state write failure mask the real error.
1947
+ }
1948
+ }
1949
+ console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
1950
+ if (parentSlug) console.error(` orch --integrate ${parentSlug}`);
1951
+ process.exit(1);
1952
+ }
1953
+ }
1954
+
1955
+ /**
1956
+ * The `--unit <parent>:<unitId>` driver: skips triage and runs research → planner →
1957
+ * worktree (from the seq tip) → test loop → code loop → commit. On success patches
1958
+ * `seq.json.units[]` to `done` with sha/changedFiles/slug; on failure, `failed` then exit 1.
1959
+ */
1960
+ export async function runUnitPipeline(prompt, options = {}) {
1961
+ const verbose = Boolean(options.verbose);
1962
+ const maxRounds = options.maxRounds ?? 5;
1963
+ const backend = AGENT_BACKENDS[options.agent];
1964
+ if (!backend) {
1965
+ throw new Error(`Unknown agent backend: ${options.agent}`);
1966
+ }
1967
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
1968
+ const cwd = options.cwd ?? process.cwd();
1969
+ const { parentSlug, unitId, base } = options;
1970
+ const exitFn = options.exit ?? ((code) => process.exit(code));
1971
+
1972
+ const createRunContextFn = options.createRunContext ?? createRunContext;
1973
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
1974
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
1975
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
1976
+ const patchUnitFn = options.patchUnit ?? patchUnit;
1977
+ const recordChangedFilesFn = options.recordChangedFiles ?? recordChangedFiles;
1978
+ const execFileFn = options.execFile;
1979
+
1980
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
1981
+ const jobCwd = options.jobCwd ?? cwd;
1982
+ const patchJobFn = options.patchJob ?? patchJob;
1983
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
1984
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
1985
+
1986
+ const jobPatch = (fields) => {
1987
+ if (!jobSlug) return;
1988
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
1989
+ };
1990
+ const jobCheckpoint = async () => {
1991
+ if (!jobSlug) return;
1992
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
1993
+ };
1994
+
1995
+ if (!options.AgentClass) {
1996
+ ensureBinaryOnPath(backend.binary, options.agent);
1997
+ }
1998
+
1999
+ try {
2000
+ const runContext = createRunContextFn(jobSlug ? { cwd, slug: jobSlug } : { cwd });
2001
+
2002
+ jobPatch({ phase: 'research', stage: 'research', round: null });
2003
+ const research = researchAgentArgs({ prompt, cwd, researchPath: runContext.researchPath });
2004
+ const researchAgent = new AgentClass(
2005
+ research.name,
2006
+ research.instructions,
2007
+ research.prompt,
2008
+ research.options,
2009
+ );
2010
+ const researchResult = await researchAgent.run({ verbose });
2011
+ if (!researchResult.ok) throw researchResult.error ?? new Error('research failed');
2012
+ await jobCheckpoint();
2013
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
2014
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
2015
+
2016
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
2017
+ const planner = plannerAgentArgs({
2018
+ prompt,
2019
+ cwd,
2020
+ researchPath: runContext.researchPath,
2021
+ taskPath: runContext.taskPath,
2022
+ researchOutput: researchContent,
2023
+ });
2024
+ const plannerAgent = new AgentClass(
2025
+ planner.name,
2026
+ planner.instructions,
2027
+ planner.prompt,
2028
+ planner.options,
2029
+ );
2030
+ const plannerResult = await plannerAgent.run({ verbose });
2031
+ await jobCheckpoint();
2032
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
2033
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
2034
+
2035
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
2036
+ const worktree = await createWorktreeFn({ cwd, slug: runContext.slug, base });
2037
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
2038
+
2039
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
2040
+ fs.writeFileSync(
2041
+ runContext.statusPath,
2042
+ `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n- Parent: \`${parentSlug}\`\n- Worker: \`${unitId}\`\n`,
2043
+ );
2044
+
2045
+ const testAccepted = await runTestLoop({
2046
+ prompt,
2047
+ worktreePath: worktree.worktreePath,
2048
+ branch: worktree.branch,
2049
+ taskPath: runContext.taskPath,
2050
+ statusPath: runContext.statusPath,
2051
+ maxRounds,
2052
+ AgentClass,
2053
+ verbose,
2054
+ jobPatch,
2055
+ jobCheckpoint,
2056
+ });
2057
+
2058
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent]
2059
+ .filter(Boolean)
2060
+ .join('\n');
2061
+
2062
+ const codeAccepted = await runCodeLoop({
2063
+ prompt,
2064
+ worktreePath: worktree.worktreePath,
2065
+ branch: worktree.branch,
2066
+ taskPath: runContext.taskPath,
2067
+ statusPath: runContext.statusPath,
2068
+ maxRounds,
2069
+ AgentClass,
2070
+ verbose,
2071
+ jobPatch,
2072
+ jobCheckpoint,
2073
+ acceptedVerification,
2074
+ });
2075
+
2076
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
2077
+ const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
2078
+ const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
2079
+ printFilesChanged(worktreeChanges);
2080
+ const commitResult = await commitWorktreeFn({
2081
+ worktreePath: worktree.worktreePath,
2082
+ branch: worktree.branch,
2083
+ message,
2084
+ });
2085
+
2086
+ if (commitResult.committed) {
2087
+ fs.appendFileSync(
2088
+ runContext.statusPath,
2089
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
2090
+ );
2091
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
2092
+ } else if (commitResult.sha) {
2093
+ console.log(`commit: ${String(commitResult.sha).slice(0, 7)} on ${worktree.branch}`);
2094
+ } else {
2095
+ fs.appendFileSync(
2096
+ runContext.statusPath,
2097
+ `\n## Commit\n\n- No changes to commit on \`${worktree.branch}\`.\n`,
2098
+ );
2099
+ console.log(`commit: no changes on ${worktree.branch}`);
2100
+ }
2101
+
2102
+ let changedFiles = [];
2103
+ try {
2104
+ changedFiles = recordChangedFilesFn({
2105
+ repoRoot: worktree.repoRoot ?? cwd,
2106
+ base,
2107
+ branch: worktree.branch,
2108
+ execFile: execFileFn,
2109
+ });
2110
+ } catch {
2111
+ // Best-effort: changedFiles is informational only.
2112
+ }
2113
+
2114
+ patchUnitFn(cwd, parentSlug, unitId, {
2115
+ state: 'done',
2116
+ sha: commitResult.sha,
2117
+ changedFiles,
2118
+ slug: runContext.slug,
2119
+ });
2120
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2121
+ state: 'done',
2122
+ exitCode: 0,
2123
+ summary: codeAccepted?.verdict?.summary ?? '',
2124
+ error: null,
2125
+ task: prompt,
2126
+ });
2127
+ } catch (err) {
2128
+ console.error(`Error: ${err.message}`);
2129
+ try {
2130
+ patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
2131
+ } catch {
2132
+ // Best-effort.
2133
+ }
2134
+ if (jobSlug) {
2135
+ try {
2136
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2137
+ state: 'failed',
2138
+ exitCode: 1,
2139
+ summary: '',
2140
+ error: err.message,
2141
+ task: prompt,
2142
+ });
2143
+ } catch {
2144
+ // Best-effort.
2145
+ }
2146
+ }
2147
+ console.error(`next: orch resume ${jobSlug ?? '<slug>'}`);
2148
+ if (parentSlug) console.error(` orch --seq-continue ${parentSlug}`);
2149
+ exitFn(1);
2150
+ }
2151
+ }
2152
+
2153
+ /**
2154
+ * Merge one unit branch into `orch/<parentSlug>`, repair conflicts once via
2155
+ * integrator, runner-first verify, then advance `seq.tip`. Merge/verify failure
2156
+ * marks the unit `failed` and exits non-zero.
2157
+ */
2158
+ export async function mergeOneUnit(options = {}) {
2159
+ const verbose = Boolean(options.verbose);
2160
+ const maxRounds = options.maxRounds ?? 5;
2161
+ const backend = AGENT_BACKENDS[options.agent];
2162
+ if (!backend) {
2163
+ throw new Error(`Unknown agent backend: ${options.agent}`);
2164
+ }
2165
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
2166
+ const cwd = options.cwd ?? process.cwd();
2167
+ const { parentSlug, unitId, unitBranch } = options;
2168
+ const exitFn = options.exit ?? ((code) => process.exit(code));
2169
+ const logFn = options.log ?? ((line) => console.log(line));
2170
+
2171
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
2172
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
2173
+ const readSeqFn = options.readSeq ?? readSeq;
2174
+ const patchUnitFn = options.patchUnit ?? patchUnit;
2175
+ const patchTipFn = options.patchTip ?? patchTip;
2176
+ const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
2177
+ const abortMergeFn = options.abortMerge ?? abortMerge;
2178
+ const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
2179
+ const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
2180
+ const execFileFn = options.execFile ?? defaultExecFile;
2181
+
2182
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
2183
+ const jobCwd = options.jobCwd ?? cwd;
2184
+ const patchJobFn = options.patchJob ?? patchJob;
2185
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
2186
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
2187
+
2188
+ const jobPatch = (fields) => {
2189
+ if (!jobSlug) return;
2190
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
2191
+ };
2192
+ const jobCheckpoint = async () => {
2193
+ if (!jobSlug) return;
2194
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
2195
+ };
2196
+
2197
+ const failUnit = async (message) => {
2198
+ if (message) console.error(`Error: ${message}`);
2199
+ try {
2200
+ patchUnitFn(cwd, parentSlug, unitId, { state: 'failed' });
2201
+ } catch {
2202
+ // Best-effort.
2203
+ }
2204
+ exitFn(1);
2205
+ };
2206
+
2207
+ try {
2208
+ const seq = readSeqFn(cwd, parentSlug);
2209
+ if (!seq) {
2210
+ await failUnit(`unknown parent ${parentSlug} (no seq.json found)`);
2211
+ return;
2212
+ }
2213
+
2214
+ jobPatch({ phase: 'merge', stage: 'merge', round: null });
2215
+
2216
+ const reuseWorktreePath = `${cwd}-${parentSlug}`;
2217
+ const expectedBranch = `orch/${parentSlug}`;
2218
+ let worktree = null;
2219
+
2220
+ if (fs.existsSync(reuseWorktreePath)) {
2221
+ try {
2222
+ const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
2223
+ if (currentBranch === expectedBranch) {
2224
+ worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
2225
+ }
2226
+ } catch {
2227
+ // Fall through to create.
2228
+ }
2229
+ }
2230
+
2231
+ if (!worktree) {
2232
+ worktree = await createWorktreeFn({
2233
+ cwd,
2234
+ slug: parentSlug,
2235
+ base: seq.tip || seq.base,
2236
+ });
2237
+ }
2238
+
2239
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
2240
+
2241
+ const mergeResult = await mergeBranchesFn({
2242
+ cwd: worktree.worktreePath,
2243
+ candidates: [unitBranch],
2244
+ merged: [],
2245
+ overlappingFiles: [],
2246
+ execFile: execFileFn,
2247
+ });
2248
+
2249
+ let conflicts = [];
2250
+ let mergedOk = false;
2251
+ let mergeOutput = '';
2252
+ if (Array.isArray(mergeResult)) {
2253
+ conflicts = mergeResult.filter((r) => r.status === 'conflict').map((r) => r.branch);
2254
+ mergedOk = mergeResult.some((r) => r.status === 'merged');
2255
+ mergeOutput = mergeResult.find((r) => r.status === 'conflict')?.output ?? '';
2256
+ } else {
2257
+ conflicts = mergeResult?.conflicts ?? [];
2258
+ mergedOk = (mergeResult?.merged ?? []).includes(unitBranch) || (mergeResult?.merged ?? []).length > 0;
2259
+ }
2260
+
2261
+ if (conflicts.length > 0) {
2262
+ jobPatch({ phase: 'merge', stage: 'integrator', round: null });
2263
+ const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
2264
+ const integratorArgs = integratorAgentArgs({
2265
+ prompt: `Resolve the merge conflict from combining \`${unitBranch}\` into the seq integration branch for "${seq.task}".`,
2266
+ cwd: worktree.worktreePath,
2267
+ conflictedFiles: conflicted,
2268
+ mergeOutput,
2269
+ involvedWorkers: [{ id: unitId, title: unitId, subtask: unitId, area: '' }],
2270
+ });
2271
+ const integratorAgent = new AgentClass(
2272
+ 'integrator',
2273
+ integratorArgs.instructions,
2274
+ integratorArgs.prompt,
2275
+ integratorArgs.options,
2276
+ );
2277
+
2278
+ let integratorOk = false;
2279
+ try {
2280
+ const integratorOut = await integratorAgent.run({ verbose });
2281
+ await jobCheckpoint();
2282
+ integratorOk = Boolean(integratorOut.ok);
2283
+ } catch {
2284
+ integratorOk = false;
2285
+ }
2286
+
2287
+ const stillConflicted = integratorOk
2288
+ ? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
2289
+ : true;
2290
+
2291
+ if (!stillConflicted) {
2292
+ try {
2293
+ execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
2294
+ } catch {
2295
+ // May already be committed by integrator.
2296
+ }
2297
+ mergedOk = true;
2298
+ } else {
2299
+ try {
2300
+ abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
2301
+ } catch {
2302
+ // Best-effort.
2303
+ }
2304
+ await failUnit(`merge conflict for ${unitId} could not be repaired`);
2305
+ return;
2306
+ }
2307
+ }
2308
+
2309
+ if (!mergedOk && conflicts.length === 0) {
2310
+ await failUnit(`failed to merge ${unitBranch}`);
2311
+ return;
2312
+ }
2313
+
2314
+ const artifactDir = path.join(jobCwd, '.orch', parentSlug);
2315
+ await runCodeLoop({
2316
+ prompt: seq.task,
2317
+ worktreePath: worktree.worktreePath,
2318
+ branch: worktree.branch,
2319
+ taskPath: path.join(artifactDir, 'task.md'),
2320
+ statusPath: path.join(artifactDir, 'status.md'),
2321
+ maxRounds,
2322
+ AgentClass,
2323
+ verbose,
2324
+ jobPatch,
2325
+ jobCheckpoint,
2326
+ acceptedVerification: '',
2327
+ runnerFirst: true,
2328
+ loopTitle: 'Verify loop',
2329
+ });
2330
+
2331
+ const commitResult = await commitWorktreeFn({
2332
+ worktreePath: worktree.worktreePath,
2333
+ branch: worktree.branch,
2334
+ message: `orch: merge ${unitId} into ${parentSlug}`,
2335
+ });
2336
+
2337
+ let tipSha = commitResult?.sha;
2338
+ if (!tipSha) {
2339
+ tipSha = execFileFn('git', ['-C', worktree.worktreePath, 'rev-parse', 'HEAD']).trim();
2340
+ }
2341
+ patchTipFn(cwd, parentSlug, tipSha);
2342
+ logFn(`merged ${unitId} → tip ${String(tipSha).slice(0, 7)}`);
2343
+ } catch (err) {
2344
+ await failUnit(err.message);
2345
+ }
2346
+ }
2347
+
2348
+ /**
2349
+ * The `--integrate <parent>` driver: reuses (or creates) the integration worktree keyed
2350
+ * by the parent slug, merges `fanout.integration.candidates` in order (repairing conflicts
2351
+ * via the `integrator` agent, one conflict at a time), then runs a runner-first verify
2352
+ * loop and commits on green. Never invokes triage/research/planner/test-writer/test-critic.
2353
+ * Appends every step to `.orch/<job-slug>/integration.md` as it happens.
2354
+ */
2355
+ export async function runIntegratePipeline(options = {}) {
2356
+ const verbose = Boolean(options.verbose);
2357
+ const maxRounds = options.maxRounds ?? 5;
2358
+ const backend = AGENT_BACKENDS[options.agent];
2359
+ if (!backend) {
2360
+ throw new Error(`Unknown agent backend: ${options.agent}`);
2361
+ }
2362
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
2363
+ const cwd = options.cwd ?? process.cwd();
2364
+ const { parentSlug } = options;
2365
+
2366
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
2367
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
2368
+ const readFanoutFn = options.readFanout ?? readFanout;
2369
+ const patchIntegrationFn = options.patchIntegration ?? patchIntegration;
2370
+ const mergeBranchesFn = options.mergeBranches ?? mergeBranches;
2371
+ const abortMergeFn = options.abortMerge ?? abortMerge;
2372
+ const conflictedFilesFn = options.conflictedFiles ?? conflictedFiles;
2373
+ const hasConflictMarkersFn = options.hasConflictMarkers ?? hasConflictMarkers;
2374
+ const execFileFn = options.execFile ?? defaultExecFile;
2375
+
2376
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
2377
+ const jobCwd = options.jobCwd ?? cwd;
2378
+ const patchJobFn = options.patchJob ?? patchJob;
2379
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
2380
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
2381
+
2382
+ const jobPatch = (fields) => {
2383
+ if (!jobSlug) return;
2384
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
2385
+ };
2386
+ const jobCheckpoint = async () => {
2387
+ if (!jobSlug) return;
2388
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
2389
+ };
2390
+
2391
+ if (!options.AgentClass) {
2392
+ ensureBinaryOnPath(backend.binary, options.agent);
2393
+ }
2394
+
2395
+ const fanout = readFanoutFn(cwd, parentSlug);
2396
+ if (!fanout) {
2397
+ console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
2398
+ process.exit(1);
2399
+ return;
2400
+ }
2401
+
2402
+ const integrationSlug = jobSlug ?? parentSlug;
2403
+ const integrationDir = path.join(jobCwd, '.orch', integrationSlug);
2404
+ const integrationMdPath = path.join(integrationDir, 'integration.md');
2405
+ const logIntegration = (line) => {
2406
+ fs.mkdirSync(integrationDir, { recursive: true });
2407
+ fs.appendFileSync(integrationMdPath, `${line}\n`);
2408
+ };
2409
+
2410
+ let merged = [...fanout.integration.merged];
2411
+ let skipped = [...(fanout.integration.skipped ?? [])];
2412
+
2413
+ try {
2414
+ fs.mkdirSync(integrationDir, { recursive: true });
2415
+ fs.appendFileSync(integrationMdPath, `# Integration: ${parentSlug}\n\n`);
2416
+
2417
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
2418
+
2419
+ const reuseWorktreePath = `${cwd}-${parentSlug}`;
2420
+ const expectedBranch = `orch/${parentSlug}`;
2421
+ let worktree = null;
2422
+
2423
+ if (fs.existsSync(reuseWorktreePath)) {
2424
+ const currentBranch = execFileFn('git', ['-C', reuseWorktreePath, 'rev-parse', '--abbrev-ref', 'HEAD']).trim();
2425
+ if (currentBranch === expectedBranch) {
2426
+ worktree = { repoRoot: cwd, worktreePath: reuseWorktreePath, branch: expectedBranch };
2427
+ }
2428
+ }
2429
+
2430
+ const reused = Boolean(worktree);
2431
+ if (!worktree) {
2432
+ worktree = createWorktreeFn({ cwd, slug: parentSlug, base: fanout.base });
2433
+ }
2434
+
2435
+ logIntegration(
2436
+ reused
2437
+ ? `- Reused existing worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`
2438
+ : `- Created worktree at \`${worktree.worktreePath}\` on \`${worktree.branch}\`.`,
2439
+ );
2440
+
2441
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
1129
2442
  patchIntegrationFn(cwd, parentSlug, (current) => ({
1130
2443
  worktree: current.worktree ?? worktree.worktreePath,
1131
2444
  branch: current.branch ?? worktree.branch,
1132
2445
  }));
1133
2446
 
1134
- let remaining = fanout.integration.candidates.filter(
1135
- (branch) => !merged.includes(branch) && !skipped.includes(branch),
2447
+ let remaining = fanout.integration.candidates.filter(
2448
+ (branch) => !merged.includes(branch) && !skipped.includes(branch),
2449
+ );
2450
+
2451
+ while (remaining.length > 0) {
2452
+ const results = mergeBranchesFn({
2453
+ cwd: worktree.worktreePath,
2454
+ candidates: remaining,
2455
+ merged,
2456
+ overlappingFiles: fanout.integration.overlappingFiles,
2457
+ execFile: execFileFn,
2458
+ });
2459
+
2460
+ for (const result of results) {
2461
+ if (result.status === 'skipped') continue;
2462
+
2463
+ if (result.status === 'merged') {
2464
+ merged.push(result.branch);
2465
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
2466
+ merged: [...current.merged, result.branch],
2467
+ }));
2468
+ logIntegration(`- Merged \`${result.branch}\` cleanly.`);
2469
+ continue;
2470
+ }
2471
+
2472
+ // status === 'conflict'
2473
+ logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
2474
+ patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
2475
+
2476
+ const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
2477
+ const involvedWorkers = fanout.workers
2478
+ .filter((worker) => worker.branch === result.branch)
2479
+ .map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
2480
+
2481
+ jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
2482
+ const integratorArgs = integratorAgentArgs({
2483
+ prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
2484
+ cwd: worktree.worktreePath,
2485
+ conflictedFiles: conflicted,
2486
+ mergeOutput: result.output,
2487
+ involvedWorkers,
2488
+ });
2489
+ const integratorAgent = new AgentClass(
2490
+ 'integrator',
2491
+ integratorArgs.instructions,
2492
+ integratorArgs.prompt,
2493
+ integratorArgs.options,
2494
+ );
2495
+
2496
+ let integratorOk = false;
2497
+ try {
2498
+ const integratorOut = await integratorAgent.run({ verbose });
2499
+ await jobCheckpoint();
2500
+ const { content: integratorContent, summary: integratorSummary } = splitStageSummary(integratorOut.result);
2501
+ printStageSummary('integrator', resolveStageSummary('integrator', integratorSummary, integratorContent));
2502
+ integratorOk = Boolean(integratorOut.ok);
2503
+ } catch (err) {
2504
+ logIntegration(`- Integrator agent errored: ${err.message}`);
2505
+ integratorOk = false;
2506
+ }
2507
+
2508
+ const stillConflicted = integratorOk
2509
+ ? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
2510
+ : true;
2511
+
2512
+ if (!stillConflicted) {
2513
+ execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
2514
+ merged.push(result.branch);
2515
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
2516
+ merged: [...current.merged, result.branch],
2517
+ }));
2518
+ logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
2519
+ } else {
2520
+ abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
2521
+ skipped.push(result.branch);
2522
+ patchIntegrationFn(cwd, parentSlug, (current) => ({
2523
+ skipped: [...(current.skipped ?? []), result.branch],
2524
+ }));
2525
+ logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
2526
+ }
2527
+ }
2528
+
2529
+ remaining = remaining.slice(results.length);
2530
+ }
2531
+
2532
+ // --- runner-first verify loop: test-runner first, code-writer only on failure ---
2533
+ const codeAccepted = await runCodeLoop({
2534
+ prompt: fanout.task,
2535
+ worktreePath: worktree.worktreePath,
2536
+ branch: worktree.branch,
2537
+ taskPath: path.join(integrationDir, 'task.md'),
2538
+ statusPath: integrationMdPath,
2539
+ maxRounds,
2540
+ AgentClass,
2541
+ verbose,
2542
+ jobPatch,
2543
+ jobCheckpoint,
2544
+ acceptedVerification: '',
2545
+ runnerFirst: true,
2546
+ loopTitle: 'Verify loop',
2547
+ });
2548
+
2549
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
2550
+ const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
2551
+ const commitResult = commitWorktreeFn({
2552
+ worktreePath: worktree.worktreePath,
2553
+ branch: `orch/${parentSlug}`,
2554
+ message,
2555
+ });
2556
+
2557
+ logIntegration(
2558
+ commitResult.committed
2559
+ ? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
2560
+ : `- No changes to commit on \`${commitResult.branch}\`.`,
2561
+ );
2562
+
2563
+ patchIntegrationFn(cwd, parentSlug, {
2564
+ state: 'done',
2565
+ sha: commitResult.sha,
2566
+ merged,
2567
+ skipped,
2568
+ });
2569
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2570
+ state: 'done',
2571
+ exitCode: 0,
2572
+ summary: codeAccepted?.verdict?.summary ?? '',
2573
+ error: null,
2574
+ task: fanout.task,
2575
+ });
2576
+ } catch (err) {
2577
+ console.error(`Error: ${err.message}`);
2578
+ try {
2579
+ logIntegration(`- Error: ${err.message}`);
2580
+ } catch {
2581
+ // Best-effort: don't let a log write failure mask the real error.
2582
+ }
2583
+ try {
2584
+ patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
2585
+ } catch {
2586
+ // Best-effort: don't let a fanout-state write failure mask the real error.
2587
+ }
2588
+ if (jobSlug) {
2589
+ try {
2590
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
2591
+ state: 'failed',
2592
+ exitCode: 1,
2593
+ summary: '',
2594
+ error: err.message,
2595
+ task: fanout?.task,
2596
+ });
2597
+ } catch {
2598
+ // Best-effort: don't let a job-state write failure mask the real error.
2599
+ }
2600
+ }
2601
+ process.exit(1);
2602
+ }
2603
+ }
2604
+
2605
+ function sleep(ms) {
2606
+ return new Promise((resolve) => setTimeout(resolve, ms));
2607
+ }
2608
+
2609
+ /** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
2610
+ * cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
2611
+ class FanoutInterrupted extends Error {}
2612
+
2613
+ /**
2614
+ * SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
2615
+ * integration session's own pid via its `run.json`, filters through `isPidAlive`, and
2616
+ * SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
2617
+ * `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
2618
+ * both this and the normal in-process agent-CLI reaping.
2619
+ */
2620
+ export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
2621
+ const fanout = readFanout(cwd, parentSlug);
2622
+ if (!fanout) return;
2623
+
2624
+ const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
2625
+ if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
2626
+
2627
+ for (const slug of slugs) {
2628
+ const record = readJob(cwd, slug);
2629
+ if (!record) continue;
2630
+ if (isPidAliveFn(record.pid)) {
2631
+ kill(record.pid, 'SIGTERM');
2632
+ }
2633
+ }
2634
+ }
2635
+
2636
+ /**
2637
+ * SIGINT/SIGHUP/SIGTERM cascade for seq: reads `seq.json` unit slugs and SIGTERMs
2638
+ * live non-terminal children. Never touches worktrees.
2639
+ */
2640
+ export function cascadeStopSeqChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
2641
+ const seq = readSeq(cwd, parentSlug);
2642
+ if (!seq) return;
2643
+
2644
+ const slugs = (seq.units || []).filter((unit) => unit.slug).map((unit) => unit.slug);
2645
+ for (const slug of slugs) {
2646
+ const record = readJob(cwd, slug);
2647
+ if (!record) continue;
2648
+ if (TERMINAL_JOB_STATES.includes(record.state)) continue;
2649
+ if (isPidAliveFn(record.pid)) {
2650
+ kill(record.pid, 'SIGTERM');
2651
+ }
2652
+ }
2653
+ }
2654
+
2655
+ /**
2656
+ * CLI / management cascade stop: SIGTERM the parent pid if alive, then every
2657
+ * live child pid (via fan-out and/or seq helpers). The coordinator signal
2658
+ * handler keeps calling the child-only helpers so it does not re-signal itself.
2659
+ */
2660
+ export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
2661
+ const parent = readJob(cwd, parentSlug);
2662
+ if (parent && isPidAliveFn(parent.pid)) {
2663
+ kill(parent.pid, 'SIGTERM');
2664
+ }
2665
+ cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
2666
+ cascadeStopSeqChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
2667
+ }
2668
+
2669
+ /**
2670
+ * The `--seq` coordinator: triage → seq-decomposer (no boundaries) → schedule
2671
+ * units concurrency 1 → merge+verify after each → hybrid adjust. Decline falls
2672
+ * through to the single-worktree pipeline with no seq.json.
2673
+ */
2674
+ export async function runSeqPipeline(prompt, options = {}) {
2675
+ const verbose = Boolean(options.verbose);
2676
+ const maxRounds = options.maxRounds ?? 5;
2677
+ const maxUnits = options.maxUnits ?? 8;
2678
+ const backend = AGENT_BACKENDS[options.agent];
2679
+ if (!backend) {
2680
+ throw new Error(`Unknown agent backend: ${options.agent}`);
2681
+ }
2682
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
2683
+ const invocationCwd = options.cwd ?? process.cwd();
2684
+
2685
+ const createRunContextFn = options.createRunContext ?? createRunContext;
2686
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
2687
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
2688
+ const collectWorktreeChangesFn = options.collectWorktreeChanges ?? collectWorktreeChanges;
2689
+ const spawnFn = options.spawn ?? spawn;
2690
+ const execFileFn = options.execFile ?? defaultExecFile;
2691
+ const allocateJobFn = options.allocateJob ?? allocateJob;
2692
+ const reconcileJobFn = options.reconcileJob ?? reconcileJob;
2693
+ const readSeqFn = options.readSeq ?? readSeq;
2694
+ const writeSeqFn = options.writeSeq ?? writeSeq;
2695
+ const patchUnitFn = options.patchUnit ?? patchUnit;
2696
+ const appendAdjustmentFn = options.appendAdjustment ?? appendAdjustment;
2697
+ const mergeOneUnitFn = options.mergeOneUnit ?? mergeOneUnit;
2698
+ const exitFn = options.exit ?? ((code) => process.exit(code));
2699
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
2700
+
2701
+ const jobSlug = options.jobSlug ?? process.env.ORCH_JOB_SLUG;
2702
+ const jobCwd = options.jobCwd ?? invocationCwd;
2703
+ const patchJobFn = options.patchJob ?? patchJob;
2704
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
2705
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
2706
+
2707
+ const jobPatch = (fields) => {
2708
+ if (!jobSlug) return;
2709
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
2710
+ };
2711
+ const jobCheckpoint = async () => {
2712
+ if (!jobSlug) return;
2713
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
2714
+ };
2715
+
2716
+ if (!options.AgentClass) {
2717
+ ensureBinaryOnPath(backend.binary, options.agent);
2718
+ }
2719
+
2720
+ console.log(`cwd: ${invocationCwd}`);
2721
+ console.log(`agent: ${options.agent}`);
2722
+ console.log();
2723
+
2724
+ let interrupted = false;
2725
+ const onSignal = (signal) => {
2726
+ interrupted = true;
2727
+ try {
2728
+ cascadeStopSeqChildren(invocationCwd, jobSlug);
2729
+ } catch {
2730
+ // Best-effort.
2731
+ }
2732
+ if (jobSlug) {
2733
+ try {
2734
+ const exitCode = exitCodeForSignal(signal);
2735
+ const finishedAt = new Date().toISOString();
2736
+ patchJobFn(jobCwd, jobSlug, (current) => ({
2737
+ state: 'stopped',
2738
+ exitCode,
2739
+ finishedAt,
2740
+ lastOutcome: buildLastOutcome({
2741
+ state: 'stopped',
2742
+ phase: current.phase,
2743
+ stage: current.stage,
2744
+ round: current.round,
2745
+ exitCode,
2746
+ finishedAt,
2747
+ task: prompt,
2748
+ summary: '',
2749
+ error: null,
2750
+ }),
2751
+ }));
2752
+ } catch {
2753
+ // Best-effort.
2754
+ }
2755
+ }
2756
+ exitFn(exitCodeForSignal(signal));
2757
+ };
2758
+ process.on('SIGINT', () => onSignal('SIGINT'));
2759
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
2760
+ process.on('SIGHUP', () => onSignal('SIGHUP'));
2761
+
2762
+ const runDeclinePipeline = async () => {
2763
+ const runContext = createRunContextFn(jobSlug ? { cwd: invocationCwd, slug: jobSlug } : { cwd: invocationCwd });
2764
+
2765
+ jobPatch({ phase: 'research', stage: 'research', round: null });
2766
+ const research = researchAgentArgs({ prompt, cwd: invocationCwd, researchPath: runContext.researchPath });
2767
+ const researchAgent = new AgentClass(research.name, research.instructions, research.prompt, research.options);
2768
+ const researchResult = await researchAgent.run({ verbose });
2769
+ await jobCheckpoint();
2770
+ const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
2771
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
2772
+
2773
+ jobPatch({ phase: 'plan', stage: 'planner', round: null });
2774
+ const planner = plannerAgentArgs({
2775
+ prompt, cwd: invocationCwd, researchPath: runContext.researchPath, taskPath: runContext.taskPath, researchOutput: researchContent,
2776
+ });
2777
+ const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
2778
+ const plannerResult = await plannerAgent.run({ verbose });
2779
+ await jobCheckpoint();
2780
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
2781
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
2782
+
2783
+ jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
2784
+ const worktree = await createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
2785
+ jobPatch({ branch: worktree.branch, worktree: worktree.worktreePath });
2786
+
2787
+ fs.mkdirSync(path.dirname(runContext.statusPath), { recursive: true });
2788
+ fs.writeFileSync(
2789
+ runContext.statusPath,
2790
+ `# Status\n\n- Slug: \`${runContext.slug}\`\n- Branch: \`${worktree.branch}\`\n- Worktree: \`${worktree.worktreePath}\`\n`,
1136
2791
  );
1137
2792
 
1138
- while (remaining.length > 0) {
1139
- const results = mergeBranchesFn({
1140
- cwd: worktree.worktreePath,
1141
- candidates: remaining,
1142
- merged,
1143
- overlappingFiles: fanout.integration.overlappingFiles,
1144
- execFile: execFileFn,
1145
- });
2793
+ const testAccepted = await runTestLoop({
2794
+ prompt,
2795
+ worktreePath: worktree.worktreePath,
2796
+ branch: worktree.branch,
2797
+ taskPath: runContext.taskPath,
2798
+ statusPath: runContext.statusPath,
2799
+ maxRounds,
2800
+ AgentClass,
2801
+ verbose,
2802
+ jobPatch,
2803
+ jobCheckpoint,
2804
+ });
1146
2805
 
1147
- for (const result of results) {
1148
- if (result.status === 'skipped') continue;
2806
+ const acceptedVerification = [testAccepted.verdict.summary, testAccepted.writerContent].filter(Boolean).join('\n');
1149
2807
 
1150
- if (result.status === 'merged') {
1151
- merged.push(result.branch);
1152
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1153
- merged: [...current.merged, result.branch],
1154
- }));
1155
- logIntegration(`- Merged \`${result.branch}\` cleanly.`);
1156
- continue;
1157
- }
2808
+ await runCodeLoop({
2809
+ prompt,
2810
+ worktreePath: worktree.worktreePath,
2811
+ branch: worktree.branch,
2812
+ taskPath: runContext.taskPath,
2813
+ statusPath: runContext.statusPath,
2814
+ maxRounds,
2815
+ AgentClass,
2816
+ verbose,
2817
+ jobPatch,
2818
+ jobCheckpoint,
2819
+ acceptedVerification,
2820
+ });
1158
2821
 
1159
- // status === 'conflict'
1160
- logIntegration(`- Conflict merging \`${result.branch}\`; entering repair.`);
1161
- patchIntegrationFn(cwd, parentSlug, { state: 'repairing' });
2822
+ jobPatch({ phase: 'commit', stage: 'commit', round: null });
2823
+ const message = `orch: ${runContext.slug} ${prompt.split('\n')[0]}`;
2824
+ const worktreeChanges = await collectWorktreeChangesFn({ worktreePath: worktree.worktreePath });
2825
+ printFilesChanged(worktreeChanges);
2826
+ const commitResult = await commitWorktreeFn({ worktreePath: worktree.worktreePath, branch: worktree.branch, message });
1162
2827
 
1163
- const conflicted = conflictedFilesFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1164
- const involvedWorkers = fanout.workers
1165
- .filter((worker) => worker.branch === result.branch)
1166
- .map(({ id, title, subtask, area }) => ({ id, title, subtask, area }));
2828
+ if (commitResult.committed) {
2829
+ fs.appendFileSync(
2830
+ runContext.statusPath,
2831
+ `\n## Commit\n\n- SHA: \`${commitResult.sha}\`\n- Branch: \`${commitResult.branch}\`\n`,
2832
+ );
2833
+ console.log(`commit: ${commitResult.sha.slice(0, 7)} on ${commitResult.branch}`);
2834
+ console.log(`merge: git merge ${commitResult.branch}`);
2835
+ } else {
2836
+ fs.appendFileSync(
2837
+ runContext.statusPath,
2838
+ `\n## Commit\n\n- No changes to commit on \`${commitResult.branch}\`.\n`,
2839
+ );
2840
+ console.log(`commit: no changes on ${commitResult.branch}`);
2841
+ }
1167
2842
 
1168
- jobPatch({ phase: 'integrate', stage: 'integrator', round: null });
1169
- const integratorArgs = integratorAgentArgs({
1170
- prompt: `Resolve the merge conflict from combining \`${result.branch}\` into the integration branch for "${fanout.task}".`,
1171
- cwd: worktree.worktreePath,
1172
- conflictedFiles: conflicted,
1173
- mergeOutput: result.output,
1174
- involvedWorkers,
2843
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
2844
+ exitFn(0);
2845
+ };
2846
+
2847
+ const runAdjust = async (seqDoc) => {
2848
+ const doneUnits = seqDoc.units.filter((u) => u.state === 'done');
2849
+ const pendingUnits = seqDoc.units.filter((u) => u.state === 'pending');
2850
+ if (pendingUnits.length === 0) return seqDoc;
2851
+
2852
+ let feedback;
2853
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
2854
+ jobPatch({ phase: 'adjust', stage: 'adjust', round: attempt });
2855
+ const adjust = adjustAgentArgs({
2856
+ originalTask: prompt,
2857
+ doneUnits,
2858
+ pendingUnits,
2859
+ tip: seqDoc.tip,
2860
+ cwd: invocationCwd,
2861
+ maxUnits,
2862
+ feedback,
2863
+ });
2864
+ const adjustAgent = new AgentClass(adjust.name, adjust.instructions, adjust.prompt, adjust.options);
2865
+ const adjustResult = await adjustAgent.run({ verbose });
2866
+ await jobCheckpoint();
2867
+ const { content: adjustContent, summary: adjustSummary } = splitStageSummary(adjustResult.result);
2868
+ printStageSummary('adjust', resolveStageSummary('adjust', adjustSummary, adjustContent));
2869
+
2870
+ const parsed = parseDecomposition(adjustContent);
2871
+ if (!parsed) {
2872
+ feedback = ['adjust output was not valid JSON'];
2873
+ continue;
2874
+ }
2875
+ const violations = validateAdjustResult(parsed, { units: seqDoc.units, maxUnits });
2876
+ if (violations.length === 0) {
2877
+ const applied = applyAdjustResult(seqDoc, parsed);
2878
+ writeSeqFn(invocationCwd, jobSlug, applied);
2879
+ const rewriteIds = (parsed.rewrites || []).map((r) => r.id);
2880
+ const dropIds = parsed.drops || [];
2881
+ const summaryParts = [];
2882
+ if (rewriteIds.length) summaryParts.push(`rewrote ${rewriteIds.join(', ')}`);
2883
+ if (dropIds.length) summaryParts.push(`dropped ${dropIds.join(', ')}`);
2884
+ const summary = summaryParts.join('; ') || 'no changes';
2885
+ appendAdjustmentFn(invocationCwd, jobSlug, {
2886
+ afterUnitId: doneUnits[doneUnits.length - 1]?.id ?? null,
2887
+ tip: applied.tip,
2888
+ summary,
1175
2889
  });
1176
- const integratorAgent = new AgentClass(
1177
- 'integrator',
1178
- integratorArgs.instructions,
1179
- integratorArgs.prompt,
1180
- integratorArgs.options,
1181
- );
2890
+ console.log(`adjust: ${summary}`);
2891
+ return applied;
2892
+ }
2893
+ feedback = violations;
2894
+ }
2895
+ console.log('adjust: validation failed after repairs — keeping previous pending list');
2896
+ return seqDoc;
2897
+ };
1182
2898
 
1183
- let integratorOk = false;
1184
- try {
1185
- const integratorOut = await integratorAgent.run({ verbose });
1186
- await jobCheckpoint();
1187
- const { summary: integratorSummary } = splitStageSummary(integratorOut.result);
1188
- printStageSummary('integrator', integratorSummary);
1189
- integratorOk = Boolean(integratorOut.ok);
1190
- } catch (err) {
1191
- logIntegration(`- Integrator agent errored: ${err.message}`);
1192
- integratorOk = false;
1193
- }
2899
+ const spawnUnitChild = async (unit, tip) => {
2900
+ const envelope = buildUnitEnvelope({
2901
+ id: unit.id,
2902
+ title: unit.title,
2903
+ subtask: unit.subtask,
2904
+ originalTask: prompt,
2905
+ });
2906
+ const unitPrompt = `${unit.subtask}\n\n${envelope}`;
1194
2907
 
1195
- const stillConflicted = integratorOk
1196
- ? hasConflictMarkersFn({ cwd: worktree.worktreePath, execFile: execFileFn })
1197
- : true;
2908
+ const allocated = await allocateJobFn({
2909
+ cwd: invocationCwd,
2910
+ prompt: unitPrompt,
2911
+ agent: options.agent,
2912
+ maxRounds,
2913
+ state: 'starting',
2914
+ parent: jobSlug,
2915
+ role: 'worker',
2916
+ workerId: unit.id,
2917
+ });
2918
+ const unitSlug = allocated.slug;
2919
+ patchUnitFn(invocationCwd, jobSlug, unit.id, {
2920
+ slug: unitSlug,
2921
+ state: 'running',
2922
+ });
1198
2923
 
1199
- if (!stillConflicted) {
1200
- execFileFn('git', ['-C', worktree.worktreePath, 'commit']);
1201
- merged.push(result.branch);
1202
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1203
- merged: [...current.merged, result.branch],
1204
- }));
1205
- logIntegration(`- Integrator resolved conflicts in \`${result.branch}\`; merge completed.`);
1206
- } else {
1207
- abortMergeFn({ cwd: worktree.worktreePath, execFile: execFileFn });
1208
- skipped.push(result.branch);
1209
- patchIntegrationFn(cwd, parentSlug, (current) => ({
1210
- skipped: [...(current.skipped ?? []), result.branch],
1211
- }));
1212
- logIntegration(`- Conflicts in \`${result.branch}\` remained unresolved; aborted merge and skipped.`);
2924
+ const { logPath } = jobPaths(invocationCwd, unitSlug);
2925
+ const logFd = fs.openSync(logPath, 'a');
2926
+ const childArgs = [
2927
+ __filename, unitPrompt,
2928
+ '--agent', options.agent,
2929
+ '--max-rounds', String(maxRounds),
2930
+ '--unit', `${jobSlug}:${unit.id}`,
2931
+ ];
2932
+ const child = spawnFn(process.execPath, childArgs, {
2933
+ cwd: invocationCwd,
2934
+ env: {
2935
+ ...process.env,
2936
+ ORCH_JOB_SLUG: unitSlug,
2937
+ ORCH_DETACHED: '1',
2938
+ ORCH_SEQ_DEPTH: '1',
2939
+ ORCH_FANOUT_DEPTH: '1',
2940
+ },
2941
+ detached: true,
2942
+ stdio: ['ignore', logFd, logFd],
2943
+ });
2944
+ child.unref();
2945
+ patchJobFn(invocationCwd, unitSlug, { pid: child.pid, state: 'running' });
2946
+ console.log(`[${unit.id} ${unitSlug}] running (base ${String(tip).slice(0, 7)})`);
2947
+ return unitSlug;
2948
+ };
2949
+
2950
+ const waitForUnit = async (unitId, unitSlug) => {
2951
+ const spawnedAt = Date.now();
2952
+ for (;;) {
2953
+ if (interrupted) return 'interrupted';
2954
+ await sleep(pollIntervalMs);
2955
+ await jobCheckpoint();
2956
+
2957
+ const seq = readSeqFn(invocationCwd, jobSlug);
2958
+ const unit = seq?.units?.find((u) => u.id === unitId);
2959
+ if (unit && (unit.state === 'done' || unit.state === 'failed' || unit.state === 'skipped')) {
2960
+ return unit.state;
2961
+ }
2962
+
2963
+ const job = reconcileJobFn(invocationCwd, unitSlug, readJob(invocationCwd, unitSlug));
2964
+ if (job && TERMINAL_JOB_STATES.includes(job.state)) {
2965
+ if (unit && unit.state !== 'done' && unit.state !== 'failed') {
2966
+ const nextState = job.state === 'done' ? 'done' : 'failed';
2967
+ patchUnitFn(invocationCwd, jobSlug, unitId, {
2968
+ state: nextState,
2969
+ sha: job.sha ?? null,
2970
+ });
2971
+ return nextState;
1213
2972
  }
2973
+ return job.state === 'done' ? 'done' : 'failed';
1214
2974
  }
1215
2975
 
1216
- remaining = remaining.slice(results.length);
2976
+ // Grace period before treating a dead pid as a crash (mirrors fan-out).
2977
+ if (job && !job.pid && Date.now() - spawnedAt > Math.max(pollIntervalMs * 4, 2000)) {
2978
+ patchUnitFn(invocationCwd, jobSlug, unitId, { state: 'failed' });
2979
+ return 'failed';
2980
+ }
1217
2981
  }
2982
+ };
1218
2983
 
1219
- // --- runner-first verify loop: test-runner first, code-writer only on failure ---
1220
- await runCodeLoop({
1221
- prompt: fanout.task,
1222
- worktreePath: worktree.worktreePath,
1223
- branch: worktree.branch,
1224
- taskPath: path.join(integrationDir, 'task.md'),
1225
- statusPath: integrationMdPath,
1226
- maxRounds,
1227
- AgentClass,
1228
- verbose,
1229
- jobPatch,
1230
- jobCheckpoint,
1231
- acceptedVerification: '',
1232
- runnerFirst: true,
1233
- loopTitle: 'Verify loop',
2984
+ try {
2985
+ jobPatch({ phase: 'triage', stage: 'triage', round: null });
2986
+ const triage = triageAgentArgs({ prompt, cwd: invocationCwd });
2987
+ const triageAgent = new AgentClass(triage.name, triage.instructions, triage.prompt, triage.options);
2988
+ const triageResult = await triageAgent.run({ verbose });
2989
+ await jobCheckpoint();
2990
+ const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
2991
+ printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
2992
+ const parsed = parseTriageJson(triageContent);
2993
+
2994
+ if (parsed?.simple === true) {
2995
+ console.log('triage: simple — seq skipped (quick-fix)');
2996
+ jobPatch({ phase: 'quick-fix', stage: 'quick-fix', round: null });
2997
+ const quickFix = quickFixAgentArgs({ prompt, cwd: invocationCwd, fix_plan: parsed.fix_plan });
2998
+ const quickFixTracker = new FileTracker({ cwd: invocationCwd });
2999
+ const quickFixAgent = new AgentClass(
3000
+ quickFix.name,
3001
+ quickFix.instructions,
3002
+ quickFix.prompt,
3003
+ { ...quickFix.options, fileTracker: quickFixTracker },
3004
+ );
3005
+ const quickFixResult = await quickFixAgent.run({ verbose });
3006
+ await jobCheckpoint();
3007
+ const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
3008
+ printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
3009
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
3010
+ exitFn(0);
3011
+ return;
3012
+ }
3013
+
3014
+ console.log('triage: complex — seq requested');
3015
+
3016
+ let feedback;
3017
+ let decision = null;
3018
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
3019
+ jobPatch({ phase: 'decompose', stage: 'seq-decomposer', round: attempt });
3020
+ const decomposer = seqDecomposerAgentArgs({
3021
+ prompt, cwd: invocationCwd, maxUnits, feedback,
3022
+ });
3023
+ const decomposerAgent = new AgentClass(decomposer.name, decomposer.instructions, decomposer.prompt, decomposer.options);
3024
+ const decomposerResult = await decomposerAgent.run({ verbose });
3025
+ await jobCheckpoint();
3026
+ const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
3027
+ printStageSummary('seq-decomposer', resolveStageSummary('seq-decomposer', decomposerSummary, decomposerContent));
3028
+
3029
+ const parsedDecomposition = parseDecomposition(decomposerContent);
3030
+ if (!parsedDecomposition) {
3031
+ feedback = ['decomposer output was not valid JSON'];
3032
+ if (attempt === 3) decision = { decline: true, why: 'decomposer did not return valid JSON after repair attempts' };
3033
+ continue;
3034
+ }
3035
+ if (parsedDecomposition.decomposable === false) {
3036
+ decision = { decline: true, why: parsedDecomposition.why };
3037
+ break;
3038
+ }
3039
+
3040
+ const violations = validateSeqDecomposition(parsedDecomposition, { maxUnits });
3041
+ if (violations.length === 0) {
3042
+ decision = { decline: false, decomposition: parsedDecomposition };
3043
+ break;
3044
+ }
3045
+ feedback = violations;
3046
+ if (attempt === 3) {
3047
+ decision = { decline: true, why: `decomposition still invalid after repairs: ${violations.join('; ')}` };
3048
+ }
3049
+ }
3050
+
3051
+ if (decision.decline) {
3052
+ console.log(`decomposer: declined — ${decision.why}`);
3053
+ console.log('falling through to the single-worktree pipeline');
3054
+ await runDeclinePipeline();
3055
+ return;
3056
+ }
3057
+
3058
+ const { decomposition } = decision;
3059
+ const base = execFileFn('git', ['-C', invocationCwd, 'rev-parse', 'HEAD']).trim();
3060
+
3061
+ await createWorktreeFn({ cwd: invocationCwd, slug: jobSlug, base });
3062
+
3063
+ const units = decomposition.units.map((unit) => ({
3064
+ id: unit.id,
3065
+ title: unit.title,
3066
+ subtask: unit.subtask,
3067
+ state: 'pending',
3068
+ slug: null,
3069
+ sha: null,
3070
+ changedFiles: null,
3071
+ }));
3072
+
3073
+ writeSeqFn(invocationCwd, jobSlug, {
3074
+ version: 1,
3075
+ parentSlug: jobSlug,
3076
+ task: prompt,
3077
+ base,
3078
+ tip: base,
3079
+ maxUnits,
3080
+ units,
3081
+ adjustments: [],
3082
+ state: 'running',
3083
+ startedAt: new Date().toISOString(),
3084
+ finishedAt: null,
1234
3085
  });
1235
3086
 
1236
- jobPatch({ phase: 'commit', stage: 'commit', round: null });
1237
- const message = `orch: ${parentSlug} ${fanout.task.split('\n')[0]}`;
1238
- const commitResult = commitWorktreeFn({
1239
- worktreePath: worktree.worktreePath,
1240
- branch: `orch/${parentSlug}`,
1241
- message,
1242
- });
3087
+ console.log(`decomposer: ${units.length} units`);
3088
+ jobPatch({ phase: 'schedule', stage: null, round: null });
3089
+
3090
+ for (;;) {
3091
+ if (interrupted) {
3092
+ exitFn(exitCodeForSignal('SIGINT'));
3093
+ return;
3094
+ }
3095
+
3096
+ await jobCheckpoint();
3097
+
3098
+ let seq = readSeqFn(invocationCwd, jobSlug);
3099
+ const firstPending = seq.units.find((u) => u.state === 'pending');
3100
+ if (!firstPending) {
3101
+ const failed = seq.units.some((u) => u.state === 'failed');
3102
+ writeSeqFn(invocationCwd, jobSlug, {
3103
+ ...seq,
3104
+ state: failed ? 'failed' : 'done',
3105
+ finishedAt: new Date().toISOString(),
3106
+ });
3107
+ jobPatch({
3108
+ state: failed ? 'failed' : 'done',
3109
+ exitCode: failed ? 1 : 0,
3110
+ finishedAt: new Date().toISOString(),
3111
+ });
3112
+ if (failed) {
3113
+ exitFn(1);
3114
+ } else {
3115
+ console.log(`seq complete: ${seq.units.filter((u) => u.state === 'done').length}/${seq.units.length} merged`);
3116
+ console.log(`merge: git merge orch/${jobSlug}`);
3117
+ exitFn(0);
3118
+ }
3119
+ return;
3120
+ }
3121
+
3122
+ // Re-attach to a live running unit instead of spawning a duplicate.
3123
+ const live = seq.units.find((u) => u.state === 'running' && u.slug);
3124
+ if (live) {
3125
+ const terminal = await waitForUnit(live.id, live.slug);
3126
+ if (terminal === 'interrupted') {
3127
+ exitFn(exitCodeForSignal('SIGINT'));
3128
+ return;
3129
+ }
3130
+ if (terminal !== 'done') {
3131
+ console.log(`[${live.id} …] ${terminal}`);
3132
+ const liveSlug = live.slug;
3133
+ console.log(`stopped: chain halted; next: orch continue ${liveSlug} "fix …"`);
3134
+ writeSeqFn(invocationCwd, jobSlug, {
3135
+ ...readSeqFn(invocationCwd, jobSlug),
3136
+ state: 'failed',
3137
+ finishedAt: new Date().toISOString(),
3138
+ });
3139
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
3140
+ exitFn(1);
3141
+ return;
3142
+ }
3143
+ console.log(`[${live.id} …] done — merging`);
3144
+ await mergeOneUnitFn({
3145
+ cwd: invocationCwd,
3146
+ parentSlug: jobSlug,
3147
+ unitId: live.id,
3148
+ unitBranch: `orch/${live.slug}`,
3149
+ agent: options.agent,
3150
+ AgentClass,
3151
+ maxRounds,
3152
+ verbose,
3153
+ jobSlug,
3154
+ jobCwd,
3155
+ createWorktree: createWorktreeFn,
3156
+ commitWorktree: commitWorktreeFn,
3157
+ execFile: execFileFn,
3158
+ exit: exitFn,
3159
+ });
3160
+ seq = readSeqFn(invocationCwd, jobSlug);
3161
+ await runAdjust(seq);
3162
+ continue;
3163
+ }
1243
3164
 
1244
- logIntegration(
1245
- commitResult.committed
1246
- ? `- Committed \`${commitResult.sha}\` on \`${commitResult.branch}\`.`
1247
- : `- No changes to commit on \`${commitResult.branch}\`.`,
1248
- );
3165
+ const tip = seq.tip;
3166
+ const unitSlug = await spawnUnitChild(firstPending, tip);
3167
+ const terminal = await waitForUnit(firstPending.id, unitSlug);
3168
+ if (terminal === 'interrupted') {
3169
+ exitFn(exitCodeForSignal('SIGINT'));
3170
+ return;
3171
+ }
3172
+ if (terminal !== 'done') {
3173
+ console.log(`[${firstPending.id} …] ${terminal}`);
3174
+ console.log(`stopped: chain halted; next: orch continue ${unitSlug} "fix …"`);
3175
+ writeSeqFn(invocationCwd, jobSlug, {
3176
+ ...readSeqFn(invocationCwd, jobSlug),
3177
+ state: 'failed',
3178
+ finishedAt: new Date().toISOString(),
3179
+ });
3180
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
3181
+ exitFn(1);
3182
+ return;
3183
+ }
1249
3184
 
1250
- patchIntegrationFn(cwd, parentSlug, {
1251
- state: 'done',
1252
- sha: commitResult.sha,
1253
- merged,
1254
- skipped,
1255
- });
1256
- jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
3185
+ console.log(`[${firstPending.id} …] done — merging`);
3186
+ await mergeOneUnitFn({
3187
+ cwd: invocationCwd,
3188
+ parentSlug: jobSlug,
3189
+ unitId: firstPending.id,
3190
+ unitBranch: `orch/${unitSlug}`,
3191
+ agent: options.agent,
3192
+ AgentClass,
3193
+ maxRounds,
3194
+ verbose,
3195
+ jobSlug,
3196
+ jobCwd,
3197
+ createWorktree: createWorktreeFn,
3198
+ commitWorktree: commitWorktreeFn,
3199
+ execFile: execFileFn,
3200
+ exit: exitFn,
3201
+ });
3202
+ seq = readSeqFn(invocationCwd, jobSlug);
3203
+ await runAdjust(seq);
3204
+ }
1257
3205
  } catch (err) {
1258
3206
  console.error(`Error: ${err.message}`);
1259
- try {
1260
- logIntegration(`- Error: ${err.message}`);
1261
- } catch {
1262
- // Best-effort: don't let a log write failure mask the real error.
1263
- }
1264
- try {
1265
- patchIntegrationFn(cwd, parentSlug, { state: 'failed', merged, skipped });
1266
- } catch {
1267
- // Best-effort: don't let a fanout-state write failure mask the real error.
1268
- }
1269
3207
  if (jobSlug) {
1270
3208
  try {
1271
- patchJobFn(jobCwd, jobSlug, {
3209
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1272
3210
  state: 'failed',
1273
3211
  exitCode: 1,
1274
- finishedAt: new Date().toISOString(),
3212
+ summary: '',
3213
+ error: err.message,
3214
+ task: prompt,
1275
3215
  });
1276
3216
  } catch {
1277
- // Best-effort: don't let a job-state write failure mask the real error.
3217
+ // Best-effort.
1278
3218
  }
1279
3219
  }
1280
- process.exit(1);
3220
+ exitFn(1);
1281
3221
  }
1282
3222
  }
1283
3223
 
1284
- function sleep(ms) {
1285
- return new Promise((resolve) => setTimeout(resolve, ms));
1286
- }
1287
-
1288
- /** Sentinel thrown by the coordinator's scheduling loop when a SIGINT/SIGTERM/SIGHUP
1289
- * cascade fires mid-flight, so the pending awaits unwind without a second exit call. */
1290
- class FanoutInterrupted extends Error {}
1291
-
1292
3224
  /**
1293
- * SIGINT/SIGHUP/SIGTERM cascade: reads `fanout.json`, resolves every worker's and the
1294
- * integration session's own pid via its `run.json`, filters through `isPidAlive`, and
1295
- * SIGTERMs each live one. Never touches worktrees. Composes with (does not replace)
1296
- * `lib/agent.js`'s existing `shutdown()` — the coordinator's own signal handling calls
1297
- * both this and the normal in-process agent-CLI reaping.
3225
+ * Hidden `--seq-continue <parent>`: merge any done-but-unmerged units, adjust,
3226
+ * then continue the pending schedule loop.
1298
3227
  */
1299
- export function cascadeStopFanoutChildren(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1300
- const fanout = readFanout(cwd, parentSlug);
1301
- if (!fanout) return;
3228
+ export async function runSeqContinuePipeline(options = {}) {
3229
+ const verbose = Boolean(options.verbose);
3230
+ const maxRounds = options.maxRounds ?? 5;
3231
+ const backend = AGENT_BACKENDS[options.agent];
3232
+ if (!backend) {
3233
+ throw new Error(`Unknown agent backend: ${options.agent}`);
3234
+ }
3235
+ const AgentClass = options.AgentClass ?? backend.AgentClass;
3236
+ const invocationCwd = options.cwd ?? process.cwd();
3237
+ const parentSlug = options.parentSlug;
3238
+ const exitFn = options.exit ?? ((code) => process.exit(code));
3239
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
1302
3240
 
1303
- const slugs = fanout.workers.filter((worker) => worker.slug).map((worker) => worker.slug);
1304
- if (fanout.integration?.slug) slugs.push(fanout.integration.slug);
3241
+ const createWorktreeFn = options.createWorktree ?? createWorktree;
3242
+ const commitWorktreeFn = options.commitWorktree ?? commitWorktree;
3243
+ const spawnFn = options.spawn ?? spawn;
3244
+ const execFileFn = options.execFile ?? defaultExecFile;
3245
+ const allocateJobFn = options.allocateJob ?? allocateJob;
3246
+ const reconcileJobFn = options.reconcileJob ?? reconcileJob;
3247
+ const readSeqFn = options.readSeq ?? readSeq;
3248
+ const writeSeqFn = options.writeSeq ?? writeSeq;
3249
+ const patchUnitFn = options.patchUnit ?? patchUnit;
3250
+ const appendAdjustmentFn = options.appendAdjustment ?? appendAdjustment;
3251
+ const mergeOneUnitFn = options.mergeOneUnit ?? mergeOneUnit;
1305
3252
 
1306
- for (const slug of slugs) {
1307
- const record = readJob(cwd, slug);
1308
- if (!record) continue;
1309
- if (isPidAliveFn(record.pid)) {
1310
- kill(record.pid, 'SIGTERM');
1311
- }
3253
+ const jobSlug = options.jobSlug ?? parentSlug ?? process.env.ORCH_JOB_SLUG;
3254
+ const jobCwd = options.jobCwd ?? invocationCwd;
3255
+ const patchJobFn = options.patchJob ?? patchJob;
3256
+ const checkpointPauseFn = options.checkpointPause ?? checkpointPause;
3257
+ const pausePollIntervalMs = options.pausePollIntervalMs ?? 500;
3258
+
3259
+ const jobPatch = (fields) => {
3260
+ if (!jobSlug) return;
3261
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
3262
+ };
3263
+ const jobCheckpoint = async () => {
3264
+ if (!jobSlug) return;
3265
+ await checkpointPauseFn(jobCwd, jobSlug, { pollIntervalMs: pausePollIntervalMs });
3266
+ };
3267
+
3268
+ const seq = readSeqFn(invocationCwd, parentSlug);
3269
+ if (!seq) {
3270
+ console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
3271
+ exitFn(1);
3272
+ return;
1312
3273
  }
1313
- }
1314
3274
 
1315
- /**
1316
- * CLI / management cascade stop: SIGTERM the parent pid if alive, then every
1317
- * live child pid (via `cascadeStopFanoutChildren`). The coordinator signal
1318
- * handler keeps calling the child-only helper so it does not re-signal itself.
1319
- */
1320
- export function cascadeStop(cwd, parentSlug, { kill = (pid, signal) => process.kill(pid, signal), isPidAlive: isPidAliveFn = isPidAlive } = {}) {
1321
- const parent = readJob(cwd, parentSlug);
1322
- if (parent && isPidAliveFn(parent.pid)) {
1323
- kill(parent.pid, 'SIGTERM');
3275
+ const maxUnits = seq.maxUnits ?? 8;
3276
+ const prompt = seq.task;
3277
+
3278
+ const runAdjust = async (seqDoc) => {
3279
+ const doneUnits = seqDoc.units.filter((u) => u.state === 'done');
3280
+ const pendingUnits = seqDoc.units.filter((u) => u.state === 'pending');
3281
+ if (pendingUnits.length === 0) return seqDoc;
3282
+
3283
+ let feedback;
3284
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
3285
+ const adjust = adjustAgentArgs({
3286
+ originalTask: prompt,
3287
+ doneUnits,
3288
+ pendingUnits,
3289
+ tip: seqDoc.tip,
3290
+ cwd: invocationCwd,
3291
+ maxUnits,
3292
+ feedback,
3293
+ });
3294
+ const adjustAgent = new AgentClass(adjust.name, adjust.instructions, adjust.prompt, adjust.options);
3295
+ const adjustResult = await adjustAgent.run({ verbose });
3296
+ await jobCheckpoint();
3297
+ const { content: adjustContent } = splitStageSummary(adjustResult.result);
3298
+ const parsed = parseDecomposition(adjustContent);
3299
+ if (!parsed) {
3300
+ feedback = ['adjust output was not valid JSON'];
3301
+ continue;
3302
+ }
3303
+ const violations = validateAdjustResult(parsed, { units: seqDoc.units, maxUnits });
3304
+ if (violations.length === 0) {
3305
+ const applied = applyAdjustResult(seqDoc, parsed);
3306
+ writeSeqFn(invocationCwd, parentSlug, applied);
3307
+ appendAdjustmentFn(invocationCwd, parentSlug, {
3308
+ afterUnitId: doneUnits[doneUnits.length - 1]?.id ?? null,
3309
+ tip: applied.tip,
3310
+ summary: 'seq-continue adjust',
3311
+ });
3312
+ return applied;
3313
+ }
3314
+ feedback = violations;
3315
+ }
3316
+ return seqDoc;
3317
+ };
3318
+
3319
+ const spawnUnitChild = async (unit) => {
3320
+ const envelope = buildUnitEnvelope({
3321
+ id: unit.id,
3322
+ title: unit.title,
3323
+ subtask: unit.subtask,
3324
+ originalTask: prompt,
3325
+ });
3326
+ const unitPrompt = `${unit.subtask}\n\n${envelope}`;
3327
+ const allocated = await allocateJobFn({
3328
+ cwd: invocationCwd,
3329
+ prompt: unitPrompt,
3330
+ agent: options.agent,
3331
+ maxRounds,
3332
+ state: 'starting',
3333
+ parent: parentSlug,
3334
+ role: 'worker',
3335
+ workerId: unit.id,
3336
+ });
3337
+ const unitSlug = allocated.slug;
3338
+ patchUnitFn(invocationCwd, parentSlug, unit.id, { slug: unitSlug, state: 'running' });
3339
+ const { logPath } = jobPaths(invocationCwd, unitSlug);
3340
+ const logFd = fs.openSync(logPath, 'a');
3341
+ const childArgs = [
3342
+ __filename, unitPrompt,
3343
+ '--agent', options.agent,
3344
+ '--max-rounds', String(maxRounds),
3345
+ '--unit', `${parentSlug}:${unit.id}`,
3346
+ ];
3347
+ const child = spawnFn(process.execPath, childArgs, {
3348
+ cwd: invocationCwd,
3349
+ env: {
3350
+ ...process.env,
3351
+ ORCH_JOB_SLUG: unitSlug,
3352
+ ORCH_DETACHED: '1',
3353
+ ORCH_SEQ_DEPTH: '1',
3354
+ ORCH_FANOUT_DEPTH: '1',
3355
+ },
3356
+ detached: true,
3357
+ stdio: ['ignore', logFd, logFd],
3358
+ });
3359
+ child.unref();
3360
+ patchJobFn(invocationCwd, unitSlug, { pid: child.pid, state: 'running' });
3361
+ return unitSlug;
3362
+ };
3363
+
3364
+ const waitForUnit = async (unitId, unitSlug) => {
3365
+ for (;;) {
3366
+ await sleep(pollIntervalMs);
3367
+ await jobCheckpoint();
3368
+ const current = readSeqFn(invocationCwd, parentSlug);
3369
+ const unit = current?.units?.find((u) => u.id === unitId);
3370
+ if (unit && (unit.state === 'done' || unit.state === 'failed')) return unit.state;
3371
+ const job = reconcileJobFn(invocationCwd, unitSlug, readJob(invocationCwd, unitSlug));
3372
+ if (job && TERMINAL_JOB_STATES.includes(job.state)) {
3373
+ return job.state === 'done' ? 'done' : 'failed';
3374
+ }
3375
+ }
3376
+ };
3377
+
3378
+ try {
3379
+ jobPatch({ state: 'running', phase: 'schedule', finishedAt: null, exitCode: null });
3380
+ writeSeqFn(invocationCwd, parentSlug, { ...seq, state: 'running', finishedAt: null });
3381
+
3382
+ // Merge any done units whose tip may not yet include them (done-but-unmerged).
3383
+ let current = readSeqFn(invocationCwd, parentSlug);
3384
+ for (const unit of current.units) {
3385
+ if (unit.state === 'done' && unit.slug) {
3386
+ // Heuristic: merge if unit sha is set and tip still looks like an earlier base,
3387
+ // or always attempt merge of the most recent done unit that hasn't been adjusted after.
3388
+ const alreadyAdjusted = (current.adjustments || []).some((a) => a.afterUnitId === unit.id);
3389
+ if (!alreadyAdjusted) {
3390
+ await mergeOneUnitFn({
3391
+ cwd: invocationCwd,
3392
+ parentSlug,
3393
+ unitId: unit.id,
3394
+ unitBranch: `orch/${unit.slug}`,
3395
+ agent: options.agent,
3396
+ AgentClass,
3397
+ maxRounds,
3398
+ verbose,
3399
+ jobSlug,
3400
+ jobCwd,
3401
+ createWorktree: createWorktreeFn,
3402
+ commitWorktree: commitWorktreeFn,
3403
+ execFile: execFileFn,
3404
+ exit: (code) => {
3405
+ if (code !== 0) throw new Error(`merge of ${unit.id} failed`);
3406
+ },
3407
+ });
3408
+ current = await runAdjust(readSeqFn(invocationCwd, parentSlug));
3409
+ }
3410
+ }
3411
+ }
3412
+
3413
+ for (;;) {
3414
+ await jobCheckpoint();
3415
+ current = readSeqFn(invocationCwd, parentSlug);
3416
+ const pending = current.units.find((u) => u.state === 'pending');
3417
+ if (!pending) {
3418
+ writeSeqFn(invocationCwd, parentSlug, {
3419
+ ...current,
3420
+ state: 'done',
3421
+ finishedAt: new Date().toISOString(),
3422
+ });
3423
+ jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
3424
+ exitFn(0);
3425
+ return;
3426
+ }
3427
+
3428
+ const unitSlug = await spawnUnitChild(pending);
3429
+ const terminal = await waitForUnit(pending.id, unitSlug);
3430
+ if (terminal !== 'done') {
3431
+ writeSeqFn(invocationCwd, parentSlug, {
3432
+ ...readSeqFn(invocationCwd, parentSlug),
3433
+ state: 'failed',
3434
+ finishedAt: new Date().toISOString(),
3435
+ });
3436
+ jobPatch({ state: 'failed', exitCode: 1, finishedAt: new Date().toISOString() });
3437
+ exitFn(1);
3438
+ return;
3439
+ }
3440
+
3441
+ await mergeOneUnitFn({
3442
+ cwd: invocationCwd,
3443
+ parentSlug,
3444
+ unitId: pending.id,
3445
+ unitBranch: `orch/${unitSlug}`,
3446
+ agent: options.agent,
3447
+ AgentClass,
3448
+ maxRounds,
3449
+ verbose,
3450
+ jobSlug,
3451
+ jobCwd,
3452
+ createWorktree: createWorktreeFn,
3453
+ commitWorktree: commitWorktreeFn,
3454
+ execFile: execFileFn,
3455
+ exit: exitFn,
3456
+ });
3457
+ await runAdjust(readSeqFn(invocationCwd, parentSlug));
3458
+ }
3459
+ } catch (err) {
3460
+ console.error(`Error: ${err.message}`);
3461
+ exitFn(1);
1324
3462
  }
1325
- cascadeStopFanoutChildren(cwd, parentSlug, { kill, isPidAlive: isPidAliveFn });
1326
3463
  }
1327
3464
 
1328
3465
  /**
@@ -1367,7 +3504,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
1367
3504
 
1368
3505
  const jobPatch = (fields) => {
1369
3506
  if (!jobSlug) return;
1370
- patchJobFn(jobCwd, jobSlug, fields);
3507
+ return patchJobCursor(patchJobFn, jobCwd, jobSlug, fields);
1371
3508
  };
1372
3509
  const jobCheckpoint = async () => {
1373
3510
  if (!jobSlug) return;
@@ -1392,11 +3529,24 @@ export async function runFanoutPipeline(prompt, options = {}) {
1392
3529
  }
1393
3530
  if (jobSlug) {
1394
3531
  try {
1395
- patchJobFn(jobCwd, jobSlug, {
3532
+ const exitCode = exitCodeForSignal(signal);
3533
+ const finishedAt = new Date().toISOString();
3534
+ patchJobFn(jobCwd, jobSlug, (current) => ({
1396
3535
  state: 'stopped',
1397
- exitCode: exitCodeForSignal(signal),
1398
- finishedAt: new Date().toISOString(),
1399
- });
3536
+ exitCode,
3537
+ finishedAt,
3538
+ lastOutcome: buildLastOutcome({
3539
+ state: 'stopped',
3540
+ phase: current.phase,
3541
+ stage: current.stage,
3542
+ round: current.round,
3543
+ exitCode,
3544
+ finishedAt,
3545
+ task: prompt,
3546
+ summary: '',
3547
+ error: null,
3548
+ }),
3549
+ }));
1400
3550
  } catch {
1401
3551
  // Best-effort: don't let a job-state write failure block shutdown.
1402
3552
  }
@@ -1414,7 +3564,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
1414
3564
  const triageResult = await triageAgent.run({ verbose });
1415
3565
  await jobCheckpoint();
1416
3566
  const { content: triageContent, summary: triageSummary } = splitStageSummary(triageResult.result);
1417
- printStageSummary('triage', triageSummary);
3567
+ printStageSummary('triage', resolveStageSummary('triage', triageSummary, triageContent));
1418
3568
  const parsed = parseTriageJson(triageContent);
1419
3569
 
1420
3570
  if (parsed?.simple === true) {
@@ -1430,8 +3580,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
1430
3580
  );
1431
3581
  const quickFixResult = await quickFixAgent.run({ verbose });
1432
3582
  await jobCheckpoint();
1433
- const { summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
1434
- printStageSummary('quick-fix', quickFixSummary, quickFixTracker.getFiles());
3583
+ const { content: quickFixContent, summary: quickFixSummary } = splitStageSummary(quickFixResult.result);
3584
+ printStageSummary('quick-fix', resolveStageSummary('quick-fix', quickFixSummary, quickFixContent), quickFixTracker.getFiles());
1435
3585
  jobPatch({ state: 'done', exitCode: 0, finishedAt: new Date().toISOString() });
1436
3586
  exitFn(0);
1437
3587
  return;
@@ -1447,7 +3597,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
1447
3597
  const boundariesResult = await boundariesAgent.run({ verbose });
1448
3598
  await jobCheckpoint();
1449
3599
  const { content: boundariesOutput, summary: boundariesSummary } = splitStageSummary(boundariesResult.result);
1450
- printStageSummary('boundaries', boundariesSummary);
3600
+ printStageSummary('boundaries', resolveStageSummary('boundaries', boundariesSummary, boundariesOutput));
1451
3601
  console.log(`boundaries: ${boundariesSummary || 'done'}`);
1452
3602
 
1453
3603
  // --- decomposer: up to two repair round-trips on validation failure ---
@@ -1462,7 +3612,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
1462
3612
  const decomposerResult = await decomposerAgent.run({ verbose });
1463
3613
  await jobCheckpoint();
1464
3614
  const { content: decomposerContent, summary: decomposerSummary } = splitStageSummary(decomposerResult.result);
1465
- printStageSummary('decomposer', decomposerSummary);
3615
+ printStageSummary('decomposer', resolveStageSummary('decomposer', decomposerSummary, decomposerContent));
1466
3616
 
1467
3617
  const parsedDecomposition = parseDecomposition(decomposerContent);
1468
3618
  if (!parsedDecomposition) {
@@ -1498,7 +3648,7 @@ export async function runFanoutPipeline(prompt, options = {}) {
1498
3648
  const researchResult = await researchAgent.run({ verbose });
1499
3649
  await jobCheckpoint();
1500
3650
  const { content: researchContent, summary: researchSummary } = splitStageSummary(researchResult.result);
1501
- printStageSummary('research', researchSummary);
3651
+ printStageSummary('research', resolveStageSummary('research', researchSummary, researchContent));
1502
3652
 
1503
3653
  jobPatch({ phase: 'plan', stage: 'planner', round: null });
1504
3654
  const planner = plannerAgentArgs({
@@ -1507,8 +3657,8 @@ export async function runFanoutPipeline(prompt, options = {}) {
1507
3657
  const plannerAgent = new AgentClass(planner.name, planner.instructions, planner.prompt, planner.options);
1508
3658
  const plannerResult = await plannerAgent.run({ verbose });
1509
3659
  await jobCheckpoint();
1510
- const { summary: plannerSummary } = splitStageSummary(plannerResult.result);
1511
- printStageSummary('planner', plannerSummary);
3660
+ const { content: plannerContent, summary: plannerSummary } = splitStageSummary(plannerResult.result);
3661
+ printStageSummary('planner', resolveStageSummary('planner', plannerSummary, plannerContent));
1512
3662
 
1513
3663
  jobPatch({ phase: 'worktree', stage: 'worktree', round: null });
1514
3664
  const worktree = createWorktreeFn({ cwd: invocationCwd, slug: runContext.slug });
@@ -1942,17 +4092,31 @@ export async function runFanoutPipeline(prompt, options = {}) {
1942
4092
  console.log(`retry integration after fixing it: orch --integrate ${jobSlug}`);
1943
4093
  }
1944
4094
 
1945
- jobPatch({ state: success ? 'done' : 'failed', exitCode: success ? 0 : 1, finishedAt: new Date().toISOString() });
4095
+ const finishedAt = new Date().toISOString();
4096
+ const summary = success
4097
+ ? `fan-out complete: ${doneWorkers.length} worker${doneWorkers.length === 1 ? '' : 's'} integrated`
4098
+ : (failedWorkers.length > 0
4099
+ ? `${failedWorkers.length} worker(s) failed`
4100
+ : 'fan-out failed');
4101
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
4102
+ state: success ? 'done' : 'failed',
4103
+ exitCode: success ? 0 : 1,
4104
+ summary,
4105
+ error: success ? null : summary,
4106
+ task: prompt,
4107
+ });
1946
4108
  exitFn(success ? 0 : 1);
1947
4109
  } catch (err) {
1948
4110
  if (err instanceof FanoutInterrupted) return;
1949
4111
  console.error(`Error: ${err.message}`);
1950
4112
  if (jobSlug) {
1951
4113
  try {
1952
- patchJobFn(jobCwd, jobSlug, {
4114
+ patchTerminalJob(patchJobFn, jobCwd, jobSlug, {
1953
4115
  state: 'failed',
1954
4116
  exitCode: 1,
1955
- finishedAt: new Date().toISOString(),
4117
+ summary: '',
4118
+ error: err.message,
4119
+ task: prompt,
1956
4120
  });
1957
4121
  } catch {
1958
4122
  // Best-effort: don't let a job-state write failure mask the real error.
@@ -1969,71 +4133,137 @@ function splitParentWorker(value) {
1969
4133
  return { parentSlug: value.slice(0, idx), workerId: value.slice(idx + 1) };
1970
4134
  }
1971
4135
 
1972
- /** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
1973
- * envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
1974
- async function runWorkerFromCli(options) {
4136
+ /** CLI glue for `--worker`: resolves the parent fan-out/worker record, builds the worker
4137
+ * envelope, allocates (or reuses) the job record, then calls `runWorkerPipeline`. */
4138
+ async function runWorkerFromCli(options) {
4139
+ const cwd = process.cwd();
4140
+ const { parentSlug, workerId } = splitParentWorker(options.worker);
4141
+ if (!parentSlug || !workerId) {
4142
+ console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
4143
+ process.exit(1);
4144
+ return;
4145
+ }
4146
+
4147
+ // ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
4148
+ process.env.ORCH_FANOUT_DEPTH = '1';
4149
+
4150
+ const fanout = readFanout(cwd, parentSlug);
4151
+ if (!fanout) {
4152
+ console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
4153
+ process.exit(1);
4154
+ return;
4155
+ }
4156
+
4157
+ const worker = fanout.workers.find((w) => w.id === workerId);
4158
+ if (!worker) {
4159
+ console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
4160
+ process.exit(1);
4161
+ return;
4162
+ }
4163
+
4164
+ const siblingTitles = fanout.workers
4165
+ .filter((w) => w.id !== workerId)
4166
+ .map((w) => w.title)
4167
+ .filter(Boolean);
4168
+ const envelope = buildWorkerEnvelope({
4169
+ subtask: worker.subtask,
4170
+ area: worker.area,
4171
+ scaffold: worker.scaffold,
4172
+ siblingTitles,
4173
+ });
4174
+ const workerPrompt = `${worker.subtask}\n\n${envelope}`;
4175
+
4176
+ let jobSlug = process.env.ORCH_JOB_SLUG;
4177
+ if (!jobSlug) {
4178
+ const alloc = allocateJob({
4179
+ cwd,
4180
+ prompt: workerPrompt,
4181
+ agent: options.agent,
4182
+ maxRounds: options.maxRounds,
4183
+ state: 'running',
4184
+ pid: process.pid,
4185
+ parent: parentSlug,
4186
+ role: 'worker',
4187
+ workerId,
4188
+ });
4189
+ jobSlug = alloc.slug;
4190
+ }
4191
+ setJobSlug(jobSlug);
4192
+
4193
+ await runWorkerPipeline(workerPrompt, {
4194
+ agent: options.agent,
4195
+ maxRounds: options.maxRounds,
4196
+ verbose: options.verbose,
4197
+ cwd,
4198
+ parentSlug,
4199
+ workerId,
4200
+ base: fanout.base,
4201
+ jobSlug,
4202
+ jobCwd: cwd,
4203
+ });
4204
+ }
4205
+
4206
+ /** CLI glue for `--unit`: resolves the parent seq/unit record, builds the unit
4207
+ * envelope, allocates (or reuses) the job record, then calls `runUnitPipeline`. */
4208
+ async function runUnitFromCli(options) {
1975
4209
  const cwd = process.cwd();
1976
- const { parentSlug, workerId } = splitParentWorker(options.worker);
1977
- if (!parentSlug || !workerId) {
1978
- console.error(`Error: --worker must be in the form <parent-slug>:<worker-id>, got "${options.worker}"`);
4210
+ const { parentSlug, workerId: unitId } = splitParentWorker(options.unit);
4211
+ if (!parentSlug || !unitId) {
4212
+ console.error(`Error: --unit must be in the form <parent-slug>:<unit-id>, got "${options.unit}"`);
1979
4213
  process.exit(1);
1980
4214
  return;
1981
4215
  }
1982
4216
 
1983
- // ORCH_FANOUT_DEPTH guards against a future --fan-out spawning nested fan-outs.
4217
+ process.env.ORCH_SEQ_DEPTH = '1';
1984
4218
  process.env.ORCH_FANOUT_DEPTH = '1';
1985
4219
 
1986
- const fanout = readFanout(cwd, parentSlug);
1987
- if (!fanout) {
1988
- console.error(`Error: unknown parent ${parentSlug} (no fanout.json found)`);
4220
+ const seq = readSeq(cwd, parentSlug);
4221
+ if (!seq) {
4222
+ console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
1989
4223
  process.exit(1);
1990
4224
  return;
1991
4225
  }
1992
4226
 
1993
- const worker = fanout.workers.find((w) => w.id === workerId);
1994
- if (!worker) {
1995
- console.error(`Error: unknown worker ${workerId} in ${parentSlug}`);
4227
+ const unit = seq.units.find((u) => u.id === unitId);
4228
+ if (!unit) {
4229
+ console.error(`Error: unknown unit ${unitId} in ${parentSlug}`);
1996
4230
  process.exit(1);
1997
4231
  return;
1998
4232
  }
1999
4233
 
2000
- const siblingTitles = fanout.workers
2001
- .filter((w) => w.id !== workerId)
2002
- .map((w) => w.title)
2003
- .filter(Boolean);
2004
- const envelope = buildWorkerEnvelope({
2005
- subtask: worker.subtask,
2006
- area: worker.area,
2007
- scaffold: worker.scaffold,
2008
- siblingTitles,
4234
+ const envelope = buildUnitEnvelope({
4235
+ id: unit.id,
4236
+ title: unit.title,
4237
+ subtask: unit.subtask,
4238
+ originalTask: seq.task,
2009
4239
  });
2010
- const workerPrompt = `${worker.subtask}\n\n${envelope}`;
4240
+ const unitPrompt = `${unit.subtask}\n\n${envelope}`;
2011
4241
 
2012
4242
  let jobSlug = process.env.ORCH_JOB_SLUG;
2013
4243
  if (!jobSlug) {
2014
4244
  const alloc = allocateJob({
2015
4245
  cwd,
2016
- prompt: workerPrompt,
4246
+ prompt: unitPrompt,
2017
4247
  agent: options.agent,
2018
4248
  maxRounds: options.maxRounds,
2019
4249
  state: 'running',
2020
4250
  pid: process.pid,
2021
4251
  parent: parentSlug,
2022
4252
  role: 'worker',
2023
- workerId,
4253
+ workerId: unitId,
2024
4254
  });
2025
4255
  jobSlug = alloc.slug;
2026
4256
  }
2027
4257
  setJobSlug(jobSlug);
2028
4258
 
2029
- await runWorkerPipeline(workerPrompt, {
4259
+ await runUnitPipeline(unitPrompt, {
2030
4260
  agent: options.agent,
2031
4261
  maxRounds: options.maxRounds,
2032
4262
  verbose: options.verbose,
2033
4263
  cwd,
2034
4264
  parentSlug,
2035
- workerId,
2036
- base: fanout.base,
4265
+ unitId,
4266
+ base: seq.tip,
2037
4267
  jobSlug,
2038
4268
  jobCwd: cwd,
2039
4269
  });
@@ -2093,29 +4323,91 @@ function positiveIntParser(flagName) {
2093
4323
  };
2094
4324
  }
2095
4325
 
4326
+ /** Resolve the effective agent (CLI > local > global > cursor) or exit 1. */
4327
+ function resolveAgentOrExit(cliAgent, cwd = process.cwd()) {
4328
+ try {
4329
+ return resolveAgent({ cliAgent, cwd });
4330
+ } catch (err) {
4331
+ console.error(`Error: ${err.message}`);
4332
+ process.exit(1);
4333
+ return undefined;
4334
+ }
4335
+ }
4336
+
4337
+ /**
4338
+ * Resolve CLI `--notify` / `--no-notify` to `true` | `false` | `undefined`.
4339
+ * Commander stores both on `opts.notify` (negatable); detect both-via-argv
4340
+ * for the mutual-exclusion error.
4341
+ */
4342
+ function cliNotifyFromOptions(options, argv = process.argv) {
4343
+ const hasNotify = argv.includes('--notify');
4344
+ const hasNoNotify = argv.includes('--no-notify');
4345
+ if (hasNotify && hasNoNotify) {
4346
+ console.error('Error: --notify and --no-notify are mutually exclusive');
4347
+ process.exit(1);
4348
+ return undefined;
4349
+ }
4350
+ if (options.notify === true) return true;
4351
+ if (options.notify === false) return false;
4352
+ return undefined;
4353
+ }
4354
+
4355
+ function resolveNotifyOrExit(cliNotify, cwd = process.cwd()) {
4356
+ try {
4357
+ return resolveNotify({ cliNotify, cwd });
4358
+ } catch (err) {
4359
+ console.error(`Error: ${err.message}`);
4360
+ process.exit(1);
4361
+ return undefined;
4362
+ }
4363
+ }
4364
+
4365
+ /** Apply process-level notify gate from CLI flags + config (skip for dry-run). */
4366
+ function applyNotifyEnabled(options, { cwd = process.cwd(), dryRun = false } = {}) {
4367
+ if (dryRun) {
4368
+ setNotifyEnabled(false);
4369
+ return false;
4370
+ }
4371
+ const cliNotify = cliNotifyFromOptions(options);
4372
+ const enabled = resolveNotifyOrExit(cliNotify, cwd);
4373
+ setNotifyEnabled(enabled);
4374
+ return enabled;
4375
+ }
4376
+
4377
+ /** Append `--notify` / `--no-notify` to child argv when the parent had an explicit flag. */
4378
+ function appendNotifyArgs(args, notify) {
4379
+ if (notify === true) args.push('--notify');
4380
+ else if (notify === false) args.push('--no-notify');
4381
+ }
4382
+
2096
4383
  const program = new Command();
2097
4384
 
2098
4385
  program
2099
4386
  .name('orch')
2100
4387
  .version(version)
2101
4388
  .description('The Orchestrator: triage → research → plan → implement pipeline against a task')
2102
- .argument('<task...>', 'Task description to use as the prompt (mention a file path and the agent will read it)')
4389
+ .argument('[task...]', 'Task description to use as the prompt (mention a file path and the agent will read it)')
2103
4390
  .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
2104
4391
  .option('--dry-run', 'Check that the selected agent CLI is on PATH and exit; do not run the pipeline')
2105
4392
  .option('--ask', 'Ask a read-only question about the codebase; print the reply and exit (skips triage and all write pipelines)')
2106
4393
  .option('--quick', 'Skip triage, run quick-fix directly in the current working tree; create no artifacts, worktrees, or commits')
2107
4394
  .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')
2108
4395
  .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop (ignored with --ask and --quick)', positiveIntParser('--max-rounds'), 5)
2109
- .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')
4396
+ .option('--fan-out', 'Decompose into parallel workers, then integrate once. Cannot be combined with --ask, --quick, --dry-run, or --seq')
4397
+ .option('--seq', 'Decompose into ordered units; merge each, then adjust the next. Cannot be combined with --fan-out, --ask, --quick, or --dry-run')
2110
4398
  .option('--max-workers <n>', 'Max number of parallel fan-out workers (only meaningful with --fan-out)', positiveIntParser('--max-workers'), 4)
4399
+ .option('--max-units <n>', 'Max number of sequential units (only meaningful with --seq)', positiveIntParser('--max-units'), 8)
2111
4400
  .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'))
4401
+ .option('--notify', 'Enable desktop notification when a job reaches a terminal state (default: on)')
4402
+ .option('--no-notify', 'Disable desktop notifications for this run')
2112
4403
  .addOption(
2113
- new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), or "agn" (agn CLI)')
2114
- .choices(['cursor', 'claude', 'agn'])
2115
- .default('cursor'),
4404
+ new Option('--agent <agent>', 'Agent backend to run the pipeline with: "cursor" (Cursor Agent CLI), "claude" (Claude Code CLI), "agn" (agn CLI), or "opencode" (OpenCode CLI). Omitting uses local then global config, else cursor')
4405
+ .choices(['cursor', 'claude', 'agn', 'opencode']),
2116
4406
  )
2117
4407
  .addOption(new Option('--worker <value>', 'internal: run a single fan-out worker "<parent-slug>:<worker-id>"').hideHelp())
4408
+ .addOption(new Option('--unit <value>', 'internal: run a single seq unit "<parent-slug>:<unit-id>"').hideHelp())
2118
4409
  .addOption(new Option('--integrate <value>', 'internal: (re)run fan-out integration for "<parent-slug>"').hideHelp())
4410
+ .addOption(new Option('--seq-continue <value>', 'internal: resume a seq coordinator after a fixed unit "<parent-slug>"').hideHelp())
2119
4411
  .addHelpText(
2120
4412
  'after',
2121
4413
  `
@@ -2123,32 +4415,139 @@ Examples:
2123
4415
  $ orch "fix the typo in the README" --agent claude
2124
4416
  $ orch "fix the bug described in task.md" --agent cursor -v
2125
4417
  $ orch "implement the local spec" --agent agn -v
4418
+ $ orch "fix the typo in the README" --agent opencode
2126
4419
  $ orch --ask "where is the CLI entrypoint?" --agent claude
2127
4420
  $ orch --quick "fix the typo in the README" --agent claude
2128
4421
  $ orch "noop" --dry-run --agent cursor
4422
+ $ orch "noop" --dry-run --agent opencode
4423
+ $ orch config # print effective agent
4424
+ $ orch config --agent claude # pin global default
4425
+ $ orch config --agent agn --local # pin project default
4426
+ $ orch config --agent opencode # pin OpenCode as default
2129
4427
 
2130
4428
  Headless runs:
2131
4429
  $ orch "long-running task" --detach --agent claude # start in the background, prints the run slug
2132
4430
  $ orch list # show all tracked runs
2133
4431
  $ orch status [slug] # show full status (defaults to most recent)
2134
4432
  $ orch pause <slug> # request a pause at the next stage boundary
2135
- $ orch resume <slug> # resume a paused/pausing run
4433
+ $ orch resume <slug> # unpause live pause, or recover failed/stopped/crashed
4434
+ $ orch continue <slug> "new task" # new work on a done run's worktree
4435
+ # (workers: same command; then re-integrate the parent)
2136
4436
  $ orch stop <slug> # send SIGTERM to a running job
2137
4437
  $ orch logs <slug> [-f] # print (or follow) a run's log file
2138
4438
 
2139
4439
  Fan-out:
2140
4440
  $ orch "implement the billing module" --fan-out --agent claude # triage, decompose, run parallel workers, integrate
2141
4441
  $ orch "implement X" --fan-out --max-workers 6 --max-concurrency 3
4442
+
4443
+ Sequential (--seq):
4444
+ $ orch "implement the billing module" --seq --agent claude
4445
+ $ orch "implement X" --seq --max-units 6
2142
4446
  `,
2143
4447
  )
2144
4448
  .action(async (task, options) => {
2145
- const prompt = task.join(' ').trim();
4449
+ const prompt = (Array.isArray(task) ? task : []).join(' ').trim();
4450
+
4451
+ options.agent = resolveAgentOrExit(options.agent);
4452
+ applyNotifyEnabled(options, { dryRun: Boolean(options.dryRun) });
4453
+
4454
+ if (options.seqContinue) {
4455
+ const cwd = process.cwd();
4456
+ const parentSlug = options.seqContinue;
4457
+ const seq = readSeq(cwd, parentSlug);
4458
+ if (!seq) {
4459
+ console.error(`Error: unknown parent ${parentSlug} (no seq.json found)`);
4460
+ process.exit(1);
4461
+ return;
4462
+ }
4463
+ let jobSlug = process.env.ORCH_JOB_SLUG;
4464
+ if (!jobSlug) {
4465
+ jobSlug = parentSlug;
4466
+ setJobSlug(jobSlug);
4467
+ }
4468
+ await runSeqContinuePipeline({
4469
+ agent: options.agent,
4470
+ maxRounds: options.maxRounds,
4471
+ verbose: options.verbose,
4472
+ cwd,
4473
+ parentSlug,
4474
+ jobSlug,
4475
+ jobCwd: cwd,
4476
+ });
4477
+ return;
4478
+ }
4479
+
2146
4480
  if (!prompt) {
2147
- console.error('Error: task cannot be empty');
4481
+ // `[task...]` stays optional so hidden `--seq-continue` can omit a
4482
+ // positional; still mirror commander's required-arg error when argv
4483
+ // has no task parts at all (vs empty/whitespace → cannot be empty).
4484
+ const taskParts = Array.isArray(task) ? task : [];
4485
+ if (taskParts.length === 0) {
4486
+ console.error("error: missing required argument 'task'");
4487
+ } else {
4488
+ console.error('Error: task cannot be empty');
4489
+ }
4490
+ process.exit(1);
4491
+ return;
4492
+ }
4493
+
4494
+ if (options.seq && options.fanOut) {
4495
+ console.error('Error: --seq cannot be combined with --fan-out');
2148
4496
  process.exit(1);
2149
4497
  return;
2150
4498
  }
2151
4499
 
4500
+ if (options.seq) {
4501
+ const conflicts = ['ask', 'quick', 'dryRun']
4502
+ .filter((key) => options[key])
4503
+ .map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
4504
+ if (conflicts.length > 0) {
4505
+ console.error(`Error: --seq cannot be combined with ${conflicts.join(', ')}`);
4506
+ process.exit(1);
4507
+ return;
4508
+ }
4509
+ if (process.env.ORCH_SEQ_DEPTH || process.env.ORCH_FANOUT_DEPTH) {
4510
+ console.error('Error: --seq cannot be used inside a seq/fan-out child (ORCH_SEQ_DEPTH or ORCH_FANOUT_DEPTH is already set)');
4511
+ process.exit(1);
4512
+ return;
4513
+ }
4514
+
4515
+ if (options.detach) {
4516
+ await runDetached(prompt, {
4517
+ ...options,
4518
+ seq: true,
4519
+ maxUnits: options.maxUnits,
4520
+ notify: cliNotifyFromOptions(options),
4521
+ });
4522
+ return;
4523
+ }
4524
+
4525
+ const cwd = process.cwd();
4526
+ let slug = process.env.ORCH_JOB_SLUG;
4527
+ if (!slug) {
4528
+ const alloc = allocateJob({
4529
+ cwd,
4530
+ prompt,
4531
+ agent: options.agent,
4532
+ maxRounds: options.maxRounds,
4533
+ state: 'running',
4534
+ pid: process.pid,
4535
+ role: 'coordinator',
4536
+ });
4537
+ slug = alloc.slug;
4538
+ }
4539
+ setJobSlug(slug);
4540
+
4541
+ await runSeqPipeline(prompt, {
4542
+ ...options,
4543
+ cwd,
4544
+ jobSlug: slug,
4545
+ jobCwd: cwd,
4546
+ maxUnits: options.maxUnits,
4547
+ });
4548
+ return;
4549
+ }
4550
+
2152
4551
  if (options.fanOut) {
2153
4552
  const conflicts = ['ask', 'quick', 'dryRun']
2154
4553
  .filter((key) => options[key])
@@ -2158,8 +4557,8 @@ Fan-out:
2158
4557
  process.exit(1);
2159
4558
  return;
2160
4559
  }
2161
- if (process.env.ORCH_FANOUT_DEPTH) {
2162
- console.error('Error: --fan-out cannot be used inside a fan-out child (ORCH_FANOUT_DEPTH is already set)');
4560
+ if (process.env.ORCH_FANOUT_DEPTH || process.env.ORCH_SEQ_DEPTH) {
4561
+ console.error('Error: --fan-out cannot be used inside a fan-out/seq child (ORCH_FANOUT_DEPTH or ORCH_SEQ_DEPTH is already set)');
2163
4562
  process.exit(1);
2164
4563
  return;
2165
4564
  }
@@ -2187,8 +4586,8 @@ Fan-out:
2187
4586
  return;
2188
4587
  }
2189
4588
 
2190
- if (options.worker || options.integrate) {
2191
- const flagName = options.worker ? '--worker' : '--integrate';
4589
+ if (options.worker || options.integrate || options.unit) {
4590
+ const flagName = options.worker ? '--worker' : options.unit ? '--unit' : '--integrate';
2192
4591
  const conflicts = ['ask', 'quick', 'detach', 'dryRun']
2193
4592
  .filter((key) => options[key])
2194
4593
  .map((key) => `--${key === 'dryRun' ? 'dry-run' : key}`);
@@ -2200,6 +4599,8 @@ Fan-out:
2200
4599
 
2201
4600
  if (options.worker) {
2202
4601
  await runWorkerFromCli(options);
4602
+ } else if (options.unit) {
4603
+ await runUnitFromCli(options);
2203
4604
  } else {
2204
4605
  await runIntegrateFromCli(options);
2205
4606
  }
@@ -2215,7 +4616,10 @@ Fan-out:
2215
4616
  process.exit(1);
2216
4617
  return;
2217
4618
  }
2218
- await runDetached(prompt, options);
4619
+ await runDetached(prompt, {
4620
+ ...options,
4621
+ notify: cliNotifyFromOptions(options),
4622
+ });
2219
4623
  return;
2220
4624
  }
2221
4625
 
@@ -2235,10 +4639,205 @@ Fan-out:
2235
4639
  await runPipeline(prompt, options);
2236
4640
  });
2237
4641
 
4642
+ program
4643
+ .command('continue')
4644
+ .description(
4645
+ 'Start new complex work on a done run\'s existing worktree (not crash recovery — use orch resume for failed/stopped/crashed). Workers: continue the worker slug, then re-integrate the parent',
4646
+ )
4647
+ .argument('<slug>', 'Existing run slug under .orch/')
4648
+ .argument('<task...>', 'New task prompt for this continue iteration')
4649
+ .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
4650
+ .option('--dry-run', 'Validate eligibility and agent PATH; do not reopen the job or run agents')
4651
+ .option('--ask', 'Rejected: continue does not support --ask')
4652
+ .option('--quick', 'Rejected: continue does not support --quick')
4653
+ .option('--detach', 'Run the continue in the background under the same slug')
4654
+ .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
4655
+ .option('--notify', 'Enable desktop notification when the continue job reaches a terminal state')
4656
+ .option('--no-notify', 'Disable desktop notifications for this continue')
4657
+ .addOption(
4658
+ new Option('--agent <agent>', 'Agent backend: "cursor", "claude", "agn", or "opencode". Omitting uses local then global config, else cursor')
4659
+ .choices(['cursor', 'claude', 'agn', 'opencode']),
4660
+ )
4661
+ .action(async (slug, taskParts, options, command) => {
4662
+ // Parent program also defines --ask/--quick/--dry-run/--agent/--detach/
4663
+ // --max-rounds; when those flags appear after the continue arguments,
4664
+ // Commander attaches them to the parent. Merge via optsWithGlobals.
4665
+ const opts = typeof command.optsWithGlobals === 'function'
4666
+ ? command.optsWithGlobals()
4667
+ : { ...program.opts(), ...options };
4668
+ const prompt = taskParts.join(' ').trim();
4669
+ const cwd = process.cwd();
4670
+
4671
+ opts.agent = resolveAgentOrExit(opts.agent, cwd);
4672
+ applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
4673
+
4674
+ let record;
4675
+ try {
4676
+ record = validateContinue(cwd, slug, {
4677
+ task: prompt,
4678
+ ask: opts.ask,
4679
+ quick: opts.quick,
4680
+ });
4681
+ } catch (err) {
4682
+ console.error(`Error: ${err.message}`);
4683
+ process.exit(1);
4684
+ return;
4685
+ }
4686
+
4687
+ if (opts.detach) {
4688
+ await runContinueDetached(slug, prompt, {
4689
+ agent: opts.agent,
4690
+ maxRounds: opts.maxRounds,
4691
+ verbose: opts.verbose,
4692
+ cwd,
4693
+ notify: cliNotifyFromOptions(opts),
4694
+ });
4695
+ return;
4696
+ }
4697
+
4698
+ if (opts.dryRun) {
4699
+ const backend = AGENT_BACKENDS[opts.agent];
4700
+ if (!isBinaryOnPath(backend.binary)) {
4701
+ console.error(binaryMissingHint(opts.agent));
4702
+ process.exit(1);
4703
+ return;
4704
+ }
4705
+ console.log(`dry-run: continue ${slug} ok`);
4706
+ return;
4707
+ }
4708
+
4709
+ const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
4710
+ let priorOutcome;
4711
+ let continuation;
4712
+ let worktreePath = record.worktree;
4713
+ let branch = record.branch;
4714
+ let role = record.role;
4715
+ let parentSlug = record.parent;
4716
+ let workerId = record.workerId;
4717
+
4718
+ if (!alreadyReopened) {
4719
+ priorOutcome = snapshotPriorOutcome(cwd, slug, record);
4720
+ const updated = reopenJob(cwd, slug, {
4721
+ task: prompt,
4722
+ agent: opts.agent,
4723
+ maxRounds: opts.maxRounds,
4724
+ pid: process.pid,
4725
+ prior: priorOutcome,
4726
+ });
4727
+ continuation = updated.continuation;
4728
+ setJobSlug(slug);
4729
+ } else {
4730
+ const live = readJob(cwd, slug) ?? record;
4731
+ continuation = live.continuation ?? 2;
4732
+ const entries = Array.isArray(live.continuations) ? live.continuations : [];
4733
+ const last = entries[entries.length - 1];
4734
+ priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
4735
+ worktreePath = live.worktree ?? worktreePath;
4736
+ branch = live.branch ?? branch;
4737
+ role = live.role ?? role;
4738
+ parentSlug = live.parent ?? parentSlug;
4739
+ workerId = live.workerId ?? workerId;
4740
+ setJobSlug(slug);
4741
+ }
4742
+
4743
+ // PATH check after reopen (foreground) so empty-PATH tests can observe the bump.
4744
+ const backend = AGENT_BACKENDS[opts.agent];
4745
+ ensureBinaryOnPath(backend.binary, opts.agent);
4746
+
4747
+ await runContinuePipeline(prompt, {
4748
+ agent: opts.agent,
4749
+ maxRounds: opts.maxRounds,
4750
+ verbose: opts.verbose,
4751
+ cwd,
4752
+ slug,
4753
+ worktreePath,
4754
+ branch,
4755
+ role,
4756
+ parentSlug,
4757
+ workerId,
4758
+ priorOutcome,
4759
+ continuation,
4760
+ jobSlug: slug,
4761
+ jobCwd: cwd,
4762
+ });
4763
+ });
4764
+
4765
+ program
4766
+ .command('config')
4767
+ .description('Print or set default agent / notify (global ~/.orch/config or local .orch/config)')
4768
+ .addOption(
4769
+ new Option('--agent <agent>', 'Set the default agent backend')
4770
+ .choices(['cursor', 'claude', 'agn', 'opencode']),
4771
+ )
4772
+ .option('--notify', 'Set notify: true in the target config')
4773
+ .option('--no-notify', 'Set notify: false in the target config')
4774
+ .option('--global', 'Write the global config (~/.orch/config); default when setting a value')
4775
+ .option('--local', 'Write the project-local config (.orch/config)')
4776
+ .action((options, command) => {
4777
+ // Parent also defines --agent/--notify/--no-notify; flags after `config`
4778
+ // may land on the parent. Merge so either placement works.
4779
+ const opts = typeof command.optsWithGlobals === 'function'
4780
+ ? command.optsWithGlobals()
4781
+ : { ...program.opts(), ...options };
4782
+ const cwd = process.cwd();
4783
+
4784
+ if (opts.global && opts.local) {
4785
+ console.error('Error: --global and --local are mutually exclusive');
4786
+ process.exit(1);
4787
+ return;
4788
+ }
4789
+
4790
+ const hasNotify = process.argv.includes('--notify');
4791
+ const hasNoNotify = process.argv.includes('--no-notify');
4792
+ if (hasNotify && hasNoNotify) {
4793
+ console.error('Error: --notify and --no-notify are mutually exclusive');
4794
+ process.exit(1);
4795
+ return;
4796
+ }
4797
+
4798
+ const settingNotify = hasNotify || hasNoNotify;
4799
+ const settingAgent = Boolean(opts.agent);
4800
+ const writing = settingAgent || settingNotify;
4801
+
4802
+ if ((opts.global || opts.local) && !writing) {
4803
+ console.error('Error: --global/--local require --agent, --notify, or --no-notify (omit flags to print config)');
4804
+ process.exit(1);
4805
+ return;
4806
+ }
4807
+
4808
+ if (writing) {
4809
+ const targetPath = opts.local
4810
+ ? localConfigPath(cwd)
4811
+ : globalConfigPath();
4812
+ const patch = {};
4813
+ if (settingAgent) patch.agent = opts.agent;
4814
+ if (hasNotify) patch.notify = true;
4815
+ if (hasNoNotify) patch.notify = false;
4816
+ try {
4817
+ writeConfig(targetPath, patch);
4818
+ } catch (err) {
4819
+ console.error(`Error: ${err.message}`);
4820
+ process.exit(1);
4821
+ return;
4822
+ }
4823
+ console.log(`wrote ${targetPath}`);
4824
+ process.stdout.write(`${JSON.stringify(patch, null, 2)}\n`);
4825
+ return;
4826
+ }
4827
+
4828
+ try {
4829
+ printConfig({ cwd });
4830
+ } catch (err) {
4831
+ console.error(`Error: ${err.message}`);
4832
+ process.exit(1);
4833
+ }
4834
+ });
4835
+
2238
4836
  program
2239
4837
  .command('list')
2240
4838
  .description('List all runs (active and finished) tracked under .orch/ in this directory')
2241
4839
  .action(() => {
4840
+ applyNotifyEnabled({});
2242
4841
  const jobs = listJobs(process.cwd());
2243
4842
  if (jobs.length === 0) {
2244
4843
  console.log('no runs');
@@ -2253,6 +4852,7 @@ program
2253
4852
  .description('Show full status for a run')
2254
4853
  .action((slug) => {
2255
4854
  const cwd = process.cwd();
4855
+ applyNotifyEnabled({}, { cwd });
2256
4856
  let record;
2257
4857
  if (slug) {
2258
4858
  record = readJob(cwd, slug);
@@ -2299,23 +4899,162 @@ program
2299
4899
  program
2300
4900
  .command('resume')
2301
4901
  .argument('<slug>', 'Run slug to resume')
2302
- .description('Resume a paused (or pausing) job')
2303
- .action((slug) => {
4902
+ .description(
4903
+ 'Unpause a live paused/pausing job, or recover a failed/stopped/crashed complex job at its unfinished stage',
4904
+ )
4905
+ .option('-v, --verbose', 'Stream agent thinking/output deltas to stderr as the pipeline runs')
4906
+ .option('--dry-run', 'Validate eligibility only; do not unpause, reopen, or run agents')
4907
+ .option('--ask', 'Rejected: resume does not support --ask')
4908
+ .option('--quick', 'Rejected: resume does not support --quick')
4909
+ .option('--detach', 'Run failure resume in the background under the same slug')
4910
+ .option('--max-rounds <n>', 'Max writer⇄critic and writer⇄runner iterations per implementer loop', positiveIntParser('--max-rounds'), 5)
4911
+ .addOption(
4912
+ new Option('--agent <agent>', 'Agent backend: "cursor", "claude", "agn", or "opencode". Omitting uses local then global config, else cursor')
4913
+ .choices(['cursor', 'claude', 'agn', 'opencode']),
4914
+ )
4915
+ .action(async (slug, options, command) => {
4916
+ const opts = typeof command.optsWithGlobals === 'function'
4917
+ ? command.optsWithGlobals()
4918
+ : { ...program.opts(), ...options };
2304
4919
  const cwd = process.cwd();
4920
+ opts.agent = resolveAgentOrExit(opts.agent, cwd);
4921
+ applyNotifyEnabled(opts, { cwd, dryRun: Boolean(opts.dryRun) });
4922
+
4923
+ let validated;
2305
4924
  try {
2306
- const record = readJob(cwd, slug);
2307
- if (!record) throw new Error(`requestResume: unknown job ${slug}`);
2308
- if (isCascadeParent(cwd, record)) {
2309
- cascadeResume(cwd, slug);
2310
- console.log(`resumed ${slug}`);
2311
- } else {
2312
- requestResume(cwd, slug);
2313
- console.log(`resumed ${slug}`);
2314
- }
4925
+ validated = validateResume(cwd, slug, {
4926
+ ask: opts.ask,
4927
+ quick: opts.quick,
4928
+ });
2315
4929
  } catch (err) {
2316
4930
  console.error(`Error: ${err.message}`);
2317
4931
  process.exit(1);
4932
+ return;
4933
+ }
4934
+
4935
+ const { mode, record } = validated;
4936
+
4937
+ if (opts.dryRun) {
4938
+ if (mode === 'failure') {
4939
+ const backend = AGENT_BACKENDS[opts.agent];
4940
+ if (!isBinaryOnPath(backend.binary)) {
4941
+ console.error(binaryMissingHint(opts.agent));
4942
+ process.exit(1);
4943
+ return;
4944
+ }
4945
+ }
4946
+ console.log(`dry-run: resume ${slug} ok (${mode})`);
4947
+ return;
4948
+ }
4949
+
4950
+ if (mode === 'unpause') {
4951
+ try {
4952
+ if (isCascadeParent(cwd, record)) {
4953
+ cascadeResume(cwd, slug);
4954
+ console.log(`resumed ${slug}`);
4955
+ } else {
4956
+ requestResume(cwd, slug);
4957
+ console.log(`resumed ${slug}`);
4958
+ }
4959
+ } catch (err) {
4960
+ console.error(`Error: ${err.message}`);
4961
+ process.exit(1);
4962
+ }
4963
+ return;
4964
+ }
4965
+
4966
+ if (mode === 'noop') {
4967
+ console.log(`resumed ${slug}`);
4968
+ return;
4969
+ }
4970
+
4971
+ // mode === 'failure'
4972
+ if (opts.detach) {
4973
+ await runResumeDetached(slug, {
4974
+ agent: opts.agent,
4975
+ maxRounds: opts.maxRounds,
4976
+ verbose: opts.verbose,
4977
+ cwd,
4978
+ notify: cliNotifyFromOptions(opts),
4979
+ });
4980
+ return;
4981
+ }
4982
+
4983
+ const alreadyReopened = Boolean(process.env.ORCH_JOB_SLUG);
4984
+ let priorOutcome;
4985
+ let worktreePath = record.worktree;
4986
+ let branch = record.branch;
4987
+ let role = record.role;
4988
+ let parentSlug = record.parent;
4989
+ let workerId = record.workerId;
4990
+ let recoverBrief = '';
4991
+
4992
+ if (!alreadyReopened) {
4993
+ priorOutcome = snapshotPriorOutcome(cwd, slug, record);
4994
+ reopenForResume(cwd, slug, {
4995
+ agent: opts.agent,
4996
+ maxRounds: opts.maxRounds,
4997
+ pid: process.pid,
4998
+ prior: priorOutcome,
4999
+ });
5000
+ setJobSlug(slug);
5001
+ const recovered = runRecover(cwd, slug, {
5002
+ prior: priorOutcome,
5003
+ worktreePath,
5004
+ });
5005
+ console.log(`orch: [recover] ${recovered.oneLiner}`);
5006
+ recoverBrief = recovered.brief;
5007
+ } else {
5008
+ const live = readJob(cwd, slug) ?? record;
5009
+ const entries = Array.isArray(live.resumes) ? live.resumes : [];
5010
+ const last = entries[entries.length - 1];
5011
+ priorOutcome = last?.prior ?? snapshotPriorOutcome(cwd, slug, live);
5012
+ worktreePath = live.worktree ?? worktreePath;
5013
+ branch = live.branch ?? branch;
5014
+ role = live.role ?? role;
5015
+ parentSlug = live.parent ?? parentSlug;
5016
+ workerId = live.workerId ?? workerId;
5017
+ setJobSlug(slug);
5018
+ const recoverPath = path.join(jobPaths(cwd, slug).dir, 'recover.md');
5019
+ if (!fs.existsSync(recoverPath)) {
5020
+ const recovered = runRecover(cwd, slug, {
5021
+ prior: priorOutcome,
5022
+ worktreePath,
5023
+ });
5024
+ console.log(`orch: [recover] ${recovered.oneLiner}`);
5025
+ recoverBrief = recovered.brief;
5026
+ } else {
5027
+ recoverBrief = fs.readFileSync(recoverPath, 'utf8');
5028
+ const orient = recoverBrief.match(/^- Orientation: (.+)$/m)
5029
+ || recoverBrief.match(/^([^\n]+)$/m);
5030
+ console.log(`orch: [recover] ${orient?.[1] ?? 'resuming from recover.md'}`);
5031
+ }
5032
+ }
5033
+
5034
+ const backend = AGENT_BACKENDS[opts.agent];
5035
+ if (!isBinaryOnPath(backend.binary)) {
5036
+ console.error(binaryMissingHint(opts.agent));
5037
+ process.exit(1);
5038
+ return;
2318
5039
  }
5040
+
5041
+ console.log(`resumed ${slug} (pid ${process.pid})`);
5042
+ await runResumePipeline({
5043
+ agent: opts.agent,
5044
+ maxRounds: opts.maxRounds,
5045
+ verbose: opts.verbose,
5046
+ cwd,
5047
+ slug,
5048
+ jobSlug: slug,
5049
+ priorOutcome,
5050
+ recoverBrief,
5051
+ worktreePath,
5052
+ branch,
5053
+ role,
5054
+ parentSlug,
5055
+ workerId,
5056
+ prompt: priorOutcome?.task ?? record.task,
5057
+ });
2319
5058
  });
2320
5059
 
2321
5060
  program
@@ -2324,6 +5063,7 @@ program
2324
5063
  .description('Send SIGTERM to a running job (or reconcile a dead one to crashed)')
2325
5064
  .action((slug) => {
2326
5065
  const cwd = process.cwd();
5066
+ applyNotifyEnabled({}, { cwd });
2327
5067
  try {
2328
5068
  const record = readJob(cwd, slug);
2329
5069
  if (!record) throw new Error(`stopJob: unknown job ${slug}`);
@@ -2352,7 +5092,7 @@ const jobsCmd = program
2352
5092
 
2353
5093
  jobsCmd
2354
5094
  .command('clean')
2355
- .description('Delete all orch jobs from the .orch folder')
5095
+ .description('Delete all orch jobs from .orch/ (refuses while live jobs are running; orch stop <slug> first)')
2356
5096
  .action(async () => {
2357
5097
  const cwd = process.cwd();
2358
5098
  const orchDir = path.join(cwd, '.orch');
@@ -2361,6 +5101,17 @@ jobsCmd
2361
5101
  return;
2362
5102
  }
2363
5103
 
5104
+ // Refuse before prompting so we never ask to wipe dirs we will not delete.
5105
+ const live = liveSlugsBlockingClean(cwd);
5106
+ if (live.length > 0) {
5107
+ console.error(
5108
+ `Error: cannot clean while live jobs exist: ${live.join(', ')}. ` +
5109
+ `Stop them first with: orch stop <slug>`,
5110
+ );
5111
+ process.exit(1);
5112
+ return;
5113
+ }
5114
+
2364
5115
  const rl = readline.createInterface({ input, output });
2365
5116
  let answer;
2366
5117
  try {
@@ -2374,8 +5125,13 @@ jobsCmd
2374
5125
  return;
2375
5126
  }
2376
5127
 
2377
- const removed = cleanJobs(cwd);
2378
- console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
5128
+ try {
5129
+ const removed = cleanJobs(cwd);
5130
+ console.log(`deleted ${removed.length} job${removed.length === 1 ? '' : 's'} from .orch/`);
5131
+ } catch (err) {
5132
+ console.error(`Error: ${err.message}`);
5133
+ process.exit(1);
5134
+ }
2379
5135
  });
2380
5136
 
2381
5137
  program