nightralph 0.0.54 → 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.
@@ -50,6 +50,7 @@ import { runTestCmd } from "./testcmd.js";
50
50
  import {
51
51
  readDocsRule,
52
52
  installRule,
53
+ subagentRule,
53
54
  testGateRule,
54
55
  LINT_RULE,
55
56
  integrityRule,
@@ -59,6 +60,15 @@ import {
59
60
  agentsMdRule
60
61
  } from "./rules.js";
61
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";
62
72
  import {
63
73
  EFFORT_LADDERS,
64
74
  escalateEffort,
@@ -84,6 +94,10 @@ import {
84
94
  selectHistory,
85
95
  tailLines
86
96
  } from "./learnings.js";
97
+ import {
98
+ parseBlockerField,
99
+ provisionalRefs
100
+ } from "./blockers.js";
87
101
  function formatErrorMessage(error) {
88
102
  return error instanceof Error ? error.message : String(error);
89
103
  }
@@ -152,21 +166,7 @@ function parseStatus(body) {
152
166
  return m ? m[1].trim().toLowerCase() : "";
153
167
  }
154
168
  __name(parseStatus, "parseStatus");
155
- function parseBlockers(body) {
156
- const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i);
157
- if (!m) return [];
158
- const text = m[1];
159
- if (/none/i.test(text)) return [];
160
- const nums = [];
161
- const re = /(\d+)/g;
162
- let match;
163
- while (match = re.exec(text)) {
164
- nums.push(Number(match[1]));
165
- }
166
- return nums;
167
- }
168
- __name(parseBlockers, "parseBlockers");
169
- function scanTickets(dir, feature = "") {
169
+ function scanTickets(dir, feature = "", knownFeatures = /* @__PURE__ */ new Set()) {
170
170
  const files = readdirSync(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
171
171
  b,
172
172
  void 0,
@@ -183,7 +183,10 @@ function scanTickets(dir, feature = "") {
183
183
  filepath,
184
184
  body,
185
185
  status: parseStatus(body),
186
- blockers: parseBlockers(body),
186
+ blockers: provisionalRefs(
187
+ parseBlockerField(body, knownFeatures).refs,
188
+ feature
189
+ ),
187
190
  feature
188
191
  };
189
192
  });
@@ -193,15 +196,35 @@ function ticketKey(feature, num) {
193
196
  return `${feature}:${num}`;
194
197
  }
195
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");
196
221
  function findReadyTickets(tickets) {
197
222
  const doneKeys = new Set(
198
223
  tickets.filter((t) => t.status === "done").map((t) => ticketKey(t.feature, t.num))
199
224
  );
200
225
  return tickets.filter((t) => {
201
226
  if (t.status !== "ready-for-agent") return false;
202
- return t.blockers.every(
203
- (b) => doneKeys.has(ticketKey(t.feature, b))
204
- );
227
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
205
228
  });
206
229
  }
207
230
  __name(findReadyTickets, "findReadyTickets");
@@ -210,14 +233,19 @@ function findBlockedTickets(tickets, failedKeys) {
210
233
  tickets.map((t) => [ticketKey(t.feature, t.num), t])
211
234
  );
212
235
  function collectDead(ticket, visited, found) {
213
- for (const b of ticket.blockers) {
214
- const key = ticketKey(ticket.feature, b);
236
+ for (const ref of ticket.blockers) {
237
+ const key = ticketKey(ref.feature, ref.num);
215
238
  if (visited.has(key)) continue;
216
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
+ }
217
245
  const blocker = byKey.get(key);
218
246
  if (!blocker || blocker.status === "done") continue;
219
247
  if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
220
- found.add(key);
248
+ found.set(key, ref);
221
249
  continue;
222
250
  }
223
251
  collectDead(blocker, visited, found);
@@ -229,9 +257,9 @@ function findBlockedTickets(tickets, failedKeys) {
229
257
  const key = ticketKey(t.feature, t.num);
230
258
  if (t.status !== "ready-for-agent") continue;
231
259
  if (failedKeys.has(key)) continue;
232
- const found = /* @__PURE__ */ new Set();
260
+ const found = /* @__PURE__ */ new Map();
233
261
  collectDead(t, /* @__PURE__ */ new Set([key]), found);
234
- if (found.size > 0) blocked.set(key, [...found]);
262
+ if (found.size > 0) blocked.set(key, [...found.values()]);
235
263
  }
236
264
  return blocked;
237
265
  }
@@ -411,6 +439,7 @@ function renderPrompt(specBody, ticket, opts) {
411
439
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
412
440
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
413
441
  ...installRule(opts.installCmd),
442
+ ...subagentRule(opts.preferSubagent),
414
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.",
415
444
  testGateRule(opts.testCmd, "ticket"),
416
445
  LINT_RULE,
@@ -446,6 +475,7 @@ function renderMergePrompt(specBody, ticket, opts = {}) {
446
475
  "",
447
476
  "Do NOT re-implement the ticket from scratch.",
448
477
  ...installRule(opts.installCmd),
478
+ ...subagentRule(opts.preferSubagent),
449
479
  "Do NOT read or modify files outside your current directory (the worktree).",
450
480
  "Do NOT run git push."
451
481
  ].join("\n");
@@ -454,7 +484,7 @@ __name(renderMergePrompt, "renderMergePrompt");
454
484
  function listTickets(tickets) {
455
485
  console.log("\nTickets:");
456
486
  for (const t of tickets) {
457
- 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";
458
488
  console.log(
459
489
  ` ${t.filename} (${t.status}) ${blockerStr}`
460
490
  );
@@ -478,11 +508,7 @@ function* simulateWaves(tickets) {
478
508
  if (sim.get(key) !== "ready-for-agent") {
479
509
  return false;
480
510
  }
481
- return t.blockers.every(
482
- (b) => doneKeys.has(
483
- ticketKey(t.feature, b)
484
- )
485
- );
511
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
486
512
  });
487
513
  if (ready.length === 0) break;
488
514
  yield { wave, ready };
@@ -515,9 +541,7 @@ function detectDeadlocks(tickets) {
515
541
  (t) => ticketKey(t.feature, t.num) === key
516
542
  );
517
543
  if (ticket.blockers.every(
518
- (b) => doneKeys.has(
519
- ticketKey(ticket.feature, b)
520
- )
544
+ (r) => blockerSatisfied(r, doneKeys)
521
545
  )) {
522
546
  doneKeys.add(key);
523
547
  readyKeys.delete(key);
@@ -560,39 +584,46 @@ function getProviderArgs(cmd) {
560
584
  }
561
585
  __name(getProviderArgs, "getProviderArgs");
562
586
  function formatStreamLine(line) {
563
- let obj;
587
+ let value;
564
588
  try {
565
- obj = JSON.parse(line);
589
+ value = JSON.parse(line);
566
590
  } catch {
567
591
  return line;
568
592
  }
593
+ return formatStreamObject(value);
594
+ }
595
+ __name(formatStreamLine, "formatStreamLine");
596
+ function formatStreamObject(value) {
597
+ if (!isRecord(value)) return null;
598
+ const obj = value;
569
599
  if (obj.type === "assistant") {
570
- const msg = obj.message;
571
- 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;
572
603
  if (!content) return null;
573
604
  for (const block of content) {
574
- if (block.type === "text") {
575
- return String(block.text);
576
- }
605
+ if (!isRecord(block)) continue;
606
+ if (block.type !== "text") continue;
607
+ if (typeof block.text === "string") return block.text;
577
608
  }
578
609
  return null;
579
610
  }
580
611
  if (obj.type === "result") {
581
- const sub = obj.subtype ?? "done";
582
- const cost = obj.total_cost_usd;
583
- 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)})` : "";
584
615
  return `${sub}${costStr}`;
585
616
  }
586
617
  if (obj.type === "system") {
587
- const sub = obj.subtype;
618
+ const sub = typeof obj.subtype === "string" ? obj.subtype : void 0;
588
619
  if (sub === "api_retry") {
589
- const error = obj.error;
590
- return `retry: ${error ?? "unknown"}`;
620
+ const error = typeof obj.error === "string" ? obj.error : "unknown";
621
+ return `retry: ${error}`;
591
622
  }
592
623
  }
593
624
  return null;
594
625
  }
595
- __name(formatStreamLine, "formatStreamLine");
626
+ __name(formatStreamObject, "formatStreamObject");
596
627
  function createPiStreamFormatter() {
597
628
  const calls = /* @__PURE__ */ new Map();
598
629
  let textBuf = "";
@@ -745,10 +776,15 @@ function spawnAgent(opts) {
745
776
  opts.logPath,
746
777
  { flags: "w" }
747
778
  );
779
+ const env = isClaude && opts.preferSubagent ? {
780
+ ...process.env,
781
+ CLAUDE_CODE_SUBAGENT_MODEL: opts.preferSubagent
782
+ } : void 0;
748
783
  const proc = spawn(opts.agentCmd, args, {
749
784
  stdio: ["pipe", "pipe", "pipe"],
750
785
  cwd: opts.cwd,
751
- detached: true
786
+ detached: true,
787
+ ...env ? { env } : {}
752
788
  });
753
789
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
754
790
  proc.stdout.pipe(logStream, { end: false });
@@ -766,10 +802,26 @@ function spawnAgent(opts) {
766
802
  }
767
803
  }
768
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");
769
821
  const rl = createInterface({ input: proc.stdout });
770
822
  rl.on("line", (line) => {
771
823
  if (isClaude) {
772
- const formatted = formatStreamLine(line);
824
+ const formatted = formatClaudeLine(line);
773
825
  if (formatted !== null) {
774
826
  collectCompleted(formatted);
775
827
  const output = `${prefix}${formatted}`;
@@ -861,7 +913,12 @@ function spawnAgent(opts) {
861
913
  rlErr.close();
862
914
  logStream.end();
863
915
  if (promptFile) cleanupPromptFile(promptFile);
864
- resolve({ exitCode, completedItems, timedOut });
916
+ resolve({
917
+ exitCode,
918
+ completedItems,
919
+ timedOut,
920
+ ...isClaude ? streamStats.stats : {}
921
+ });
865
922
  }
866
923
  __name(finish, "finish");
867
924
  proc.on("exit", (code) => {
@@ -905,7 +962,7 @@ function spawnAgent(opts) {
905
962
  });
906
963
  }
907
964
  __name(spawnAgent, "spawnAgent");
908
- function dryRun(tickets, specBody, testCmd) {
965
+ function dryRun(tickets, specBody, testCmd, preferSubagent) {
909
966
  listTickets(tickets);
910
967
  console.log("\nSimulated wave order:");
911
968
  for (const { wave, ready } of simulateWaves(tickets)) {
@@ -921,6 +978,7 @@ function dryRun(tickets, specBody, testCmd) {
921
978
  }
922
979
  console.log(`
923
980
  Test command: ${testCmd || "(none)"}`);
981
+ console.log(`Prefer subagent: ${preferSubagent || "(none)"}`);
924
982
  const firstReady = findReadyTickets(tickets);
925
983
  if (firstReady.length > 0) {
926
984
  console.log(
@@ -932,7 +990,7 @@ Prompt for first ready ticket (${firstReady[0].filename}):
932
990
  renderPrompt(
933
991
  specBody,
934
992
  firstReady[0],
935
- { testCmd }
993
+ { testCmd, preferSubagent }
936
994
  )
937
995
  );
938
996
  }
@@ -978,7 +1036,8 @@ Prompt for first ready ticket (${firstReady[0].filename}):
978
1036
  firstReady[0],
979
1037
  {
980
1038
  testCmd: opts.testCmd,
981
- installCmd: opts.installCmd
1039
+ installCmd: opts.installCmd,
1040
+ preferSubagent: opts.preferSubagent
982
1041
  }
983
1042
  )
984
1043
  );
@@ -994,6 +1053,9 @@ Test command: ${opts.testCmd || "(none)"}`
994
1053
  console.log(
995
1054
  `Effort: ${opts.effort ?? "(provider default)"}`
996
1055
  );
1056
+ console.log(
1057
+ `Prefer subagent: ${opts.preferSubagent || "(none)"}`
1058
+ );
997
1059
  console.log(
998
1060
  `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
999
1061
  );
@@ -1094,7 +1156,10 @@ async function respawnAndRetryMerge(opts) {
1094
1156
  const prompt = renderMergePrompt(
1095
1157
  opts.specBody,
1096
1158
  opts.ticket,
1097
- { installCmd: opts.installCmd }
1159
+ {
1160
+ installCmd: opts.installCmd,
1161
+ preferSubagent: opts.preferSubagent
1162
+ }
1098
1163
  );
1099
1164
  const logPath = join(
1100
1165
  opts.logsDir,
@@ -1113,8 +1178,10 @@ async function respawnAndRetryMerge(opts) {
1113
1178
  cwd: opts.worktreePath,
1114
1179
  label,
1115
1180
  maxTurns: opts.maxTurns,
1181
+ preferSubagent: opts.preferSubagent,
1116
1182
  onLine: opts.onLine
1117
1183
  });
1184
+ opts.onAgentRun?.(result);
1118
1185
  if (result.exitCode !== 0) {
1119
1186
  const msg2 = ` ${opts.ticket.filename}: respawned agent exited ${result.exitCode}`;
1120
1187
  if (opts.onLine) {
@@ -1199,17 +1266,29 @@ function attemptLogPath(logsDir, ticket, attempt, ext) {
1199
1266
  return join(logsDir, `${stem}${suffix}${ext}`);
1200
1267
  }
1201
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");
1202
1289
  function buildHistory(learningsPath, ticket, d) {
1203
1290
  try {
1204
- return renderHistory(selectHistory(
1205
- readEntries(learningsPath),
1206
- {
1207
- key: ticketKey(ticket.feature, ticket.num),
1208
- blockerKeys: ticket.blockers.map((b) => {
1209
- return ticketKey(ticket.feature, b);
1210
- })
1211
- }
1212
- ));
1291
+ return historyFor(learningsPath, ticket);
1213
1292
  } catch (err) {
1214
1293
  d.log(
1215
1294
  ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
@@ -1286,7 +1365,8 @@ async function runTicketAttempt(opts) {
1286
1365
  opts.learningsPath,
1287
1366
  ticket,
1288
1367
  d
1289
- )
1368
+ ),
1369
+ preferSubagent: opts.preferSubagent
1290
1370
  }
1291
1371
  ),
1292
1372
  agentCmd: opts.agentCmd,
@@ -1302,8 +1382,11 @@ async function runTicketAttempt(opts) {
1302
1382
  label: ticket.filename.replace(/\.md$/, ""),
1303
1383
  maxTurns: opts.maxTurns,
1304
1384
  effort: opts.effort,
1385
+ preferSubagent: opts.preferSubagent,
1386
+ onStats: opts.onStats,
1305
1387
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1306
1388
  });
1389
+ opts.onAgentRun?.(agentResult);
1307
1390
  let notes = null;
1308
1391
  try {
1309
1392
  notes = collectNotes(worktree.worktreePath);
@@ -1316,7 +1399,9 @@ async function runTicketAttempt(opts) {
1316
1399
  exitCode: agentResult.exitCode,
1317
1400
  completedItems: agentResult.completedItems,
1318
1401
  timedOut: agentResult.timedOut,
1319
- notes
1402
+ notes,
1403
+ ...agentResult.subagents === void 0 ? {} : { subagents: agentResult.subagents },
1404
+ ...agentResult.costUsd === void 0 ? {} : { costUsd: agentResult.costUsd }
1320
1405
  };
1321
1406
  if (agentResult.timedOut) {
1322
1407
  d.log(
@@ -1484,6 +1569,10 @@ function recordAttempt(opts) {
1484
1569
  __name(recordAttempt, "recordAttempt");
1485
1570
  async function runTicketAttempts(opts) {
1486
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");
1487
1576
  return runWithRetries({
1488
1577
  maxAttempts: opts.retries + 1,
1489
1578
  retryDelay: opts.retryDelay,
@@ -1508,7 +1597,12 @@ async function runTicketAttempts(opts) {
1508
1597
  outcome = await runTicketAttempt({
1509
1598
  ...opts,
1510
1599
  attempt,
1511
- 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")
1512
1606
  });
1513
1607
  } catch (error) {
1514
1608
  d.log(
@@ -1566,15 +1660,77 @@ function indentFilenames(tickets) {
1566
1660
  return tickets.map((t) => ` ${t.filename}`).join("\n");
1567
1661
  }
1568
1662
  __name(indentFilenames, "indentFilenames");
1569
- function renderFinishSummary(tickets, totalCompleted, failedKeys) {
1663
+ function explainOpen(tickets, failedKeys, warnings) {
1570
1664
  const remaining = tickets.filter(
1571
1665
  (t) => t.status === "ready-for-agent"
1572
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 = [];
1573
1673
  const human = tickets.filter((t) => t.status === HUMAN_STATUS);
1574
- const humanNums = new Set(human.map((t) => t.num));
1575
- const blockedByHuman = remaining.filter(
1576
- (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")
1577
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"
1732
+ );
1733
+ const human = tickets.filter((t) => t.status === HUMAN_STATUS);
1578
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` : "");
1579
1735
  const lines = [counts];
1580
1736
  const features = new Set(
@@ -1603,26 +1759,54 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
1603
1759
  lines.push(detail);
1604
1760
  }
1605
1761
  }
1606
- if (human.length === 0) return lines.join("\n");
1607
- const noun = human.length === 1 ? "ticket is" : "tickets are";
1608
- lines.push(
1609
- "",
1610
- (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:",
1611
- 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])
1612
1778
  );
1613
- if (blockedByHuman.length > 0) {
1614
- 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) {
1615
1793
  lines.push(
1616
- `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
1617
- 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."
1618
1805
  );
1619
1806
  }
1620
- lines.push(
1621
- "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
1622
- );
1623
1807
  return lines.join("\n");
1624
1808
  }
1625
- __name(renderFinishSummary, "renderFinishSummary");
1809
+ __name(renderNothingReady, "renderNothingReady");
1626
1810
  function sessionTickets(tickets) {
1627
1811
  return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
1628
1812
  }
@@ -1641,7 +1825,10 @@ function reportBlocked(d, tickets, failedKeys) {
1641
1825
  )) {
1642
1826
  const ticket = byKey.get(key);
1643
1827
  if (!ticket) continue;
1644
- 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
+ });
1645
1832
  d.setBlocked(ticket.num, ticket.feature, labels);
1646
1833
  }
1647
1834
  }
@@ -1653,7 +1840,7 @@ function runProgress(tickets) {
1653
1840
  };
1654
1841
  }
1655
1842
  __name(runProgress, "runProgress");
1656
- 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) {
1657
1844
  mkdirSync(logsDir, { recursive: true });
1658
1845
  const d = display ?? createDisplay();
1659
1846
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
@@ -1667,6 +1854,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1667
1854
  let waveNum = 0;
1668
1855
  const failedKeys = /* @__PURE__ */ new Set();
1669
1856
  const session = sessionTickets(tickets);
1857
+ let runStats = emptyRunStats();
1670
1858
  while (true) {
1671
1859
  if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
1672
1860
  const ready = findReadyTickets(tickets).filter(
@@ -1697,7 +1885,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1697
1885
  const prompt = renderPrompt(
1698
1886
  specBody,
1699
1887
  t,
1700
- { testCmd }
1888
+ { testCmd, preferSubagent }
1701
1889
  );
1702
1890
  const label = t.filename.replace(
1703
1891
  /\.md$/,
@@ -1709,6 +1897,18 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1709
1897
  t.feature
1710
1898
  );
1711
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");
1712
1912
  let level = effort;
1713
1913
  let result = await spawnAgent({
1714
1914
  prompt,
@@ -1719,8 +1919,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1719
1919
  label,
1720
1920
  maxTurns,
1721
1921
  effort: level,
1922
+ preferSubagent,
1923
+ onStats,
1722
1924
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1723
1925
  });
1926
+ tally(result);
1724
1927
  for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
1725
1928
  if (isKnown) {
1726
1929
  level = escalateEffort(
@@ -1747,8 +1950,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1747
1950
  label,
1748
1951
  maxTurns,
1749
1952
  effort: level,
1953
+ preferSubagent,
1954
+ onStats,
1750
1955
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1751
1956
  });
1957
+ tally(result);
1752
1958
  }
1753
1959
  return { ticket: t, result };
1754
1960
  }
@@ -1807,8 +2013,11 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
1807
2013
  d.log("\n" + renderFinishSummary(
1808
2014
  tickets,
1809
2015
  totalCompleted,
1810
- failedKeys
2016
+ failedKeys,
2017
+ warnings
1811
2018
  ));
2019
+ const statsLine = renderRunStats(runStats);
2020
+ if (statsLine) d.log(statsLine);
1812
2021
  d.cleanup();
1813
2022
  return { allDone: remaining.length === 0 };
1814
2023
  }
@@ -1895,6 +2104,10 @@ async function runWavesIsolated(opts) {
1895
2104
  let waveNum = 0;
1896
2105
  const failedKeys = /* @__PURE__ */ new Set();
1897
2106
  const session = sessionTickets(opts.tickets);
2107
+ let runStats = emptyRunStats();
2108
+ const onAgentRun = /* @__PURE__ */ __name((result) => {
2109
+ runStats = addRun(runStats, result);
2110
+ }, "onAgentRun");
1898
2111
  while (true) {
1899
2112
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
1900
2113
  const ready = findReadyTickets(opts.tickets).filter(
@@ -1936,12 +2149,14 @@ async function runWavesIsolated(opts) {
1936
2149
  installCmd: opts.installCmd,
1937
2150
  maxTurns: opts.maxTurns,
1938
2151
  effort: opts.effort,
2152
+ preferSubagent: opts.preferSubagent,
1939
2153
  learningsPath: learningsPathFor(
1940
2154
  tracker.pathFor(t.feature)
1941
2155
  ),
1942
2156
  retries,
1943
2157
  retryDelay,
1944
- d
2158
+ d,
2159
+ onAgentRun
1945
2160
  })
1946
2161
  );
1947
2162
  const touched = new Set(ready.map((t) => {
@@ -2161,7 +2376,9 @@ async function runWavesIsolated(opts) {
2161
2376
  testCmd: opts.testCmd,
2162
2377
  installCmd: opts.installCmd,
2163
2378
  maxTurns: opts.maxTurns,
2164
- onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
2379
+ preferSubagent: opts.preferSubagent,
2380
+ onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine"),
2381
+ onAgentRun
2165
2382
  }) : false;
2166
2383
  if (recovered) {
2167
2384
  markDone(ticket);
@@ -2318,10 +2535,18 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
2318
2535
  installCmd: opts.installCmd,
2319
2536
  maxTurns: opts.maxTurns,
2320
2537
  effort: opts.effort,
2538
+ preferSubagent: opts.preferSubagent,
2321
2539
  retries,
2322
2540
  retryDelay,
2323
2541
  log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
2324
- }, { spawnAgent, registry: runningAgents });
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
+ });
2325
2550
  reviewLine = renderReviewSummary(
2326
2551
  outcome,
2327
2552
  relative(repoRoot, opts.review.reviewPath)
@@ -2337,8 +2562,11 @@ Review failed: ${formatErrorMessage(err)}`);
2337
2562
  d.log("\n" + renderFinishSummary(
2338
2563
  opts.tickets,
2339
2564
  totalCompleted,
2340
- failedKeys
2565
+ failedKeys,
2566
+ opts.warnings ?? []
2341
2567
  ));
2568
+ const statsLine = renderRunStats(runStats);
2569
+ if (statsLine) d.log(statsLine);
2342
2570
  if (reviewLine) d.log(reviewLine);
2343
2571
  d.log(`Work is on ${baseBranch}`);
2344
2572
  d.cleanup();
@@ -2355,6 +2583,8 @@ export {
2355
2583
  HUMAN_STATUS,
2356
2584
  TICKET_RE,
2357
2585
  TOOL_USE_RE,
2586
+ blockerLabel,
2587
+ blockerSatisfied,
2358
2588
  checkItems,
2359
2589
  createPiStreamFormatter,
2360
2590
  dryRun,
@@ -2365,15 +2595,17 @@ export {
2365
2595
  formatErrorMessage,
2366
2596
  formatStreamLine,
2367
2597
  getProviderArgs,
2598
+ historyFor,
2368
2599
  killProcessGroup,
2369
2600
  killRunningAgents,
2370
2601
  lookupSpec,
2371
2602
  markDone,
2372
- parseBlockers,
2603
+ nothingReady,
2373
2604
  parseStatus,
2374
2605
  parseTicketFilename,
2375
2606
  renderFinishSummary,
2376
2607
  renderMergePrompt,
2608
+ renderNothingReady,
2377
2609
  renderPrompt,
2378
2610
  retryDelayFor2 as retryDelayFor,
2379
2611
  runPool,