nightralph 0.0.53 → 0.0.56

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 (60) hide show
  1. package/README.md +181 -19
  2. package/dist/blockers.js +149 -0
  3. package/dist/blockers.js.map +7 -0
  4. package/dist/display.js +12 -4
  5. package/dist/display.js.map +2 -2
  6. package/dist/docs-templates/issue-tracker.md +9 -1
  7. package/dist/index.js +2735 -415
  8. package/dist/index.js.map +4 -4
  9. package/dist/learnings.js +360 -0
  10. package/dist/learnings.js.map +7 -0
  11. package/dist/meta.json +289 -16
  12. package/dist/orchestrator.js +753 -188
  13. package/dist/orchestrator.js.map +3 -3
  14. package/dist/preflight.js +34 -0
  15. package/dist/preflight.js.map +7 -0
  16. package/dist/progress.js +5 -4
  17. package/dist/progress.js.map +2 -2
  18. package/dist/resolve.js +98 -9
  19. package/dist/resolve.js.map +2 -2
  20. package/dist/retry.js +83 -0
  21. package/dist/retry.js.map +7 -0
  22. package/dist/review.js +669 -0
  23. package/dist/review.js.map +7 -0
  24. package/dist/rules.js +67 -0
  25. package/dist/rules.js.map +7 -0
  26. package/dist/skills/to-tickets/SKILL.md +5 -5
  27. package/dist/src/blockers.d.ts +45 -0
  28. package/dist/src/blockers.d.ts.map +1 -0
  29. package/dist/src/display.d.ts +9 -0
  30. package/dist/src/display.d.ts.map +1 -1
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/learnings.d.ts +70 -0
  33. package/dist/src/learnings.d.ts.map +1 -0
  34. package/dist/src/orchestrator.d.ts +85 -28
  35. package/dist/src/orchestrator.d.ts.map +1 -1
  36. package/dist/src/preflight.d.ts +6 -0
  37. package/dist/src/preflight.d.ts.map +1 -0
  38. package/dist/src/progress.d.ts +1 -1
  39. package/dist/src/progress.d.ts.map +1 -1
  40. package/dist/src/resolve.d.ts +24 -0
  41. package/dist/src/resolve.d.ts.map +1 -1
  42. package/dist/src/retry.d.ts +26 -0
  43. package/dist/src/retry.d.ts.map +1 -0
  44. package/dist/src/review.d.ts +131 -0
  45. package/dist/src/review.d.ts.map +1 -0
  46. package/dist/src/rules.d.ts +11 -0
  47. package/dist/src/rules.d.ts.map +1 -0
  48. package/dist/src/stats.d.ts +43 -0
  49. package/dist/src/stats.d.ts.map +1 -0
  50. package/dist/src/testcmd.d.ts +1 -0
  51. package/dist/src/testcmd.d.ts.map +1 -1
  52. package/dist/src/worktree.d.ts +30 -1
  53. package/dist/src/worktree.d.ts.map +1 -1
  54. package/dist/stats.js +111 -0
  55. package/dist/stats.js.map +7 -0
  56. package/dist/testcmd.js +13 -0
  57. package/dist/testcmd.js.map +2 -2
  58. package/dist/worktree.js +236 -32
  59. package/dist/worktree.js.map +2 -2
  60. package/package.json +3 -2
@@ -7,10 +7,11 @@ import {
7
7
  readFileSync,
8
8
  writeFileSync,
9
9
  unlinkSync,
10
- readdirSync
10
+ readdirSync,
11
+ statSync
11
12
  } from "node:fs";
12
13
  import { tmpdir } from "node:os";
13
- import { join, dirname, basename } from "node:path";
14
+ import { join, dirname, basename, relative } from "node:path";
14
15
  import { createInterface } from "node:readline";
15
16
  import { promisify } from "node:util";
16
17
  const execFileAsync = promisify(execFile);
@@ -23,7 +24,8 @@ import {
23
24
  buildBranchName,
24
25
  hasNewCommits,
25
26
  isDirty,
26
- commitDirty
27
+ commitDirty,
28
+ headCommit
27
29
  } from "./worktree.js";
28
30
  import {
29
31
  generateProgress,
@@ -45,6 +47,57 @@ import {
45
47
  ticketLabel
46
48
  } from "./display.js";
47
49
  import { runTestCmd } from "./testcmd.js";
50
+ import {
51
+ readDocsRule,
52
+ installRule,
53
+ subagentRule,
54
+ testGateRule,
55
+ LINT_RULE,
56
+ integrityRule,
57
+ CONFINEMENT_RULES,
58
+ commitRule,
59
+ notesRule,
60
+ agentsMdRule
61
+ } from "./rules.js";
62
+ import { runReviewPass, renderReviewSummary } from "./review.js";
63
+ import {
64
+ addRun,
65
+ addStats,
66
+ emptyRunStats,
67
+ emptyStreamStats,
68
+ isRecord,
69
+ nextStreamStats,
70
+ renderRunStats
71
+ } from "./stats.js";
72
+ import {
73
+ EFFORT_LADDERS,
74
+ escalateEffort,
75
+ retryDelayFor,
76
+ runWithRetries,
77
+ sleep
78
+ } from "./retry.js";
79
+ import {
80
+ EFFORT_LADDERS as EFFORT_LADDERS2,
81
+ ALL_EFFORT_LEVELS,
82
+ escalateEffort as escalateEffort2,
83
+ retryDelayFor as retryDelayFor2
84
+ } from "./retry.js";
85
+ import {
86
+ LOG_TAIL_LINES,
87
+ appendEntry,
88
+ collectNotes,
89
+ ensureNotesIgnored,
90
+ learningsPathFor,
91
+ readEntries,
92
+ renderEntry,
93
+ renderHistory,
94
+ selectHistory,
95
+ tailLines
96
+ } from "./learnings.js";
97
+ import {
98
+ parseBlockerField,
99
+ provisionalRefs
100
+ } from "./blockers.js";
48
101
  function formatErrorMessage(error) {
49
102
  return error instanceof Error ? error.message : String(error);
50
103
  }
@@ -113,21 +166,7 @@ function parseStatus(body) {
113
166
  return m ? m[1].trim().toLowerCase() : "";
114
167
  }
115
168
  __name(parseStatus, "parseStatus");
116
- function parseBlockers(body) {
117
- const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i);
118
- if (!m) return [];
119
- const text = m[1];
120
- if (/none/i.test(text)) return [];
121
- const nums = [];
122
- const re = /(\d+)/g;
123
- let match;
124
- while (match = re.exec(text)) {
125
- nums.push(Number(match[1]));
126
- }
127
- return nums;
128
- }
129
- __name(parseBlockers, "parseBlockers");
130
- function scanTickets(dir, feature = "") {
169
+ function scanTickets(dir, feature = "", knownFeatures = /* @__PURE__ */ new Set()) {
131
170
  const files = readdirSync(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
132
171
  b,
133
172
  void 0,
@@ -144,7 +183,10 @@ function scanTickets(dir, feature = "") {
144
183
  filepath,
145
184
  body,
146
185
  status: parseStatus(body),
147
- blockers: parseBlockers(body),
186
+ blockers: provisionalRefs(
187
+ parseBlockerField(body, knownFeatures).refs,
188
+ feature
189
+ ),
148
190
  feature
149
191
  };
150
192
  });
@@ -154,15 +196,35 @@ function ticketKey(feature, num) {
154
196
  return `${feature}:${num}`;
155
197
  }
156
198
  __name(ticketKey, "ticketKey");
199
+ function blockerSatisfied(ref, doneKeys) {
200
+ switch (ref.where) {
201
+ case "run":
202
+ return doneKeys.has(ticketKey(ref.feature, ref.num));
203
+ case "done":
204
+ return true;
205
+ case "pending":
206
+ case "missing":
207
+ return false;
208
+ }
209
+ }
210
+ __name(blockerSatisfied, "blockerSatisfied");
211
+ function blockerLabel(ref) {
212
+ const nn = String(ref.num).padStart(2, "0");
213
+ const base = ref.feature ? `${ref.feature}/${nn}` : nn;
214
+ if (ref.where === "pending") {
215
+ return `${base} (${ref.status ?? "not done"}, not in run)`;
216
+ }
217
+ if (ref.where === "missing") return `${base} (not found)`;
218
+ return base;
219
+ }
220
+ __name(blockerLabel, "blockerLabel");
157
221
  function findReadyTickets(tickets) {
158
222
  const doneKeys = new Set(
159
223
  tickets.filter((t) => t.status === "done").map((t) => ticketKey(t.feature, t.num))
160
224
  );
161
225
  return tickets.filter((t) => {
162
226
  if (t.status !== "ready-for-agent") return false;
163
- return t.blockers.every(
164
- (b) => doneKeys.has(ticketKey(t.feature, b))
165
- );
227
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
166
228
  });
167
229
  }
168
230
  __name(findReadyTickets, "findReadyTickets");
@@ -171,14 +233,19 @@ function findBlockedTickets(tickets, failedKeys) {
171
233
  tickets.map((t) => [ticketKey(t.feature, t.num), t])
172
234
  );
173
235
  function collectDead(ticket, visited, found) {
174
- for (const b of ticket.blockers) {
175
- const key = ticketKey(ticket.feature, b);
236
+ for (const ref of ticket.blockers) {
237
+ const key = ticketKey(ref.feature, ref.num);
176
238
  if (visited.has(key)) continue;
177
239
  visited.add(key);
240
+ if (ref.where === "done") continue;
241
+ if (ref.where === "pending" || ref.where === "missing") {
242
+ found.set(key, ref);
243
+ continue;
244
+ }
178
245
  const blocker = byKey.get(key);
179
246
  if (!blocker || blocker.status === "done") continue;
180
247
  if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
181
- found.add(key);
248
+ found.set(key, ref);
182
249
  continue;
183
250
  }
184
251
  collectDead(blocker, visited, found);
@@ -190,9 +257,9 @@ function findBlockedTickets(tickets, failedKeys) {
190
257
  const key = ticketKey(t.feature, t.num);
191
258
  if (t.status !== "ready-for-agent") continue;
192
259
  if (failedKeys.has(key)) continue;
193
- const found = /* @__PURE__ */ new Set();
260
+ const found = /* @__PURE__ */ new Map();
194
261
  collectDead(t, /* @__PURE__ */ new Set([key]), found);
195
- if (found.size > 0) blocked.set(key, [...found]);
262
+ if (found.size > 0) blocked.set(key, [...found.values()]);
196
263
  }
197
264
  return blocked;
198
265
  }
@@ -337,11 +404,23 @@ function stripSpecSection(specBody, heading) {
337
404
  return out.join("\n");
338
405
  }
339
406
  __name(stripSpecSection, "stripSpecSection");
407
+ function reviewSpecBody(specBody, specBodies) {
408
+ if (!specBodies || specBodies.size <= 1) return specBody;
409
+ return [...specBodies].map(([name, body]) => `# Feature: ${name}
410
+
411
+ ${body}`).join("\n\n");
412
+ }
413
+ __name(reviewSpecBody, "reviewSpecBody");
340
414
  function lookupSpec(specBodies, fallback, feature) {
341
415
  if (!feature || !specBodies) return fallback;
342
416
  return specBodies.get(feature) ?? fallback;
343
417
  }
344
418
  __name(lookupSpec, "lookupSpec");
419
+ function historyBlock(history) {
420
+ if (!history) return [];
421
+ return [history, ""];
422
+ }
423
+ __name(historyBlock, "historyBlock");
345
424
  function renderPrompt(specBody, ticket, opts) {
346
425
  return [
347
426
  "---- SPEC CONTEXT ----",
@@ -351,27 +430,30 @@ function renderPrompt(specBody, ticket, opts) {
351
430
  ticket.filename,
352
431
  ticket.body,
353
432
  "",
433
+ ...historyBlock(opts.history),
354
434
  "---- AGENT INSTRUCTIONS ----",
355
435
  "You are an autonomous coding agent. Implement the ticket using /tdd.",
356
436
  "",
357
437
  "Follow these rules:",
358
- "- Read the files directly relevant to the ticket before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.",
438
+ readDocsRule("ticket"),
359
439
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
360
440
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
361
- "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor.",
362
- `- While iterating, run only the tests relevant to what you changed. Run \`${opts.testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`,
363
- "- If the project has a lint script, run it and fix what it reports.",
364
- "- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a checklist item done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and say what blocked you.",
365
- "- Do NOT read or modify files outside your current directory (the worktree).",
366
- "- Do NOT run git push.",
367
- "- Commit after each checklist item, and make sure everything is committed when finished.",
441
+ ...installRule(opts.installCmd),
442
+ ...subagentRule(opts.preferSubagent),
443
+ "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor. For a checklist item that is pure scaffolding, config, or docs (where a test adds no value), skip test-first and record that decision in your notes file.",
444
+ testGateRule(opts.testCmd, "ticket"),
445
+ LINT_RULE,
446
+ integrityRule("checklist item"),
447
+ ...CONFINEMENT_RULES,
448
+ commitRule("checklist item"),
449
+ notesRule("checklist item", "ticket"),
368
450
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
369
- "- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ticket-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory.",
451
+ agentsMdRule("ticket"),
370
452
  ""
371
453
  ].join("\n");
372
454
  }
373
455
  __name(renderPrompt, "renderPrompt");
374
- function renderMergePrompt(specBody, ticket) {
456
+ function renderMergePrompt(specBody, ticket, opts = {}) {
375
457
  return [
376
458
  "---- SPEC CONTEXT ----",
377
459
  specBody,
@@ -392,6 +474,8 @@ function renderMergePrompt(specBody, ticket) {
392
474
  "4. Commit the resolved files.",
393
475
  "",
394
476
  "Do NOT re-implement the ticket from scratch.",
477
+ ...installRule(opts.installCmd),
478
+ ...subagentRule(opts.preferSubagent),
395
479
  "Do NOT read or modify files outside your current directory (the worktree).",
396
480
  "Do NOT run git push."
397
481
  ].join("\n");
@@ -400,7 +484,7 @@ __name(renderMergePrompt, "renderMergePrompt");
400
484
  function listTickets(tickets) {
401
485
  console.log("\nTickets:");
402
486
  for (const t of tickets) {
403
- const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.join(", ")}]` : "no blockers";
487
+ const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.map(blockerLabel).join(", ")}]` : "no blockers";
404
488
  console.log(
405
489
  ` ${t.filename} (${t.status}) ${blockerStr}`
406
490
  );
@@ -424,11 +508,7 @@ function* simulateWaves(tickets) {
424
508
  if (sim.get(key) !== "ready-for-agent") {
425
509
  return false;
426
510
  }
427
- return t.blockers.every(
428
- (b) => doneKeys.has(
429
- ticketKey(t.feature, b)
430
- )
431
- );
511
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
432
512
  });
433
513
  if (ready.length === 0) break;
434
514
  yield { wave, ready };
@@ -461,9 +541,7 @@ function detectDeadlocks(tickets) {
461
541
  (t) => ticketKey(t.feature, t.num) === key
462
542
  );
463
543
  if (ticket.blockers.every(
464
- (b) => doneKeys.has(
465
- ticketKey(ticket.feature, b)
466
- )
544
+ (r) => blockerSatisfied(r, doneKeys)
467
545
  )) {
468
546
  doneKeys.add(key);
469
547
  readyKeys.delete(key);
@@ -505,76 +583,47 @@ function getProviderArgs(cmd) {
505
583
  return PROVIDER_ARGS[name] ?? [];
506
584
  }
507
585
  __name(getProviderArgs, "getProviderArgs");
508
- const EFFORT_LADDERS = {
509
- pi: [
510
- "off",
511
- "minimal",
512
- "low",
513
- "medium",
514
- "high",
515
- "xhigh",
516
- "max"
517
- ],
518
- claude: [
519
- "low",
520
- "medium",
521
- "high",
522
- "xhigh",
523
- "max"
524
- ],
525
- codex: ["low", "medium", "high"]
526
- };
527
- const ALL_EFFORT_LEVELS = [
528
- "off",
529
- "minimal",
530
- "low",
531
- "medium",
532
- "high",
533
- "xhigh",
534
- "max"
535
- ];
536
- function escalateEffort(provider, level) {
537
- if (!level) return "high";
538
- const ladder = EFFORT_LADDERS[provider];
539
- const i = ladder.indexOf(level);
540
- if (i === -1) return level;
541
- return ladder[Math.min(i + 1, ladder.length - 1)];
542
- }
543
- __name(escalateEffort, "escalateEffort");
544
586
  function formatStreamLine(line) {
545
- let obj;
587
+ let value;
546
588
  try {
547
- obj = JSON.parse(line);
589
+ value = JSON.parse(line);
548
590
  } catch {
549
591
  return line;
550
592
  }
593
+ return formatStreamObject(value);
594
+ }
595
+ __name(formatStreamLine, "formatStreamLine");
596
+ function formatStreamObject(value) {
597
+ if (!isRecord(value)) return null;
598
+ const obj = value;
551
599
  if (obj.type === "assistant") {
552
- const msg = obj.message;
553
- const content = msg?.content;
600
+ if (obj.parent_tool_use_id != null) return null;
601
+ const msg = isRecord(obj.message) ? obj.message : void 0;
602
+ const content = Array.isArray(msg?.content) ? msg.content : void 0;
554
603
  if (!content) return null;
555
604
  for (const block of content) {
556
- if (block.type === "text") {
557
- return String(block.text);
558
- }
605
+ if (!isRecord(block)) continue;
606
+ if (block.type !== "text") continue;
607
+ if (typeof block.text === "string") return block.text;
559
608
  }
560
609
  return null;
561
610
  }
562
611
  if (obj.type === "result") {
563
- const sub = obj.subtype ?? "done";
564
- const cost = obj.total_cost_usd;
565
- const costStr = cost != null ? ` ($${cost.toFixed(2)})` : "";
612
+ const sub = typeof obj.subtype === "string" ? obj.subtype : "done";
613
+ const cost = typeof obj.total_cost_usd === "number" ? obj.total_cost_usd : void 0;
614
+ const costStr = cost !== void 0 ? ` ($${cost.toFixed(2)})` : "";
566
615
  return `${sub}${costStr}`;
567
616
  }
568
617
  if (obj.type === "system") {
569
- const sub = obj.subtype;
618
+ const sub = typeof obj.subtype === "string" ? obj.subtype : void 0;
570
619
  if (sub === "api_retry") {
571
- const error = obj.error;
572
- return `retry: ${error ?? "unknown"}`;
620
+ const error = typeof obj.error === "string" ? obj.error : "unknown";
621
+ return `retry: ${error}`;
573
622
  }
574
623
  }
575
624
  return null;
576
625
  }
577
- __name(formatStreamLine, "formatStreamLine");
626
+ __name(formatStreamObject, "formatStreamObject");
578
627
  function createPiStreamFormatter() {
579
628
  const calls = /* @__PURE__ */ new Map();
580
629
  let textBuf = "";
@@ -727,10 +776,15 @@ function spawnAgent(opts) {
727
776
  opts.logPath,
728
777
  { flags: "w" }
729
778
  );
779
+ const env = isClaude && opts.preferSubagent ? {
780
+ ...process.env,
781
+ CLAUDE_CODE_SUBAGENT_MODEL: opts.preferSubagent
782
+ } : void 0;
730
783
  const proc = spawn(opts.agentCmd, args, {
731
784
  stdio: ["pipe", "pipe", "pipe"],
732
785
  cwd: opts.cwd,
733
- detached: true
786
+ detached: true,
787
+ ...env ? { env } : {}
734
788
  });
735
789
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
736
790
  proc.stdout.pipe(logStream, { end: false });
@@ -748,10 +802,26 @@ function spawnAgent(opts) {
748
802
  }
749
803
  }
750
804
  __name(collectCompleted, "collectCompleted");
805
+ let streamStats = emptyStreamStats();
806
+ function formatClaudeLine(line) {
807
+ let obj;
808
+ try {
809
+ obj = JSON.parse(line);
810
+ } catch {
811
+ return line;
812
+ }
813
+ const next = nextStreamStats(streamStats, obj);
814
+ if (next !== streamStats) {
815
+ streamStats = next;
816
+ opts.onStats?.(next.stats);
817
+ }
818
+ return formatStreamObject(obj);
819
+ }
820
+ __name(formatClaudeLine, "formatClaudeLine");
751
821
  const rl = createInterface({ input: proc.stdout });
752
822
  rl.on("line", (line) => {
753
823
  if (isClaude) {
754
- const formatted = formatStreamLine(line);
824
+ const formatted = formatClaudeLine(line);
755
825
  if (formatted !== null) {
756
826
  collectCompleted(formatted);
757
827
  const output = `${prefix}${formatted}`;
@@ -843,7 +913,12 @@ function spawnAgent(opts) {
843
913
  rlErr.close();
844
914
  logStream.end();
845
915
  if (promptFile) cleanupPromptFile(promptFile);
846
- resolve({ exitCode, completedItems, timedOut });
916
+ resolve({
917
+ exitCode,
918
+ completedItems,
919
+ timedOut,
920
+ ...isClaude ? streamStats.stats : {}
921
+ });
847
922
  }
848
923
  __name(finish, "finish");
849
924
  proc.on("exit", (code) => {
@@ -867,22 +942,27 @@ function spawnAgent(opts) {
867
942
  }
868
943
  rl.close();
869
944
  rlErr.close();
870
- logStream.end();
945
+ const msg = "agent process error: " + formatErrorMessage(err);
946
+ logStream.write(`nightralph: ${msg}
947
+ `);
871
948
  if (promptFile) cleanupPromptFile(promptFile);
872
- console.error(
873
- "Agent process error:",
874
- formatErrorMessage(err)
875
- );
876
- resolve({
877
- exitCode: 1,
878
- completedItems,
879
- timedOut
949
+ if (opts.onLine) {
950
+ opts.onLine(`${prefix}${msg}`);
951
+ } else {
952
+ console.error(`${prefix}${msg}`);
953
+ }
954
+ logStream.end(() => {
955
+ resolve({
956
+ exitCode: 1,
957
+ completedItems,
958
+ timedOut
959
+ });
880
960
  });
881
961
  });
882
962
  });
883
963
  }
884
964
  __name(spawnAgent, "spawnAgent");
885
- function dryRun(tickets, specBody, testCmd) {
965
+ function dryRun(tickets, specBody, testCmd, preferSubagent) {
886
966
  listTickets(tickets);
887
967
  console.log("\nSimulated wave order:");
888
968
  for (const { wave, ready } of simulateWaves(tickets)) {
@@ -896,6 +976,9 @@ function dryRun(tickets, specBody, testCmd) {
896
976
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
897
977
  );
898
978
  }
979
+ console.log(`
980
+ Test command: ${testCmd || "(none)"}`);
981
+ console.log(`Prefer subagent: ${preferSubagent || "(none)"}`);
899
982
  const firstReady = findReadyTickets(tickets);
900
983
  if (firstReady.length > 0) {
901
984
  console.log(
@@ -907,7 +990,7 @@ Prompt for first ready ticket (${firstReady[0].filename}):
907
990
  renderPrompt(
908
991
  specBody,
909
992
  firstReady[0],
910
- { testCmd }
993
+ { testCmd, preferSubagent }
911
994
  )
912
995
  );
913
996
  }
@@ -951,18 +1034,28 @@ Prompt for first ready ticket (${firstReady[0].filename}):
951
1034
  renderPrompt(
952
1035
  opts.specBody,
953
1036
  firstReady[0],
954
- { testCmd: opts.testCmd }
1037
+ {
1038
+ testCmd: opts.testCmd,
1039
+ installCmd: opts.installCmd,
1040
+ preferSubagent: opts.preferSubagent
1041
+ }
955
1042
  )
956
1043
  );
957
1044
  }
958
1045
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
959
1046
  console.log(
960
1047
  `
961
- Test command: ${opts.testCmd}`
1048
+ Test command: ${opts.testCmd || "(none)"}`
1049
+ );
1050
+ console.log(
1051
+ `Install: ${opts.installCmd || "(none)"}`
962
1052
  );
963
1053
  console.log(
964
1054
  `Effort: ${opts.effort ?? "(provider default)"}`
965
1055
  );
1056
+ console.log(
1057
+ `Prefer subagent: ${opts.preferSubagent || "(none)"}`
1058
+ );
966
1059
  console.log(
967
1060
  `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
968
1061
  );
@@ -979,7 +1072,34 @@ Merge strategy: ${strategyDesc}`
979
1072
  console.log(`Progress file: ${opts.progressPath}`);
980
1073
  }
981
1074
  __name(dryRunIsolated, "dryRunIsolated");
1075
+ async function runInstall(opts) {
1076
+ const result = await runTestCmd({
1077
+ cmd: opts.installCmd,
1078
+ cwd: opts.worktreePath,
1079
+ timeout: opts.timeout,
1080
+ registry: opts.registry
1081
+ });
1082
+ const installLogPath = attemptLogPath(
1083
+ opts.logsDir,
1084
+ opts.ticket,
1085
+ opts.attempt ?? 1,
1086
+ ".install.log"
1087
+ );
1088
+ writeFileSync(installLogPath, result.output);
1089
+ if (result.exitCode !== 0) {
1090
+ opts.log(
1091
+ ` ${opts.ticket.filename}: install failed (exit ${result.exitCode}); see ${installLogPath}`
1092
+ );
1093
+ return false;
1094
+ }
1095
+ return true;
1096
+ }
1097
+ __name(runInstall, "runInstall");
982
1098
  async function runTestGate(opts) {
1099
+ if (opts.testCmd === "") {
1100
+ opts.log(` ${opts.ticket.filename}: no test gate`);
1101
+ return true;
1102
+ }
983
1103
  const result = await runTestCmd({
984
1104
  cmd: opts.testCmd,
985
1105
  cwd: opts.worktreePath,
@@ -1021,9 +1141,25 @@ async function respawnAndRetryMerge(opts) {
1021
1141
  } else {
1022
1142
  console.log(msg1);
1023
1143
  }
1144
+ if (opts.installCmd) {
1145
+ const installed = await runInstall({
1146
+ installCmd: opts.installCmd,
1147
+ ticket: opts.ticket,
1148
+ worktreePath: opts.worktreePath,
1149
+ timeout: opts.timeout,
1150
+ logsDir: opts.logsDir,
1151
+ log,
1152
+ registry: runningAgents
1153
+ });
1154
+ if (!installed) return false;
1155
+ }
1024
1156
  const prompt = renderMergePrompt(
1025
1157
  opts.specBody,
1026
- opts.ticket
1158
+ opts.ticket,
1159
+ {
1160
+ installCmd: opts.installCmd,
1161
+ preferSubagent: opts.preferSubagent
1162
+ }
1027
1163
  );
1028
1164
  const logPath = join(
1029
1165
  opts.logsDir,
@@ -1042,8 +1178,10 @@ async function respawnAndRetryMerge(opts) {
1042
1178
  cwd: opts.worktreePath,
1043
1179
  label,
1044
1180
  maxTurns: opts.maxTurns,
1181
+ preferSubagent: opts.preferSubagent,
1045
1182
  onLine: opts.onLine
1046
1183
  });
1184
+ opts.onAgentRun?.(result);
1047
1185
  if (result.exitCode !== 0) {
1048
1186
  const msg2 = ` ${opts.ticket.filename}: respawned agent exited ${result.exitCode}`;
1049
1187
  if (opts.onLine) {
@@ -1122,20 +1260,43 @@ async function runPool(items, limit, fn) {
1122
1260
  return results;
1123
1261
  }
1124
1262
  __name(runPool, "runPool");
1125
- function retryDelayFor(base, attempt) {
1126
- return base * 2 ** (attempt - 2);
1127
- }
1128
- __name(retryDelayFor, "retryDelayFor");
1129
- function sleep(ms) {
1130
- return new Promise((resolve) => setTimeout(resolve, ms));
1131
- }
1132
- __name(sleep, "sleep");
1133
1263
  function attemptLogPath(logsDir, ticket, attempt, ext) {
1134
1264
  const stem = ticket.filename.replace(/\.md$/, "");
1135
1265
  const suffix = attempt > 1 ? `.attempt${attempt}` : "";
1136
1266
  return join(logsDir, `${stem}${suffix}${ext}`);
1137
1267
  }
1138
1268
  __name(attemptLogPath, "attemptLogPath");
1269
+ function historyFor(learningsPath, ticket) {
1270
+ const featureDir = dirname(dirname(ticket.filepath));
1271
+ const scratchDir = ticket.feature ? join(featureDir, ...ticket.feature.split("/").map(() => "..")) : dirname(featureDir);
1272
+ const others = new Set(
1273
+ ticket.blockers.map((r) => r.feature).filter((f) => f !== ticket.feature)
1274
+ );
1275
+ const entries = [
1276
+ ...readEntries(learningsPath),
1277
+ ...[...others].flatMap((f) => readEntries(
1278
+ join(scratchDir, f, "learnings.md")
1279
+ ))
1280
+ ];
1281
+ return renderHistory(selectHistory(entries, {
1282
+ key: ticketKey(ticket.feature, ticket.num),
1283
+ blockerKeys: ticket.blockers.map((r) => {
1284
+ return ticketKey(r.feature, r.num);
1285
+ })
1286
+ }));
1287
+ }
1288
+ __name(historyFor, "historyFor");
1289
+ function buildHistory(learningsPath, ticket, d) {
1290
+ try {
1291
+ return historyFor(learningsPath, ticket);
1292
+ } catch (err) {
1293
+ d.log(
1294
+ ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
1295
+ );
1296
+ return "";
1297
+ }
1298
+ }
1299
+ __name(buildHistory, "buildHistory");
1139
1300
  async function runTicketAttempt(opts) {
1140
1301
  const { ticket, d } = opts;
1141
1302
  let worktree;
@@ -1154,6 +1315,38 @@ async function runTicketAttempt(opts) {
1154
1315
  "in-progress",
1155
1316
  ticket.feature
1156
1317
  );
1318
+ if (opts.installCmd) {
1319
+ const installed = await runInstall({
1320
+ installCmd: opts.installCmd,
1321
+ ticket,
1322
+ worktreePath: worktree.worktreePath,
1323
+ timeout: opts.timeout,
1324
+ logsDir: opts.logsDir,
1325
+ attempt: opts.attempt,
1326
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log"),
1327
+ registry: runningAgents
1328
+ });
1329
+ if (!installed) {
1330
+ await removeWorktreeQuietly(
1331
+ worktree.worktreePath,
1332
+ d
1333
+ );
1334
+ return {
1335
+ kind: "failed",
1336
+ reason: "install",
1337
+ branchName: worktree.branchName,
1338
+ exitCode: 1,
1339
+ completedItems: []
1340
+ };
1341
+ }
1342
+ }
1343
+ try {
1344
+ ensureNotesIgnored(worktree.worktreePath);
1345
+ } catch (err) {
1346
+ d.log(
1347
+ ` ${ticket.filename}: could not set up the notes directory: ${formatErrorMessage(err)}`
1348
+ );
1349
+ }
1157
1350
  const ticketSpec = lookupSpec(
1158
1351
  opts.specBodies,
1159
1352
  opts.specBody,
@@ -1163,7 +1356,18 @@ async function runTicketAttempt(opts) {
1163
1356
  prompt: renderPrompt(
1164
1357
  ticketSpec,
1165
1358
  ticket,
1166
- { testCmd: opts.testCmd }
1359
+ {
1360
+ testCmd: opts.testCmd,
1361
+ installCmd: opts.installCmd,
1362
+ // Rebuilt every attempt, so a retry sees the
1363
+ // entry its previous attempt just wrote
1364
+ history: buildHistory(
1365
+ opts.learningsPath,
1366
+ ticket,
1367
+ d
1368
+ ),
1369
+ preferSubagent: opts.preferSubagent
1370
+ }
1167
1371
  ),
1168
1372
  agentCmd: opts.agentCmd,
1169
1373
  model: opts.model,
@@ -1178,8 +1382,27 @@ async function runTicketAttempt(opts) {
1178
1382
  label: ticket.filename.replace(/\.md$/, ""),
1179
1383
  maxTurns: opts.maxTurns,
1180
1384
  effort: opts.effort,
1385
+ preferSubagent: opts.preferSubagent,
1386
+ onStats: opts.onStats,
1181
1387
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1182
1388
  });
1389
+ opts.onAgentRun?.(agentResult);
1390
+ let notes = null;
1391
+ try {
1392
+ notes = collectNotes(worktree.worktreePath);
1393
+ } catch (err) {
1394
+ d.log(
1395
+ ` ${ticket.filename}: could not collect notes: ${formatErrorMessage(err)}`
1396
+ );
1397
+ }
1398
+ const agentRun = {
1399
+ exitCode: agentResult.exitCode,
1400
+ completedItems: agentResult.completedItems,
1401
+ timedOut: agentResult.timedOut,
1402
+ notes,
1403
+ ...agentResult.subagents === void 0 ? {} : { subagents: agentResult.subagents },
1404
+ ...agentResult.costUsd === void 0 ? {} : { costUsd: agentResult.costUsd }
1405
+ };
1183
1406
  if (agentResult.timedOut) {
1184
1407
  d.log(
1185
1408
  ` ${ticket.filename}: timed out; keeping the work it committed`
@@ -1194,7 +1417,8 @@ async function runTicketAttempt(opts) {
1194
1417
  reason: "exit",
1195
1418
  branchName: worktree.branchName,
1196
1419
  exitCode: agentResult.exitCode,
1197
- completedItems: agentResult.completedItems
1420
+ completedItems: agentResult.completedItems,
1421
+ agentRun
1198
1422
  };
1199
1423
  }
1200
1424
  if (isDirty(worktree.worktreePath)) {
@@ -1217,7 +1441,8 @@ async function runTicketAttempt(opts) {
1217
1441
  return {
1218
1442
  kind: "no-commits",
1219
1443
  worktreePath: worktree.worktreePath,
1220
- branchName: worktree.branchName
1444
+ branchName: worktree.branchName,
1445
+ agentRun
1221
1446
  };
1222
1447
  }
1223
1448
  const passed = await runTestGate({
@@ -1240,73 +1465,189 @@ async function runTicketAttempt(opts) {
1240
1465
  reason: "tests",
1241
1466
  branchName: worktree.branchName,
1242
1467
  exitCode: 1,
1243
- completedItems: agentResult.completedItems
1468
+ completedItems: agentResult.completedItems,
1469
+ agentRun
1244
1470
  };
1245
1471
  }
1246
1472
  return {
1247
1473
  kind: "ready",
1248
1474
  worktreePath: worktree.worktreePath,
1249
1475
  branchName: worktree.branchName,
1250
- completedItems: agentResult.completedItems
1476
+ completedItems: agentResult.completedItems,
1477
+ agentRun
1251
1478
  };
1252
1479
  }
1253
1480
  __name(runTicketAttempt, "runTicketAttempt");
1254
- async function runTicketAttempts(opts) {
1255
- const { ticket, d } = opts;
1256
- const maxAttempts = opts.retries + 1;
1257
- const providerName = basename(opts.agentCmd);
1258
- const isKnown = providerName in EFFORT_LADDERS;
1259
- let effort = opts.effort;
1260
- let outcome = {
1261
- kind: "no-commits",
1262
- worktreePath: "",
1263
- branchName: ""
1481
+ function readLogTail(path) {
1482
+ try {
1483
+ return tailLines(readFileSync(path, "utf8"), LOG_TAIL_LINES);
1484
+ } catch {
1485
+ return "";
1486
+ }
1487
+ }
1488
+ __name(readLogTail, "readLogTail");
1489
+ function attemptEntry(opts) {
1490
+ const { logsDir, ticket, attempt, effort, outcome } = opts;
1491
+ const base = {
1492
+ key: ticketKey(ticket.feature, ticket.num),
1493
+ label: ticketLabel(ticket),
1494
+ attempt,
1495
+ now: /* @__PURE__ */ new Date()
1264
1496
  };
1265
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1266
- if (attempt === 1) {
1267
- d.log(` Starting ${ticket.filename}`);
1268
- } else {
1269
- if (isKnown) {
1270
- effort = escalateEffort(
1271
- providerName,
1272
- effort
1273
- );
1497
+ const logFor = /* @__PURE__ */ __name((ext) => {
1498
+ const path = attemptLogPath(logsDir, ticket, attempt, ext);
1499
+ return { logName: basename(path), logTail: readLogTail(path) };
1500
+ }, "logFor");
1501
+ switch (outcome.kind) {
1502
+ case "ready":
1503
+ return {
1504
+ ...base,
1505
+ outcome: "done",
1506
+ run: outcome.agentRun,
1507
+ effort
1508
+ };
1509
+ case "failed":
1510
+ switch (outcome.reason) {
1511
+ case "exit":
1512
+ return {
1513
+ ...base,
1514
+ outcome: "exit",
1515
+ run: outcome.agentRun,
1516
+ effort
1517
+ };
1518
+ case "tests":
1519
+ return {
1520
+ ...base,
1521
+ outcome: "tests",
1522
+ run: outcome.agentRun,
1523
+ effort,
1524
+ ...logFor(".test.log")
1525
+ };
1526
+ case "install":
1527
+ return {
1528
+ ...base,
1529
+ outcome: "install",
1530
+ ...logFor(".install.log")
1531
+ };
1532
+ default: {
1533
+ const _exhaustive = outcome;
1534
+ return _exhaustive;
1535
+ }
1274
1536
  }
1275
- const delay = retryDelayFor(opts.retryDelay, attempt);
1276
- d.log(
1277
- ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1278
- );
1279
- if (delay > 0) await sleep(delay * 1e3);
1537
+ case "no-commits":
1538
+ return {
1539
+ ...base,
1540
+ outcome: "no-commits",
1541
+ run: outcome.agentRun,
1542
+ effort
1543
+ };
1544
+ case "error":
1545
+ case "worktree-error":
1546
+ return {
1547
+ ...base,
1548
+ outcome: "error",
1549
+ error: formatErrorMessage(outcome.error)
1550
+ };
1551
+ default: {
1552
+ const _exhaustive = outcome;
1553
+ return _exhaustive;
1280
1554
  }
1555
+ }
1556
+ }
1557
+ __name(attemptEntry, "attemptEntry");
1558
+ function recordAttempt(opts) {
1559
+ try {
1560
+ const entry = attemptEntry(opts);
1561
+ if (!entry) return;
1562
+ appendEntry(opts.learningsPath, renderEntry(entry));
1563
+ } catch (err) {
1564
+ opts.d.log(
1565
+ ` ${opts.ticket.filename}: could not record learnings: ${formatErrorMessage(err)}`
1566
+ );
1567
+ }
1568
+ }
1569
+ __name(recordAttempt, "recordAttempt");
1570
+ async function runTicketAttempts(opts) {
1571
+ const { ticket, d } = opts;
1572
+ let prior = { subagents: 0 };
1573
+ const showStats = /* @__PURE__ */ __name((stats) => {
1574
+ d.setTicketStats(ticket.num, ticket.feature, stats);
1575
+ }, "showStats");
1576
+ return runWithRetries({
1577
+ maxAttempts: opts.retries + 1,
1578
+ retryDelay: opts.retryDelay,
1579
+ agentCmd: opts.agentCmd,
1580
+ effort: opts.effort,
1581
+ label: ticket.filename,
1582
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
1583
+ }, async (attempt, effort) => {
1584
+ const record = /* @__PURE__ */ __name((o) => {
1585
+ recordAttempt({
1586
+ learningsPath: opts.learningsPath,
1587
+ logsDir: opts.logsDir,
1588
+ ticket,
1589
+ attempt,
1590
+ effort,
1591
+ outcome: o,
1592
+ d
1593
+ });
1594
+ }, "record");
1595
+ let outcome;
1281
1596
  try {
1282
1597
  outcome = await runTicketAttempt({
1283
1598
  ...opts,
1284
1599
  attempt,
1285
- effort
1600
+ effort,
1601
+ onStats: /* @__PURE__ */ __name((stats) => showStats(addStats(prior, stats)), "onStats"),
1602
+ onAgentRun: /* @__PURE__ */ __name((result) => {
1603
+ prior = addStats(prior, result);
1604
+ opts.onAgentRun?.(result);
1605
+ }, "onAgentRun")
1286
1606
  });
1287
1607
  } catch (error) {
1288
1608
  d.log(
1289
1609
  ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
1290
1610
  );
1291
- return { kind: "error", error };
1611
+ const errorOutcome = { kind: "error", error };
1612
+ record(errorOutcome);
1613
+ return { result: errorOutcome, stop: true };
1292
1614
  }
1615
+ record(outcome);
1293
1616
  if (outcome.kind === "ready") {
1294
1617
  d.setTicketStatus(
1295
1618
  ticket.num,
1296
1619
  "awaiting-merge",
1297
1620
  ticket.feature
1298
1621
  );
1299
- return outcome;
1622
+ return { result: outcome, stop: true };
1300
1623
  }
1301
- if (outcome.kind === "worktree-error") return outcome;
1302
- }
1303
- return outcome;
1624
+ return {
1625
+ result: outcome,
1626
+ stop: outcome.kind === "worktree-error"
1627
+ };
1628
+ });
1304
1629
  }
1305
1630
  __name(runTicketAttempts, "runTicketAttempts");
1631
+ function failureNote(reason) {
1632
+ if (reason === "tests") return "tests failed";
1633
+ if (reason === "install") return "install failed";
1634
+ return "failed";
1635
+ }
1636
+ __name(failureNote, "failureNote");
1637
+ function isRegularFile(path) {
1638
+ try {
1639
+ return statSync(path).isFile();
1640
+ } catch {
1641
+ return false;
1642
+ }
1643
+ }
1644
+ __name(isRegularFile, "isRegularFile");
1306
1645
  async function saveProgress(repoRoot, progressPath, progress, message, d) {
1307
1646
  writeProgress(progressPath, progress);
1647
+ const learningsPath = learningsPathFor(progressPath);
1648
+ const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
1308
1649
  try {
1309
- await commitProgress(repoRoot, progressPath, message);
1650
+ await commitProgress(repoRoot, paths, message);
1310
1651
  } catch (err) {
1311
1652
  d.log(
1312
1653
  " Failed to commit progress: " + formatErrorMessage(err)
@@ -1319,15 +1660,77 @@ function indentFilenames(tickets) {
1319
1660
  return tickets.map((t) => ` ${t.filename}`).join("\n");
1320
1661
  }
1321
1662
  __name(indentFilenames, "indentFilenames");
1322
- function renderFinishSummary(tickets, totalCompleted, failedKeys) {
1663
+ function explainOpen(tickets, failedKeys, warnings) {
1323
1664
  const remaining = tickets.filter(
1324
1665
  (t) => t.status === "ready-for-agent"
1325
1666
  );
1667
+ const open = remaining.filter(
1668
+ (t) => !failedKeys.has(ticketKey(t.feature, t.num))
1669
+ );
1670
+ const dead = findBlockedTickets(tickets, failedKeys);
1671
+ const deadRefs = /* @__PURE__ */ __name((t) => dead.get(ticketKey(t.feature, t.num)) ?? [], "deadRefs");
1672
+ const lines = [];
1326
1673
  const human = tickets.filter((t) => t.status === HUMAN_STATUS);
1327
- const humanNums = new Set(human.map((t) => t.num));
1328
- const blockedByHuman = remaining.filter(
1329
- (t) => !failedKeys.has(ticketKey(t.feature, t.num)) && t.blockers.some((b) => humanNums.has(b))
1674
+ if (human.length > 0) {
1675
+ const humanKeys = new Set(
1676
+ human.map((t) => ticketKey(t.feature, t.num))
1677
+ );
1678
+ const blockedByHuman = open.filter((t) => deadRefs(t).some(
1679
+ (r) => humanKeys.has(ticketKey(r.feature, r.num))
1680
+ ));
1681
+ const noun = human.length === 1 ? "ticket is" : "tickets are";
1682
+ lines.push(
1683
+ "",
1684
+ (remaining.length === 0 && failedKeys.size === 0 ? `All remaining ${noun} marked ${HUMAN_STATUS}. ` : `${human.length} ${noun} marked ${HUMAN_STATUS}. `) + "nightralph only runs ready-for-agent tickets, so an agent cannot finish these:",
1685
+ indentFilenames(human)
1686
+ );
1687
+ if (blockedByHuman.length > 0) {
1688
+ const n = blockedByHuman.length;
1689
+ lines.push(
1690
+ `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
1691
+ indentFilenames(blockedByHuman)
1692
+ );
1693
+ }
1694
+ lines.push(
1695
+ "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
1696
+ );
1697
+ }
1698
+ const blockers = [];
1699
+ const outside = open.filter(
1700
+ (t) => deadRefs(t).some((r) => r.where === "pending")
1701
+ );
1702
+ if (outside.length > 0) {
1703
+ const n = outside.length;
1704
+ blockers.push(
1705
+ `${n} ready-for-agent ticket${n === 1 ? " waits" : "s wait"} on tickets outside this run. Select those features too, or finish them first:`,
1706
+ ...outside.map((t) => ` ${ticketLabel(toRef(t))} waits on ` + deadRefs(t).filter((r) => r.where === "pending").map(blockerLabel).join(", "))
1707
+ );
1708
+ }
1709
+ const missing = open.flatMap((t) => t.blockers.filter((r) => r.where === "missing").map((r) => ` ${ticketLabel(toRef(t))}: ${blockerLabel(r)}`));
1710
+ if (missing.length > 0) {
1711
+ const n = missing.length;
1712
+ blockers.push(
1713
+ `${n} blocker reference${n === 1 ? "" : "s"} match no ticket. Fix the **Blocked by:** line:`,
1714
+ ...missing
1715
+ );
1716
+ }
1717
+ const typos = warnings.filter((w) => w.kind === "typo");
1718
+ if (typos.length > 0) {
1719
+ const n = typos.length;
1720
+ blockers.push(
1721
+ `${n} word${n === 1 ? "" : "s"} in a **Blocked by:** line look${n === 1 ? "s" : ""} like a feature name but match no .scratch/<name>/issues directory:`,
1722
+ ...typos.map((w) => ` ${w.ticket}: ${w.text}`)
1723
+ );
1724
+ }
1725
+ if (blockers.length > 0) lines.push("", ...blockers);
1726
+ return lines;
1727
+ }
1728
+ __name(explainOpen, "explainOpen");
1729
+ function renderFinishSummary(tickets, totalCompleted, failedKeys, warnings = []) {
1730
+ const remaining = tickets.filter(
1731
+ (t) => t.status === "ready-for-agent"
1330
1732
  );
1733
+ const human = tickets.filter((t) => t.status === HUMAN_STATUS);
1331
1734
  const counts = `Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedKeys.size > 0 ? `, ${failedKeys.size} failed` : "") + (human.length > 0 ? `, ${human.length} need a human` : "");
1332
1735
  const lines = [counts];
1333
1736
  const features = new Set(
@@ -1356,26 +1759,54 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
1356
1759
  lines.push(detail);
1357
1760
  }
1358
1761
  }
1359
- if (human.length === 0) return lines.join("\n");
1360
- const noun = human.length === 1 ? "ticket is" : "tickets are";
1361
- lines.push(
1362
- "",
1363
- (remaining.length === 0 && failedKeys.size === 0 ? `All remaining ${noun} marked ${HUMAN_STATUS}. ` : `${human.length} ${noun} marked ${HUMAN_STATUS}. `) + "nightralph only runs ready-for-agent tickets, so an agent cannot finish these:",
1364
- indentFilenames(human)
1762
+ lines.push(...explainOpen(tickets, failedKeys, warnings));
1763
+ return lines.join("\n");
1764
+ }
1765
+ __name(renderFinishSummary, "renderFinishSummary");
1766
+ function nothingReady(tickets) {
1767
+ return findReadyTickets(tickets).length === 0 && tickets.some((t) => t.status === "ready-for-agent");
1768
+ }
1769
+ __name(nothingReady, "nothingReady");
1770
+ function renderNothingReady(tickets, warnings) {
1771
+ const lines = [
1772
+ "Nothing to run: every ready-for-agent ticket is waiting on a blocker.",
1773
+ ...explainOpen(tickets, /* @__PURE__ */ new Set(), warnings)
1774
+ ];
1775
+ const dead = findBlockedTickets(tickets, /* @__PURE__ */ new Set());
1776
+ const byKey = new Map(
1777
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
1365
1778
  );
1366
- if (blockedByHuman.length > 0) {
1367
- const n = blockedByHuman.length;
1779
+ const other = [];
1780
+ for (const [key, refs] of dead) {
1781
+ const stuck = refs.flatMap((r) => {
1782
+ const b = byKey.get(ticketKey(r.feature, r.num));
1783
+ return b && b.status !== HUMAN_STATUS ? [`${ticketLabel(toRef(b))} (${b.status})`] : [];
1784
+ });
1785
+ const t = byKey.get(key);
1786
+ if (t && stuck.length > 0) {
1787
+ other.push(
1788
+ ` ${ticketLabel(toRef(t))} waits on ` + stuck.join(", ")
1789
+ );
1790
+ }
1791
+ }
1792
+ if (other.length > 0) {
1368
1793
  lines.push(
1369
- `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
1370
- indentFilenames(blockedByHuman)
1794
+ "",
1795
+ "These tickets wait on tickets that are not ready-for-agent:",
1796
+ ...other,
1797
+ "Set those tickets to ready-for-agent or done, or fix the **Blocked by:** lines."
1798
+ );
1799
+ }
1800
+ const cycle = detectDeadlocks(tickets).filter((t) => !dead.has(ticketKey(t.feature, t.num)));
1801
+ if (cycle.length > 0) {
1802
+ lines.push(
1803
+ "",
1804
+ "Deadlocked tickets (waiting on each other): " + cycle.map((t) => ticketLabel(toRef(t))).join(", ") + ". Fix their **Blocked by:** lines."
1371
1805
  );
1372
1806
  }
1373
- lines.push(
1374
- "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
1375
- );
1376
1807
  return lines.join("\n");
1377
1808
  }
1378
- __name(renderFinishSummary, "renderFinishSummary");
1809
+ __name(renderNothingReady, "renderNothingReady");
1379
1810
  function sessionTickets(tickets) {
1380
1811
  return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
1381
1812
  }
@@ -1394,7 +1825,10 @@ function reportBlocked(d, tickets, failedKeys) {
1394
1825
  )) {
1395
1826
  const ticket = byKey.get(key);
1396
1827
  if (!ticket) continue;
1397
- const labels = dead.map((k) => byKey.get(k)).filter((t) => t !== void 0).map((t) => ticketLabel(toRef(t)));
1828
+ const labels = dead.map((ref) => {
1829
+ const t = byKey.get(ticketKey(ref.feature, ref.num));
1830
+ return t ? ticketLabel(toRef(t)) : blockerLabel(ref);
1831
+ });
1398
1832
  d.setBlocked(ticket.num, ticket.feature, labels);
1399
1833
  }
1400
1834
  }
@@ -1406,7 +1840,7 @@ function runProgress(tickets) {
1406
1840
  };
1407
1841
  }
1408
1842
  __name(runProgress, "runProgress");
1409
- async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
1843
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency, warnings = [], preferSubagent) {
1410
1844
  mkdirSync(logsDir, { recursive: true });
1411
1845
  const d = display ?? createDisplay();
1412
1846
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
@@ -1420,6 +1854,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1420
1854
  let waveNum = 0;
1421
1855
  const failedKeys = /* @__PURE__ */ new Set();
1422
1856
  const session = sessionTickets(tickets);
1857
+ let runStats = emptyRunStats();
1423
1858
  while (true) {
1424
1859
  if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
1425
1860
  const ready = findReadyTickets(tickets).filter(
@@ -1450,7 +1885,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1450
1885
  const prompt = renderPrompt(
1451
1886
  specBody,
1452
1887
  t,
1453
- { testCmd }
1888
+ { testCmd, preferSubagent }
1454
1889
  );
1455
1890
  const label = t.filename.replace(
1456
1891
  /\.md$/,
@@ -1462,6 +1897,18 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1462
1897
  t.feature
1463
1898
  );
1464
1899
  d.log(` Starting ${t.filename}`);
1900
+ let prior = { subagents: 0 };
1901
+ const onStats = /* @__PURE__ */ __name((stats) => {
1902
+ d.setTicketStats(
1903
+ t.num,
1904
+ t.feature,
1905
+ addStats(prior, stats)
1906
+ );
1907
+ }, "onStats");
1908
+ const tally = /* @__PURE__ */ __name((r) => {
1909
+ prior = addStats(prior, r);
1910
+ runStats = addRun(runStats, r);
1911
+ }, "tally");
1465
1912
  let level = effort;
1466
1913
  let result = await spawnAgent({
1467
1914
  prompt,
@@ -1472,8 +1919,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1472
1919
  label,
1473
1920
  maxTurns,
1474
1921
  effort: level,
1922
+ preferSubagent,
1923
+ onStats,
1475
1924
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1476
1925
  });
1926
+ tally(result);
1477
1927
  for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
1478
1928
  if (isKnown) {
1479
1929
  level = escalateEffort(
@@ -1500,8 +1950,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1500
1950
  label,
1501
1951
  maxTurns,
1502
1952
  effort: level,
1953
+ preferSubagent,
1954
+ onStats,
1503
1955
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1504
1956
  });
1957
+ tally(result);
1505
1958
  }
1506
1959
  return { ticket: t, result };
1507
1960
  }
@@ -1560,8 +2013,11 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
1560
2013
  d.log("\n" + renderFinishSummary(
1561
2014
  tickets,
1562
2015
  totalCompleted,
1563
- failedKeys
2016
+ failedKeys,
2017
+ warnings
1564
2018
  ));
2019
+ const statsLine = renderRunStats(runStats);
2020
+ if (statsLine) d.log(statsLine);
1565
2021
  d.cleanup();
1566
2022
  return { allDone: remaining.length === 0 };
1567
2023
  }
@@ -1582,6 +2038,7 @@ async function runWavesIsolated(opts) {
1582
2038
  "Working tree is dirty; commit or stash changes before running"
1583
2039
  );
1584
2040
  }
2041
+ const launchCommit = headCommit(repoRoot);
1585
2042
  const features = new Set(
1586
2043
  opts.tickets.map((t) => t.feature).filter(Boolean)
1587
2044
  );
@@ -1647,6 +2104,10 @@ async function runWavesIsolated(opts) {
1647
2104
  let waveNum = 0;
1648
2105
  const failedKeys = /* @__PURE__ */ new Set();
1649
2106
  const session = sessionTickets(opts.tickets);
2107
+ let runStats = emptyRunStats();
2108
+ const onAgentRun = /* @__PURE__ */ __name((result) => {
2109
+ runStats = addRun(runStats, result);
2110
+ }, "onAgentRun");
1650
2111
  while (true) {
1651
2112
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
1652
2113
  const ready = findReadyTickets(opts.tickets).filter(
@@ -1685,13 +2146,36 @@ async function runWavesIsolated(opts) {
1685
2146
  timeout: opts.timeout,
1686
2147
  logsDir: opts.logsDir,
1687
2148
  testCmd: opts.testCmd,
2149
+ installCmd: opts.installCmd,
1688
2150
  maxTurns: opts.maxTurns,
1689
2151
  effort: opts.effort,
2152
+ preferSubagent: opts.preferSubagent,
2153
+ learningsPath: learningsPathFor(
2154
+ tracker.pathFor(t.feature)
2155
+ ),
1690
2156
  retries,
1691
2157
  retryDelay,
1692
- d
2158
+ d,
2159
+ onAgentRun
1693
2160
  })
1694
2161
  );
2162
+ const touched = new Set(ready.map((t) => {
2163
+ return learningsPathFor(tracker.pathFor(t.feature));
2164
+ }));
2165
+ for (const path of touched) {
2166
+ if (!isRegularFile(path)) continue;
2167
+ try {
2168
+ await commitProgress(
2169
+ repoRoot,
2170
+ path,
2171
+ "nightralph: record attempt learnings"
2172
+ );
2173
+ } catch (err) {
2174
+ d.log(
2175
+ " Failed to commit learnings: " + formatErrorMessage(err)
2176
+ );
2177
+ }
2178
+ }
1695
2179
  const outcomes = settled.map(
1696
2180
  (r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
1697
2181
  );
@@ -1795,7 +2279,7 @@ async function runWavesIsolated(opts) {
1795
2279
  repoRoot,
1796
2280
  tracker.pathFor(ticket.feature),
1797
2281
  tracker.dataFor(ticket.feature),
1798
- `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
2282
+ `nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
1799
2283
  d
1800
2284
  );
1801
2285
  continue;
@@ -1831,6 +2315,7 @@ async function runWavesIsolated(opts) {
1831
2315
  branches: successfulBranches,
1832
2316
  strategy: opts.conflictStrategy
1833
2317
  });
2318
+ const doneThisWave = [];
1834
2319
  for (const branchResult of mergeResult.merged) {
1835
2320
  const bk = branchToKey.get(
1836
2321
  branchResult.branch
@@ -1839,6 +2324,7 @@ async function runWavesIsolated(opts) {
1839
2324
  if (!ticket) continue;
1840
2325
  if (branchResult.merged) {
1841
2326
  markDone(ticket);
2327
+ doneThisWave.push(ticket);
1842
2328
  waveCompleted++;
1843
2329
  totalCompleted++;
1844
2330
  d.setTicketStatus(
@@ -1888,11 +2374,15 @@ async function runWavesIsolated(opts) {
1888
2374
  timeout: opts.timeout,
1889
2375
  logsDir: opts.logsDir,
1890
2376
  testCmd: opts.testCmd,
2377
+ installCmd: opts.installCmd,
1891
2378
  maxTurns: opts.maxTurns,
1892
- onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
2379
+ preferSubagent: opts.preferSubagent,
2380
+ onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine"),
2381
+ onAgentRun
1893
2382
  }) : false;
1894
2383
  if (recovered) {
1895
2384
  markDone(ticket);
2385
+ doneThisWave.push(ticket);
1896
2386
  waveCompleted++;
1897
2387
  totalCompleted++;
1898
2388
  d.setTicketStatus(
@@ -1948,6 +2438,28 @@ async function runWavesIsolated(opts) {
1948
2438
  exitCode: 0,
1949
2439
  done: false
1950
2440
  });
2441
+ try {
2442
+ appendEntry(
2443
+ learningsPathFor(
2444
+ tracker.pathFor(ticket.feature)
2445
+ ),
2446
+ renderEntry({
2447
+ outcome: "merge",
2448
+ key: ticketKey(
2449
+ ticket.feature,
2450
+ ticket.num
2451
+ ),
2452
+ label: ticketLabel(ticket),
2453
+ attempt: 0,
2454
+ now: /* @__PURE__ */ new Date(),
2455
+ stderr: branchResult.stderr ?? ""
2456
+ })
2457
+ );
2458
+ } catch (err) {
2459
+ d.log(
2460
+ ` ${ticket.filename}: could not record learnings: ` + formatErrorMessage(err)
2461
+ );
2462
+ }
1951
2463
  await saveProgress(
1952
2464
  repoRoot,
1953
2465
  tracker.pathFor(ticket.feature),
@@ -1958,6 +2470,7 @@ async function runWavesIsolated(opts) {
1958
2470
  }
1959
2471
  }
1960
2472
  }
2473
+ await commitTicketFiles(repoRoot, doneThisWave, d);
1961
2474
  if (mergeResult.stoppedAt) {
1962
2475
  d.log(
1963
2476
  `
@@ -2000,16 +2513,64 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
2000
2513
  }
2001
2514
  }
2002
2515
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
2516
+ let reviewLine = null;
2517
+ if (opts.review) {
2518
+ try {
2519
+ const outcome = await runReviewPass({
2520
+ repoRoot,
2521
+ repoName,
2522
+ baseBranch,
2523
+ reviewPath: opts.review.reviewPath,
2524
+ forkedFrom: opts.review.forkedFrom,
2525
+ launchCommit,
2526
+ specBody: reviewSpecBody(
2527
+ opts.specBody,
2528
+ opts.specBodies
2529
+ ),
2530
+ agentCmd: opts.agentCmd,
2531
+ model: opts.model,
2532
+ timeout: opts.timeout,
2533
+ logsDir: opts.logsDir,
2534
+ testCmd: opts.testCmd,
2535
+ installCmd: opts.installCmd,
2536
+ maxTurns: opts.maxTurns,
2537
+ effort: opts.effort,
2538
+ preferSubagent: opts.preferSubagent,
2539
+ retries,
2540
+ retryDelay,
2541
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
2542
+ }, {
2543
+ spawnAgent: /* @__PURE__ */ __name(async (spawnOpts) => {
2544
+ const result = await spawnAgent(spawnOpts);
2545
+ onAgentRun(result);
2546
+ return result;
2547
+ }, "spawnAgent"),
2548
+ registry: runningAgents
2549
+ });
2550
+ reviewLine = renderReviewSummary(
2551
+ outcome,
2552
+ relative(repoRoot, opts.review.reviewPath)
2553
+ );
2554
+ } catch (err) {
2555
+ d.log(`
2556
+ Review failed: ${formatErrorMessage(err)}`);
2557
+ }
2558
+ }
2003
2559
  const remaining = opts.tickets.filter(
2004
2560
  (t) => t.status === "ready-for-agent"
2005
2561
  );
2006
2562
  d.log("\n" + renderFinishSummary(
2007
2563
  opts.tickets,
2008
2564
  totalCompleted,
2009
- failedKeys
2565
+ failedKeys,
2566
+ opts.warnings ?? []
2010
2567
  ));
2568
+ const statsLine = renderRunStats(runStats);
2569
+ if (statsLine) d.log(statsLine);
2570
+ if (reviewLine) d.log(reviewLine);
2571
+ d.log(`Work is on ${baseBranch}`);
2011
2572
  d.cleanup();
2012
- return { allDone: remaining.length === 0 };
2573
+ return { allDone: remaining.length === 0, branch: baseBranch };
2013
2574
  }
2014
2575
  __name(runWavesIsolated, "runWavesIsolated");
2015
2576
  export {
@@ -2018,31 +2579,35 @@ export {
2018
2579
  DEFAULT_CONCURRENCY,
2019
2580
  DEFAULT_RETRIES,
2020
2581
  DEFAULT_RETRY_DELAY,
2021
- EFFORT_LADDERS,
2582
+ EFFORT_LADDERS2 as EFFORT_LADDERS,
2022
2583
  HUMAN_STATUS,
2023
2584
  TICKET_RE,
2024
2585
  TOOL_USE_RE,
2586
+ blockerLabel,
2587
+ blockerSatisfied,
2025
2588
  checkItems,
2026
2589
  createPiStreamFormatter,
2027
2590
  dryRun,
2028
2591
  dryRunIsolated,
2029
- escalateEffort,
2592
+ escalateEffort2 as escalateEffort,
2030
2593
  findBlockedTickets,
2031
2594
  findReadyTickets,
2032
2595
  formatErrorMessage,
2033
2596
  formatStreamLine,
2034
2597
  getProviderArgs,
2598
+ historyFor,
2035
2599
  killProcessGroup,
2036
2600
  killRunningAgents,
2037
2601
  lookupSpec,
2038
2602
  markDone,
2039
- parseBlockers,
2603
+ nothingReady,
2040
2604
  parseStatus,
2041
2605
  parseTicketFilename,
2042
2606
  renderFinishSummary,
2043
2607
  renderMergePrompt,
2608
+ renderNothingReady,
2044
2609
  renderPrompt,
2045
- retryDelayFor,
2610
+ retryDelayFor2 as retryDelayFor,
2046
2611
  runPool,
2047
2612
  runWaves,
2048
2613
  runWavesIsolated,