nightralph 0.0.2 → 0.0.4

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.
Files changed (93) hide show
  1. package/README.md +113 -34
  2. package/dist/index.js +80 -22
  3. package/dist/index.js.map +2 -2
  4. package/dist/merge.js +194 -0
  5. package/dist/merge.js.map +7 -0
  6. package/dist/meta.json +189 -17
  7. package/dist/orchestrator.js +538 -45
  8. package/dist/orchestrator.js.map +2 -2
  9. package/dist/progress.js +164 -0
  10. package/dist/progress.js.map +7 -0
  11. package/dist/resolve.js +16 -8
  12. package/dist/resolve.js.map +2 -2
  13. package/dist/setup.js +33 -5
  14. package/dist/setup.js.map +2 -2
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/merge.d.ts +25 -0
  17. package/dist/src/merge.d.ts.map +1 -0
  18. package/dist/src/orchestrator.d.ts +29 -3
  19. package/dist/src/orchestrator.d.ts.map +1 -1
  20. package/dist/src/progress.d.ts +27 -0
  21. package/dist/src/progress.d.ts.map +1 -0
  22. package/dist/src/resolve.d.ts +2 -1
  23. package/dist/src/resolve.d.ts.map +1 -1
  24. package/dist/src/setup.d.ts +1 -0
  25. package/dist/src/setup.d.ts.map +1 -1
  26. package/dist/src/worktree.d.ts +18 -0
  27. package/dist/src/worktree.d.ts.map +1 -0
  28. package/dist/worktree.js +139 -0
  29. package/dist/worktree.js.map +7 -0
  30. package/package.json +8 -4
  31. package/.claude/settings.local.json +0 -5
  32. package/.github/dependabot.yml +0 -28
  33. package/.github/workflows/nodejs.yml +0 -30
  34. package/AGENTS.md +0 -60
  35. package/CLAUDE.md +0 -1
  36. package/PROMPT.md +0 -95
  37. package/WIP.md +0 -5
  38. package/dist/test/integration.test.js +0 -848
  39. package/dist/test/integration.test.js.map +0 -7
  40. package/dist/test/orchestrator.test.js +0 -749
  41. package/dist/test/orchestrator.test.js.map +0 -7
  42. package/dist/test/setup.test.js +0 -574
  43. package/dist/test/setup.test.js.map +0 -7
  44. package/docs/README.md +0 -12
  45. package/docs/agents/domain.md +0 -36
  46. package/docs/agents/issue-tracker.md +0 -30
  47. package/docs/agents/triage-labels.md +0 -15
  48. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase-2-future-work.md +0 -144
  49. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_01.md +0 -292
  50. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/phase_02.md +0 -212
  51. package/docs/implementation-plans/2026-08-27-issue-tracker-choice/test-requirements.md +0 -203
  52. package/docs/skills.md +0 -58
  53. package/docs/test-plans/2026-08-27-issue-tracker-choice.md +0 -95
  54. package/eslint.config.js +0 -142
  55. package/lib.es5.d.ts +0 -19
  56. package/progress.log +0 -190
  57. package/ralph_claude.sh +0 -263
  58. package/scripts/clone-skills.sh +0 -52
  59. package/specs/prd.json +0 -172
  60. package/src/docs-templates/domain.md +0 -36
  61. package/src/docs-templates/issue-tracker-github.md +0 -228
  62. package/src/docs-templates/issue-tracker.md +0 -30
  63. package/src/docs-templates/triage-labels.md +0 -15
  64. package/src/index.ts +0 -184
  65. package/src/orchestrator.ts +0 -324
  66. package/src/resolve.ts +0 -73
  67. package/src/setup.ts +0 -157
  68. package/src/skills/domain-modeling/ADR-FORMAT.md +0 -47
  69. package/src/skills/domain-modeling/CONTEXT-FORMAT.md +0 -60
  70. package/src/skills/domain-modeling/SKILL.md +0 -74
  71. package/src/skills/domain-modeling/agents/openai.yaml +0 -3
  72. package/src/skills/grill/SKILL.md +0 -7
  73. package/src/skills/grill/agents/openai.yaml +0 -5
  74. package/src/skills/grilling/SKILL.md +0 -28
  75. package/src/skills/grilling/agents/openai.yaml +0 -3
  76. package/src/skills/tdd/SKILL.md +0 -38
  77. package/src/skills/tdd/agents/openai.yaml +0 -3
  78. package/src/skills/tdd/mocking.md +0 -59
  79. package/src/skills/tdd/tests.md +0 -77
  80. package/src/skills/to-spec/SKILL.md +0 -75
  81. package/src/skills/to-spec/agents/openai.yaml +0 -5
  82. package/src/skills/to-tickets/SKILL.md +0 -105
  83. package/src/skills/to-tickets/agents/openai.yaml +0 -5
  84. package/src/skills/upstream.json +0 -5
  85. package/tasks/prd-integration-test-coverage.md +0 -243
  86. package/test/integration.test.ts +0 -636
  87. package/test/mock-agent-echo-args.js +0 -4
  88. package/test/mock-agent-fail.js +0 -3
  89. package/test/mock-agent-pass.js +0 -3
  90. package/test/orchestrator.test.ts +0 -609
  91. package/test/setup.test.ts +0 -417
  92. package/tsconfig.build.json +0 -7
  93. package/tsconfig.json +0 -31
@@ -8,7 +8,30 @@ import {
8
8
  writeFileSync,
9
9
  readdirSync
10
10
  } from "node:fs";
11
- import { join } from "node:path";
11
+ import { join, dirname, basename } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import {
14
+ getRepoRoot,
15
+ getRepoName,
16
+ getCurrentBranch,
17
+ createWorktree,
18
+ removeWorktree,
19
+ hasNewCommits,
20
+ isDirty
21
+ } from "./worktree.js";
22
+ import {
23
+ generateProgress,
24
+ readProgress,
25
+ mergeProgress,
26
+ writeProgress,
27
+ commitProgress,
28
+ checkTicket
29
+ } from "./progress.js";
30
+ import {
31
+ mergeBranch,
32
+ mergeWave,
33
+ updateWorktreeFromBase
34
+ } from "./merge.js";
12
35
  function formatErrorMessage(error) {
13
36
  return error instanceof Error ? error.message : String(error);
14
37
  }
@@ -63,7 +86,7 @@ function scanTickets(dir) {
63
86
  __name(scanTickets, "scanTickets");
64
87
  function findReadyTickets(tickets) {
65
88
  const doneNums = new Set(
66
- tickets.filter((t) => t.status !== "ready-for-agent").map((t) => t.num)
89
+ tickets.filter((t) => t.status === "done").map((t) => t.num)
67
90
  );
68
91
  return tickets.filter((t) => {
69
92
  if (t.status !== "ready-for-agent") return false;
@@ -96,38 +119,178 @@ function renderPrompt(specBody, ticket) {
96
119
  ].join("\n");
97
120
  }
98
121
  __name(renderPrompt, "renderPrompt");
122
+ function listTickets(tickets) {
123
+ console.log("\nTickets:");
124
+ for (const t of tickets) {
125
+ const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.join(", ")}]` : "no blockers";
126
+ console.log(
127
+ ` ${t.filename} (${t.status}) ${blockerStr}`
128
+ );
129
+ }
130
+ }
131
+ __name(listTickets, "listTickets");
132
+ function* simulateWaves(tickets) {
133
+ const sim = new Map(
134
+ tickets.map((t) => [t.num, t.status])
135
+ );
136
+ let wave = 1;
137
+ while (true) {
138
+ const doneNums = new Set(
139
+ [...sim.entries()].filter(([, s]) => s === "done").map(([n]) => n)
140
+ );
141
+ const ready = [...tickets].filter((t) => {
142
+ if (sim.get(t.num) !== "ready-for-agent") {
143
+ return false;
144
+ }
145
+ return t.blockers.every(
146
+ (b) => doneNums.has(b)
147
+ );
148
+ });
149
+ if (ready.length === 0) break;
150
+ yield { wave, ready };
151
+ for (const t of ready) {
152
+ sim.set(t.num, "done");
153
+ }
154
+ wave++;
155
+ }
156
+ }
157
+ __name(simulateWaves, "simulateWaves");
158
+ function detectDeadlocks(tickets) {
159
+ const doneNums = /* @__PURE__ */ new Set();
160
+ const ready = /* @__PURE__ */ new Set();
161
+ for (const t of tickets) {
162
+ if (t.status === "ready-for-agent") {
163
+ ready.add(t.num);
164
+ } else if (t.status === "done") {
165
+ doneNums.add(t.num);
166
+ }
167
+ }
168
+ let changed = true;
169
+ while (changed) {
170
+ changed = false;
171
+ for (const num of ready) {
172
+ const ticket = tickets.find(
173
+ (t) => t.num === num
174
+ );
175
+ if (ticket.blockers.every(
176
+ (b) => doneNums.has(b)
177
+ )) {
178
+ doneNums.add(num);
179
+ ready.delete(num);
180
+ changed = true;
181
+ }
182
+ }
183
+ }
184
+ return [...ready].map(
185
+ (num) => tickets.find((t) => t.num === num)
186
+ );
187
+ }
188
+ __name(detectDeadlocks, "detectDeadlocks");
189
+ const PROVIDER_ARGS = {
190
+ claude: [
191
+ "--print",
192
+ "--verbose",
193
+ "--output-format",
194
+ "stream-json",
195
+ "--dangerously-skip-permissions"
196
+ ],
197
+ codex: ["exec", "--full-auto"]
198
+ };
199
+ function getProviderArgs(cmd) {
200
+ const name = basename(cmd);
201
+ return PROVIDER_ARGS[name] ?? [];
202
+ }
203
+ __name(getProviderArgs, "getProviderArgs");
204
+ function formatStreamLine(line) {
205
+ let obj;
206
+ try {
207
+ obj = JSON.parse(line);
208
+ } catch {
209
+ return line;
210
+ }
211
+ if (obj.type === "assistant") {
212
+ const msg = obj.message;
213
+ const content = msg?.content;
214
+ if (!content) return null;
215
+ for (const block of content) {
216
+ if (block.type === "text") {
217
+ return String(block.text);
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+ if (obj.type === "result") {
223
+ const sub = obj.subtype ?? "done";
224
+ const cost = obj.total_cost_usd;
225
+ const costStr = cost != null ? ` ($${cost.toFixed(2)})` : "";
226
+ return `${sub}${costStr}`;
227
+ }
228
+ if (obj.type === "system") {
229
+ const sub = obj.subtype;
230
+ if (sub === "api_retry") {
231
+ const error = obj.error;
232
+ return `retry: ${error ?? "unknown"}`;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+ __name(formatStreamLine, "formatStreamLine");
99
238
  function spawnAgent(opts) {
100
- const args = [];
239
+ const args = [
240
+ ...getProviderArgs(opts.agentCmd)
241
+ ];
101
242
  if (opts.model) {
102
243
  args.push("--model", opts.model);
103
244
  }
245
+ const isClaude = basename(opts.agentCmd) === "claude";
246
+ const prefix = opts.label ? ` [${opts.label}] ` : " ";
104
247
  const logStream = createWriteStream(
105
248
  opts.logPath,
106
249
  { flags: "w" }
107
250
  );
108
251
  const proc = spawn(opts.agentCmd, args, {
109
- stdio: ["pipe", "pipe", "pipe"]
252
+ stdio: ["pipe", "pipe", "pipe"],
253
+ cwd: opts.cwd
110
254
  });
111
255
  proc.stdout.pipe(logStream);
256
+ const rl = createInterface({ input: proc.stdout });
257
+ rl.on("line", (line) => {
258
+ if (isClaude) {
259
+ const formatted = formatStreamLine(line);
260
+ if (formatted !== null) {
261
+ console.log(`${prefix}${formatted}`);
262
+ }
263
+ } else {
264
+ console.log(`${prefix}${line}`);
265
+ }
266
+ });
112
267
  proc.stderr.pipe(logStream);
268
+ const rlErr = createInterface({ input: proc.stderr });
269
+ rlErr.on("line", (line) => {
270
+ console.error(`${prefix}${line}`);
271
+ });
113
272
  const timeout = setTimeout(() => {
114
273
  if (!proc.killed) {
115
274
  console.warn(
116
- `Agent did not exit in ${opts.timeoutSecs}s; killing.`
275
+ `Agent did not exit in ${opts.timeout}s; killing.`
117
276
  );
118
277
  proc.kill("SIGKILL");
119
278
  }
120
- }, opts.timeoutSecs * 1e3);
279
+ }, opts.timeout * 1e3);
121
280
  proc.stdin.write(opts.prompt);
122
281
  proc.stdin.end();
123
282
  return new Promise((resolve) => {
124
283
  proc.on("close", (code) => {
125
284
  clearTimeout(timeout);
285
+ rl.close();
286
+ rlErr.close();
126
287
  logStream.end();
127
288
  resolve(code ?? 1);
128
289
  });
129
290
  proc.on("error", (err) => {
130
291
  clearTimeout(timeout);
292
+ rl.close();
293
+ rlErr.close();
131
294
  logStream.end();
132
295
  console.error(
133
296
  "Agent process error:",
@@ -139,48 +302,56 @@ function spawnAgent(opts) {
139
302
  }
140
303
  __name(spawnAgent, "spawnAgent");
141
304
  function dryRun(tickets, specBody) {
142
- console.log("\nTickets:");
143
- for (const t of tickets) {
144
- const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.join(", ")}]` : "no blockers";
305
+ listTickets(tickets);
306
+ console.log("\nSimulated wave order:");
307
+ for (const { wave, ready } of simulateWaves(tickets)) {
145
308
  console.log(
146
- ` ${t.filename} (${t.status}) ${blockerStr}`
309
+ ` Wave ${wave}: ` + ready.map((t) => t.filename).join(", ")
147
310
  );
148
311
  }
149
- console.log("\nSimulated wave order:");
150
- const sim = new Map(
151
- tickets.map((t) => [t.num, t.status])
152
- );
153
- let wave = 1;
154
- while (true) {
155
- const doneNums = new Set(
156
- [...sim.entries()].filter(([, s]) => s !== "ready-for-agent").map(([n]) => n)
312
+ const stuck = detectDeadlocks(tickets);
313
+ if (stuck.length > 0) {
314
+ console.log(
315
+ "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
157
316
  );
158
- const ready = tickets.filter((t) => {
159
- if (sim.get(t.num) !== "ready-for-agent") {
160
- return false;
161
- }
162
- return t.blockers.every(
163
- (b) => doneNums.has(b)
164
- );
165
- });
166
- if (ready.length === 0) break;
317
+ }
318
+ const firstReady = findReadyTickets(tickets);
319
+ if (firstReady.length > 0) {
320
+ console.log(
321
+ `
322
+ Prompt for first ready ticket (${firstReady[0].filename}):
323
+ `
324
+ );
325
+ console.log(
326
+ renderPrompt(specBody, firstReady[0])
327
+ );
328
+ }
329
+ }
330
+ __name(dryRun, "dryRun");
331
+ function dryRunIsolated(opts) {
332
+ listTickets(opts.tickets);
333
+ console.log("\nSimulated wave order:");
334
+ for (const { wave, ready } of simulateWaves(
335
+ opts.tickets
336
+ )) {
167
337
  console.log(
168
338
  ` Wave ${wave}: ` + ready.map((t) => t.filename).join(", ")
169
339
  );
170
340
  for (const t of ready) {
171
- sim.set(t.num, "done");
341
+ const num = String(t.num).padStart(2, "0");
342
+ const branchName = `${opts.repoName}/${num}-${t.slug}`;
343
+ const worktreePath = `../${opts.repoName}.${num}-${t.slug}`;
344
+ console.log(` worktree: ${worktreePath}`);
345
+ console.log(` branch: ${branchName}`);
172
346
  }
173
- wave++;
174
347
  }
175
- const stuck = tickets.filter(
176
- (t) => sim.get(t.num) === "ready-for-agent"
177
- );
348
+ const stuck = detectDeadlocks(opts.tickets);
178
349
  if (stuck.length > 0) {
179
350
  console.log(
180
351
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
181
352
  );
182
353
  }
183
- const firstReady = findReadyTickets(tickets);
354
+ const firstReady = findReadyTickets(opts.tickets);
184
355
  if (firstReady.length > 0) {
185
356
  console.log(
186
357
  `
@@ -188,12 +359,62 @@ Prompt for first ready ticket (${firstReady[0].filename}):
188
359
  `
189
360
  );
190
361
  console.log(
191
- renderPrompt(specBody, firstReady[0])
362
+ renderPrompt(opts.specBody, firstReady[0])
192
363
  );
193
364
  }
365
+ const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
366
+ console.log(
367
+ `
368
+ Merge strategy: ${strategyDesc}`
369
+ );
370
+ console.log(`Progress file: ${opts.progressPath}`);
194
371
  }
195
- __name(dryRun, "dryRun");
196
- async function runWaves(tickets, specBody, agentCmd, model, timeoutSecs, logsDir) {
372
+ __name(dryRunIsolated, "dryRunIsolated");
373
+ async function respawnAndRetryMerge(opts) {
374
+ await updateWorktreeFromBase(
375
+ opts.worktreePath,
376
+ opts.baseBranch
377
+ );
378
+ console.log(
379
+ ` ${opts.ticket.filename}: respawning agent to resolve merge conflict`
380
+ );
381
+ const prompt = renderPrompt(opts.specBody, opts.ticket);
382
+ const logPath = join(
383
+ opts.logsDir,
384
+ opts.ticket.filename.replace(/\.md$/, ".respawn.log")
385
+ );
386
+ const label = opts.ticket.filename.replace(
387
+ /\.md$/,
388
+ ""
389
+ );
390
+ const exitCode = await spawnAgent({
391
+ prompt,
392
+ agentCmd: opts.agentCmd,
393
+ model: opts.model,
394
+ timeout: opts.timeout,
395
+ logPath,
396
+ cwd: opts.worktreePath,
397
+ label
398
+ });
399
+ if (exitCode !== 0) {
400
+ console.log(
401
+ ` ${opts.ticket.filename}: respawned agent exited ${exitCode}`
402
+ );
403
+ return false;
404
+ }
405
+ const merged = await mergeBranch(
406
+ opts.repoRoot,
407
+ opts.branch
408
+ );
409
+ if (!merged) {
410
+ console.log(
411
+ ` ${opts.ticket.filename}: merge still conflicted after respawn`
412
+ );
413
+ }
414
+ return merged;
415
+ }
416
+ __name(respawnAndRetryMerge, "respawnAndRetryMerge");
417
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir) {
197
418
  mkdirSync(logsDir, { recursive: true });
198
419
  let totalCompleted = 0;
199
420
  let waveNum = 0;
@@ -212,13 +433,18 @@ Wave ${waveNum}: ` + ready.map((t) => t.filename).join(", ")
212
433
  logsDir,
213
434
  t.filename.replace(/\.md$/, ".log")
214
435
  );
436
+ const label = t.filename.replace(
437
+ /\.md$/,
438
+ ""
439
+ );
215
440
  console.log(` Starting ${t.filename}`);
216
441
  const code = await spawnAgent({
217
442
  prompt,
218
443
  agentCmd,
219
444
  model,
220
- timeoutSecs,
221
- logPath
445
+ timeout,
446
+ logPath,
447
+ label
222
448
  });
223
449
  return { ticket: t, exitCode: code };
224
450
  })
@@ -227,17 +453,279 @@ Wave ${waveNum}: ` + ready.map((t) => t.filename).join(", ")
227
453
  for (const r of results) {
228
454
  if (r.status === "rejected") continue;
229
455
  const { ticket, exitCode } = r.value;
230
- if (exitCode === 0) {
231
- markDone(ticket);
232
- waveCompleted++;
233
- totalCompleted++;
456
+ if (exitCode !== 0) {
457
+ console.log(
458
+ ` ${ticket.filename}: agent exited ${exitCode}`
459
+ );
460
+ continue;
461
+ }
462
+ markDone(ticket);
463
+ waveCompleted++;
464
+ totalCompleted++;
465
+ console.log(
466
+ ` ${ticket.filename}: done`
467
+ );
468
+ }
469
+ if (waveCompleted === 0) {
470
+ console.log(
471
+ "\nNo tickets completed this wave. Stopping."
472
+ );
473
+ break;
474
+ }
475
+ }
476
+ const remaining = tickets.filter(
477
+ (t) => t.status === "ready-for-agent"
478
+ );
479
+ console.log(
480
+ `
481
+ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
482
+ );
483
+ return { allDone: remaining.length === 0 };
484
+ }
485
+ __name(runWaves, "runWaves");
486
+ async function runWavesIsolated(opts) {
487
+ mkdirSync(opts.logsDir, { recursive: true });
488
+ const repoRoot = getRepoRoot();
489
+ const repoName = getRepoName();
490
+ const baseBranch = getCurrentBranch();
491
+ const progressPath = join(
492
+ dirname(opts.logsDir),
493
+ "progress.md"
494
+ );
495
+ const progress = mergeProgress(
496
+ generateProgress(opts.featureName, opts.tickets),
497
+ readProgress(progressPath)
498
+ );
499
+ writeProgress(progressPath, progress);
500
+ await commitProgress(
501
+ repoRoot,
502
+ progressPath,
503
+ `nightralph: start ${opts.featureName}`
504
+ );
505
+ let totalCompleted = 0;
506
+ let waveNum = 0;
507
+ while (true) {
508
+ const ready = findReadyTickets(opts.tickets);
509
+ if (ready.length === 0) break;
510
+ waveNum++;
511
+ console.log(
512
+ `
513
+ Wave ${waveNum}: ` + ready.map((t) => t.filename).join(", ")
514
+ );
515
+ const worktreeSettled = await Promise.allSettled(
516
+ ready.map((t) => createWorktree({
517
+ repoRoot,
518
+ repoName,
519
+ baseBranch,
520
+ ticket: t
521
+ }))
522
+ );
523
+ const activeTickets = [];
524
+ const worktrees = [];
525
+ for (let i = 0; i < worktreeSettled.length; i++) {
526
+ const settled = worktreeSettled[i];
527
+ const ticket = ready[i];
528
+ if (settled.status === "rejected") {
234
529
  console.log(
235
- ` ${ticket.filename}: done`
530
+ ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(settled.reason)}`
531
+ );
532
+ continue;
533
+ }
534
+ activeTickets.push(ticket);
535
+ worktrees.push(settled.value);
536
+ }
537
+ const results = await Promise.allSettled(
538
+ activeTickets.map((t, i) => {
539
+ const prompt = renderPrompt(opts.specBody, t);
540
+ const logPath = join(
541
+ opts.logsDir,
542
+ t.filename.replace(/\.md$/, ".log")
236
543
  );
544
+ const label = t.filename.replace(
545
+ /\.md$/,
546
+ ""
547
+ );
548
+ console.log(` Starting ${t.filename}`);
549
+ return spawnAgent({
550
+ prompt,
551
+ agentCmd: opts.agentCmd,
552
+ model: opts.model,
553
+ timeout: opts.timeout,
554
+ logPath,
555
+ cwd: worktrees[i].worktreePath,
556
+ label
557
+ });
558
+ })
559
+ );
560
+ let waveCompleted = 0;
561
+ const successfulBranches = [];
562
+ const ticketsByNum = /* @__PURE__ */ new Map();
563
+ const worktreePathsByNum = /* @__PURE__ */ new Map();
564
+ for (let i = 0; i < results.length; i++) {
565
+ const r = results[i];
566
+ if (r.status === "rejected") continue;
567
+ const exitCode = r.value;
568
+ const ticket = activeTickets[i];
569
+ const worktree = worktrees[i];
570
+ if (exitCode === 0 && !hasNewCommits(
571
+ repoRoot,
572
+ baseBranch,
573
+ worktree.branchName
574
+ )) {
575
+ console.log(
576
+ ` ${ticket.filename}: agent exited 0 but made no commits; leaving ticket ready-for-agent`
577
+ );
578
+ } else if (exitCode === 0 && isDirty(
579
+ worktree.worktreePath
580
+ )) {
581
+ console.log(
582
+ ` ${ticket.filename}: agent left uncommitted changes in the worktree; leaving ticket ready-for-agent`
583
+ );
584
+ } else if (exitCode === 0) {
585
+ console.log(
586
+ ` ${ticket.filename}: agent finished, awaiting merge`
587
+ );
588
+ ticketsByNum.set(ticket.num, ticket);
589
+ worktreePathsByNum.set(
590
+ ticket.num,
591
+ worktree.worktreePath
592
+ );
593
+ successfulBranches.push({
594
+ branch: worktree.branchName,
595
+ ticketNum: ticket.num,
596
+ worktreePath: worktree.worktreePath
597
+ });
237
598
  } else {
238
599
  console.log(
239
600
  ` ${ticket.filename}: agent exited ${exitCode}`
240
601
  );
602
+ checkTicket(progress, ticket.num, {
603
+ branch: worktree.branchName,
604
+ exitCode,
605
+ done: false
606
+ });
607
+ writeProgress(progressPath, progress);
608
+ await commitProgress(
609
+ repoRoot,
610
+ progressPath,
611
+ `nightralph: ${ticket.filename} failed`
612
+ );
613
+ try {
614
+ await removeWorktree(worktree.worktreePath);
615
+ } catch (err) {
616
+ console.error(
617
+ ` Failed to remove worktree ${worktree.worktreePath}: ` + formatErrorMessage(err)
618
+ );
619
+ }
620
+ }
621
+ }
622
+ if (successfulBranches.length > 0) {
623
+ const mergeResult = await mergeWave({
624
+ repoRoot,
625
+ baseBranch,
626
+ branches: successfulBranches,
627
+ strategy: opts.conflictStrategy
628
+ });
629
+ for (const branchResult of mergeResult.merged) {
630
+ const ticket = ticketsByNum.get(
631
+ branchResult.ticketNum
632
+ );
633
+ if (!ticket) continue;
634
+ if (branchResult.merged) {
635
+ markDone(ticket);
636
+ waveCompleted++;
637
+ totalCompleted++;
638
+ console.log(
639
+ ` ${ticket.filename}: done`
640
+ );
641
+ checkTicket(progress, ticket.num, {
642
+ branch: branchResult.branch,
643
+ exitCode: 0
644
+ });
645
+ writeProgress(progressPath, progress);
646
+ await commitProgress(
647
+ repoRoot,
648
+ progressPath,
649
+ `nightralph: ${ticket.filename} done`
650
+ );
651
+ const worktreePath = worktreePathsByNum.get(
652
+ ticket.num
653
+ );
654
+ if (worktreePath) {
655
+ try {
656
+ await removeWorktree(worktreePath);
657
+ } catch (err) {
658
+ console.error(
659
+ ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
660
+ );
661
+ }
662
+ }
663
+ } else {
664
+ const worktreePath = worktreePathsByNum.get(
665
+ ticket.num
666
+ );
667
+ const recovered = opts.conflictStrategy === "respawn" && worktreePath ? await respawnAndRetryMerge({
668
+ repoRoot,
669
+ baseBranch,
670
+ branch: branchResult.branch,
671
+ ticket,
672
+ worktreePath,
673
+ specBody: opts.specBody,
674
+ agentCmd: opts.agentCmd,
675
+ model: opts.model,
676
+ timeout: opts.timeout,
677
+ logsDir: opts.logsDir
678
+ }) : false;
679
+ if (recovered) {
680
+ markDone(ticket);
681
+ waveCompleted++;
682
+ totalCompleted++;
683
+ console.log(
684
+ ` ${ticket.filename}: done (resolved via respawn)`
685
+ );
686
+ checkTicket(progress, ticket.num, {
687
+ branch: branchResult.branch,
688
+ exitCode: 0
689
+ });
690
+ writeProgress(progressPath, progress);
691
+ await commitProgress(
692
+ repoRoot,
693
+ progressPath,
694
+ `nightralph: ${ticket.filename} done (respawn)`
695
+ );
696
+ if (worktreePath) {
697
+ try {
698
+ await removeWorktree(worktreePath);
699
+ } catch (err) {
700
+ console.error(
701
+ ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
702
+ );
703
+ }
704
+ }
705
+ } else {
706
+ console.log(
707
+ ` ${ticket.filename}: merge failed, staying ready-for-agent`
708
+ );
709
+ }
710
+ }
711
+ }
712
+ if (mergeResult.stoppedAt) {
713
+ console.log(
714
+ `
715
+ Merge stopped at ${mergeResult.stoppedAt.branch}`
716
+ );
717
+ for (const sb of successfulBranches) {
718
+ const ticket = ticketsByNum.get(sb.ticketNum);
719
+ if (ticket && ticket.status === "done") continue;
720
+ try {
721
+ await removeWorktree(sb.worktreePath);
722
+ } catch (err) {
723
+ console.error(
724
+ ` Failed to remove worktree ${sb.worktreePath}: ` + formatErrorMessage(err)
725
+ );
726
+ }
727
+ }
728
+ break;
241
729
  }
242
730
  }
243
731
  if (waveCompleted === 0) {
@@ -247,26 +735,31 @@ Wave ${waveNum}: ` + ready.map((t) => t.filename).join(", ")
247
735
  break;
248
736
  }
249
737
  }
250
- const remaining = tickets.filter(
738
+ const remaining = opts.tickets.filter(
251
739
  (t) => t.status === "ready-for-agent"
252
740
  );
253
741
  console.log(
254
742
  `
255
743
  Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
256
744
  );
745
+ return { allDone: remaining.length === 0 };
257
746
  }
258
- __name(runWaves, "runWaves");
747
+ __name(runWavesIsolated, "runWavesIsolated");
259
748
  export {
260
749
  TICKET_RE,
261
750
  dryRun,
751
+ dryRunIsolated,
262
752
  findReadyTickets,
263
753
  formatErrorMessage,
754
+ formatStreamLine,
755
+ getProviderArgs,
264
756
  markDone,
265
757
  parseBlockers,
266
758
  parseStatus,
267
759
  parseTicketFilename,
268
760
  renderPrompt,
269
761
  runWaves,
762
+ runWavesIsolated,
270
763
  scanTickets,
271
764
  spawnAgent
272
765
  };