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.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
5
5
  // src/index.ts
6
6
  import { execFileSync as execFileSync2 } from "node:child_process";
7
7
  import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
8
- import { dirname as dirname6, join as join9 } from "node:path";
8
+ import { basename as basename6, dirname as dirname6, join as join9 } from "node:path";
9
9
  import yargs from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
11
  import { select } from "@inquirer/prompts";
@@ -973,6 +973,106 @@ async function mergeWave(opts) {
973
973
  }
974
974
  __name(mergeWave, "mergeWave");
975
975
 
976
+ // src/stats.ts
977
+ var SUBAGENT_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
978
+ function isRecord(value) {
979
+ return typeof value === "object" && value !== null && !Array.isArray(value);
980
+ }
981
+ __name(isRecord, "isRecord");
982
+ function emptyStreamStats() {
983
+ return { stats: { subagents: 0 }, seenToolIds: /* @__PURE__ */ new Set() };
984
+ }
985
+ __name(emptyStreamStats, "emptyStreamStats");
986
+ function nextStreamStats(state, obj) {
987
+ if (!isRecord(obj)) return state;
988
+ if (obj.type === "assistant") return countSubagents(state, obj);
989
+ if (obj.type === "result") return applyResult(state, obj);
990
+ return state;
991
+ }
992
+ __name(nextStreamStats, "nextStreamStats");
993
+ function countSubagents(state, obj) {
994
+ if (obj.parent_tool_use_id != null) return state;
995
+ const message = obj.message;
996
+ if (!isRecord(message) || !Array.isArray(message.content)) {
997
+ return state;
998
+ }
999
+ const ids = [];
1000
+ for (const block of message.content) {
1001
+ if (!isRecord(block) || block.type !== "tool_use") continue;
1002
+ if (typeof block.name !== "string" || !SUBAGENT_TOOLS.has(block.name)) continue;
1003
+ if (typeof block.id !== "string") continue;
1004
+ if (state.seenToolIds.has(block.id)) continue;
1005
+ if (ids.includes(block.id)) continue;
1006
+ ids.push(block.id);
1007
+ }
1008
+ if (ids.length === 0) return state;
1009
+ return {
1010
+ stats: {
1011
+ ...state.stats,
1012
+ subagents: state.stats.subagents + ids.length
1013
+ },
1014
+ seenToolIds: /* @__PURE__ */ new Set([...state.seenToolIds, ...ids])
1015
+ };
1016
+ }
1017
+ __name(countSubagents, "countSubagents");
1018
+ function applyResult(state, obj) {
1019
+ const cost = obj.total_cost_usd;
1020
+ const hasCost = typeof cost === "number" && Number.isFinite(cost);
1021
+ const subagentStats = obj.subagent_stats;
1022
+ const spawned = isRecord(subagentStats) ? subagentStats.spawned : void 0;
1023
+ const hasSpawned = typeof spawned === "number" && Number.isInteger(spawned) && spawned >= 0;
1024
+ if (!hasCost && !hasSpawned) return state;
1025
+ const subagents = hasSpawned ? spawned : state.stats.subagents;
1026
+ const costUsd = hasCost ? cost : state.stats.costUsd;
1027
+ if (subagents === state.stats.subagents && costUsd === state.stats.costUsd) return state;
1028
+ return {
1029
+ stats: costUsd === void 0 ? { subagents } : { subagents, costUsd },
1030
+ seenToolIds: state.seenToolIds
1031
+ };
1032
+ }
1033
+ __name(applyResult, "applyResult");
1034
+ function addStats(total, run) {
1035
+ const subagents = total.subagents + (run.subagents ?? 0);
1036
+ if (total.costUsd === void 0 && run.costUsd === void 0) {
1037
+ return { subagents };
1038
+ }
1039
+ return {
1040
+ subagents,
1041
+ costUsd: (total.costUsd ?? 0) + (run.costUsd ?? 0)
1042
+ };
1043
+ }
1044
+ __name(addStats, "addStats");
1045
+ function formatStats(stats) {
1046
+ if (!stats) return null;
1047
+ if (stats.subagents === 0 && stats.costUsd === void 0) {
1048
+ return null;
1049
+ }
1050
+ const count = `${stats.subagents} ` + (stats.subagents === 1 ? "subagent" : "subagents");
1051
+ return stats.costUsd === void 0 ? count : `${count}, $${stats.costUsd.toFixed(2)}`;
1052
+ }
1053
+ __name(formatStats, "formatStats");
1054
+ function emptyRunStats() {
1055
+ return { agentRuns: 0, withCost: 0, subagents: 0, costUsd: 0 };
1056
+ }
1057
+ __name(emptyRunStats, "emptyRunStats");
1058
+ function addRun(run, result) {
1059
+ return {
1060
+ agentRuns: run.agentRuns + 1,
1061
+ withCost: run.withCost + (result.costUsd === void 0 ? 0 : 1),
1062
+ subagents: run.subagents + (result.subagents ?? 0),
1063
+ costUsd: run.costUsd + (result.costUsd ?? 0)
1064
+ };
1065
+ }
1066
+ __name(addRun, "addRun");
1067
+ function renderRunStats(run) {
1068
+ if (run.agentRuns === 0) return null;
1069
+ if (run.withCost === 0 && run.subagents === 0) return null;
1070
+ const runs = `${run.agentRuns} agent ` + (run.agentRuns === 1 ? "run" : "runs");
1071
+ const partial = run.withCost < run.agentRuns ? ` (cost from ${run.withCost} of ${run.agentRuns} runs)` : "";
1072
+ return `Subagents: ${run.subagents} started across ${runs}, cost $${run.costUsd.toFixed(2)}${partial}`;
1073
+ }
1074
+ __name(renderRunStats, "renderRunStats");
1075
+
976
1076
  // src/display.ts
977
1077
  var ESC = "\x1B[";
978
1078
  var ansi = {
@@ -1034,10 +1134,9 @@ var MAX_MESSAGE_BUFFER = 200;
1034
1134
  var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
1035
1135
  function rowText(ticket) {
1036
1136
  const label = ticketLabel(ticket);
1037
- if (ticket.status !== "blocked" || !ticket.blockedBy?.length) {
1038
- return label;
1039
- }
1040
- return `${label} (blocked by ${ticket.blockedBy.join(", ")})`;
1137
+ const base = ticket.status === "blocked" && ticket.blockedBy?.length ? `${label} (blocked by ${ticket.blockedBy.join(", ")})` : label;
1138
+ const suffix = formatStats(ticket.stats);
1139
+ return suffix === null ? base : `${base} - ${suffix}`;
1041
1140
  }
1042
1141
  __name(rowText, "rowText");
1043
1142
  function fitLine(line, width) {
@@ -1166,6 +1265,12 @@ var StatusDisplay = class {
1166
1265
  ticket.blockedBy = [...blockedBy];
1167
1266
  this.render();
1168
1267
  }
1268
+ setTicketStats(num, feature, stats) {
1269
+ const ticket = this.findTicket(num, feature);
1270
+ if (!ticket) return;
1271
+ ticket.stats = { ...stats };
1272
+ this.render();
1273
+ }
1169
1274
  log(message) {
1170
1275
  if (!this.wave) {
1171
1276
  this.config.stream.write(message + "\n");
@@ -1304,6 +1409,8 @@ Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
1304
1409
  }
1305
1410
  setBlocked(_num, _feature, _blockedBy) {
1306
1411
  }
1412
+ setTicketStats(_num, _feature, _stats) {
1413
+ }
1307
1414
  log(message) {
1308
1415
  console.log(message);
1309
1416
  }
@@ -1453,6 +1560,8 @@ __name(sanitize, "sanitize");
1453
1560
  function renderAgentSummary(run, effort) {
1454
1561
  const effortLine = effort ? `Effort: ${effort}.` : "";
1455
1562
  const exitLine = `Agent exit: ${run.exitCode}.`;
1563
+ const subagentsLine = run.subagents === void 0 ? "" : `Subagents: ${run.subagents}.`;
1564
+ const costLine = run.costUsd === void 0 ? "" : `Cost: $${run.costUsd.toFixed(2)}.`;
1456
1565
  const itemsText = run.completedItems.length > 0 ? run.completedItems.map(
1457
1566
  (item) => `"${sanitize(item)}"`
1458
1567
  ).join(", ") + "." : "none.";
@@ -1460,6 +1569,8 @@ function renderAgentSummary(run, effort) {
1460
1569
  return [
1461
1570
  effortLine,
1462
1571
  exitLine,
1572
+ subagentsLine,
1573
+ costLine,
1463
1574
  itemsLine
1464
1575
  ].filter(Boolean).join(" ");
1465
1576
  }
@@ -1749,6 +1860,18 @@ function installRule(installCmd) {
1749
1860
  ];
1750
1861
  }
1751
1862
  __name(installRule, "installRule");
1863
+ function subagentRule(preferSubagent, readOnly = false) {
1864
+ if (!preferSubagent) return [];
1865
+ return [
1866
+ `- Hand read-only work to subagents: exploring the codebase, searching code, and experiments or test runs whose output would flood your context. Subagents default to the ${preferSubagent} model.`,
1867
+ "- A subagent starts with no context. Brief each one with the objective, what you already know, its boundaries, and the reply you expect (file:line references, or pass/fail with the relevant output).",
1868
+ ...readOnly ? [] : [
1869
+ "- Subagents never edit files or run git commands that write. You make every edit and every commit."
1870
+ ],
1871
+ "- Do not delegate a lookup that takes a single tool call."
1872
+ ];
1873
+ }
1874
+ __name(subagentRule, "subagentRule");
1752
1875
  function testGateRule(testCmd, subject) {
1753
1876
  if (testCmd === "") {
1754
1877
  return `- Before each commit, run the tests relevant to what you changed. Do not commit with failing tests. The orchestrator runs no test command for this ${subject}.`;
@@ -1896,6 +2019,7 @@ function renderReviewPrompt(opts) {
1896
2019
  "- Read any AGENTS.md in or above the directories the change touches, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. Treat their contracts and the spec above as the standard the code must meet.",
1897
2020
  "- Look for correctness bugs, missing or weak tests, and code that breaks a documented contract. Ignore style preferences.",
1898
2021
  "- You may run the tests or the type checker to confirm a suspicion.",
2022
+ ...subagentRule(opts.preferSubagent, true),
1899
2023
  "- Do NOT edit, stage, or commit code. Anything you change here is discarded.",
1900
2024
  ...CONFINEMENT_RULES,
1901
2025
  "",
@@ -1950,6 +2074,7 @@ function renderFixPrompt(opts) {
1950
2074
  readDocsRule("findings"),
1951
2075
  "- Fix ONLY the listed findings. Do not fix unrelated problems you notice.",
1952
2076
  ...installRule(opts.installCmd),
2077
+ ...subagentRule(opts.preferSubagent),
1953
2078
  "- Use /tdd for every finding: write a failing test that shows the problem, then write only enough code to make it pass, then refactor.",
1954
2079
  testGateRule(opts.testCmd, "fix"),
1955
2080
  LINT_RULE,
@@ -2229,7 +2354,8 @@ async function runReviewer(opts, deps, range) {
2229
2354
  exclude: reviewExcludeFor(
2230
2355
  opts.repoRoot,
2231
2356
  opts.reviewPath
2232
- )
2357
+ ),
2358
+ preferSubagent: opts.preferSubagent
2233
2359
  }),
2234
2360
  agentCmd: opts.agentCmd,
2235
2361
  model: opts.model,
@@ -2239,6 +2365,7 @@ async function runReviewer(opts, deps, range) {
2239
2365
  label: "review",
2240
2366
  maxTurns: opts.maxTurns,
2241
2367
  effort,
2368
+ preferSubagent: opts.preferSubagent,
2242
2369
  onLine: opts.log
2243
2370
  });
2244
2371
  const text = collectNotes(wt.worktreePath, FINDINGS_FILE);
@@ -2308,7 +2435,8 @@ async function runFixer(opts, deps, findings) {
2308
2435
  findings,
2309
2436
  testCmd: opts.testCmd,
2310
2437
  installCmd: opts.installCmd,
2311
- previous
2438
+ previous,
2439
+ preferSubagent: opts.preferSubagent
2312
2440
  }),
2313
2441
  agentCmd: opts.agentCmd,
2314
2442
  model: opts.model,
@@ -2323,6 +2451,7 @@ async function runFixer(opts, deps, findings) {
2323
2451
  label: "review-fix",
2324
2452
  maxTurns: opts.maxTurns,
2325
2453
  effort,
2454
+ preferSubagent: opts.preferSubagent,
2326
2455
  onLine: opts.log
2327
2456
  });
2328
2457
  const notes = collectNotes(wt.worktreePath);
@@ -2457,6 +2586,149 @@ Reviewing ${from.slice(0, 7)}..${to.slice(0, 7)}`);
2457
2586
  }
2458
2587
  __name(runReviewPass, "runReviewPass");
2459
2588
 
2589
+ // src/blockers.ts
2590
+ var FIELD_RE = /\*\*Blocked by:\*\*/i;
2591
+ var NEXT_FIELD_RE = /^\s*(?:[-*]\s+)?\*\*[^*\n]+:\*\*/;
2592
+ var HEADING_RE = /^\s*#{1,6}\s/;
2593
+ var CHECKLIST_RE = /^\s*[-*+]\s+\[[ xX]\]/;
2594
+ var TOKEN_RE = /[A-Za-z0-9_-]+/g;
2595
+ function endsField(line) {
2596
+ return line.trim() === "" || NEXT_FIELD_RE.test(line) || HEADING_RE.test(line) || CHECKLIST_RE.test(line);
2597
+ }
2598
+ __name(endsField, "endsField");
2599
+ function fieldText(body) {
2600
+ const lines = body.split(/\r?\n/);
2601
+ const start = lines.findIndex((l) => FIELD_RE.test(l));
2602
+ if (start === -1) return null;
2603
+ const first = lines[start];
2604
+ const m = FIELD_RE.exec(first);
2605
+ const parts = [first.slice(m.index + m[0].length)];
2606
+ for (const line of lines.slice(start + 1)) {
2607
+ if (endsField(line)) break;
2608
+ parts.push(line);
2609
+ }
2610
+ return parts.join("\n");
2611
+ }
2612
+ __name(fieldText, "fieldText");
2613
+ function stripNotes(text) {
2614
+ const chars = [...text];
2615
+ const drop = new Array(chars.length).fill(false);
2616
+ const ticks = [];
2617
+ chars.forEach((ch, i) => {
2618
+ if (ch === "`") ticks.push(i);
2619
+ });
2620
+ for (let i = 0; i + 1 < ticks.length; i += 2) {
2621
+ for (let j = ticks[i]; j <= ticks[i + 1]; j++) {
2622
+ drop[j] = true;
2623
+ }
2624
+ }
2625
+ const open = [];
2626
+ chars.forEach((ch, i) => {
2627
+ if (drop[i]) return;
2628
+ if (ch === "(") {
2629
+ open.push(i);
2630
+ } else if (ch === ")" && open.length > 0) {
2631
+ const from = open.pop();
2632
+ for (let j = from; j <= i; j++) drop[j] = true;
2633
+ }
2634
+ });
2635
+ return chars.map((ch, i) => drop[i] ? " " : ch).join("");
2636
+ }
2637
+ __name(stripNotes, "stripNotes");
2638
+ function looksLikeFeature(word) {
2639
+ const segments = word.split("/");
2640
+ return (segments.length > 1 || word.includes("-")) && /[a-z]/.test(word) && segments.every((s) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s));
2641
+ }
2642
+ __name(looksLikeFeature, "looksLikeFeature");
2643
+ function wordBefore(text, start) {
2644
+ let i = start;
2645
+ while (i > 0 && /\s/.test(text[i - 1])) i--;
2646
+ if (i === start) return null;
2647
+ const end = i;
2648
+ while (i > 0 && /[A-Za-z0-9_/-]/.test(text[i - 1])) i--;
2649
+ return i === end ? null : text.slice(i, end);
2650
+ }
2651
+ __name(wordBefore, "wordBefore");
2652
+ function parseBlockerField(body, knownFeatures) {
2653
+ const text = fieldText(body);
2654
+ if (text === null) return { refs: [], typos: [] };
2655
+ const clean = stripNotes(text);
2656
+ if (/^\s*none\b/i.test(clean)) return { refs: [], typos: [] };
2657
+ const refs = [];
2658
+ const typos = [];
2659
+ const seen = /* @__PURE__ */ new Set();
2660
+ const add = /* @__PURE__ */ __name((num, feature) => {
2661
+ const id = `${feature ?? ""}:${num}`;
2662
+ if (seen.has(id)) return;
2663
+ seen.add(id);
2664
+ refs.push(feature === void 0 ? { num } : { feature, num });
2665
+ }, "add");
2666
+ for (const m of clean.matchAll(TOKEN_RE)) {
2667
+ const word = m[0];
2668
+ if (/^\d+(?:-\d+)*$/.test(word)) {
2669
+ const before = wordBefore(clean, m.index ?? 0);
2670
+ let feature;
2671
+ if (before !== null && knownFeatures.has(before)) {
2672
+ feature = before;
2673
+ } else if (before !== null && looksLikeFeature(before) && !typos.includes(before)) {
2674
+ typos.push(before);
2675
+ }
2676
+ for (const n of word.split("-").map(Number)) add(n, feature);
2677
+ }
2678
+ }
2679
+ return { refs, typos };
2680
+ }
2681
+ __name(parseBlockerField, "parseBlockerField");
2682
+ function refId(feature, num) {
2683
+ return `${feature}:${num}`;
2684
+ }
2685
+ __name(refId, "refId");
2686
+ function resolveBlockers(raw, ownFeature, selected, lookup) {
2687
+ const out = [];
2688
+ const seen = /* @__PURE__ */ new Set();
2689
+ for (const r of raw) {
2690
+ const feature = r.feature ?? ownFeature;
2691
+ const id = refId(feature, r.num);
2692
+ if (seen.has(id)) continue;
2693
+ seen.add(id);
2694
+ const nums = selected.get(feature);
2695
+ if (nums) {
2696
+ out.push({
2697
+ feature,
2698
+ num: r.num,
2699
+ where: nums.has(r.num) ? "run" : "missing"
2700
+ });
2701
+ continue;
2702
+ }
2703
+ const status = lookup(feature, r.num);
2704
+ if (status === void 0) {
2705
+ out.push({ feature, num: r.num, where: "missing" });
2706
+ } else {
2707
+ out.push({
2708
+ feature,
2709
+ num: r.num,
2710
+ where: status === "done" ? "done" : "pending",
2711
+ status
2712
+ });
2713
+ }
2714
+ }
2715
+ return out;
2716
+ }
2717
+ __name(resolveBlockers, "resolveBlockers");
2718
+ function provisionalRefs(raw, ownFeature) {
2719
+ const out = [];
2720
+ const seen = /* @__PURE__ */ new Set();
2721
+ for (const r of raw) {
2722
+ const feature = r.feature ?? ownFeature;
2723
+ const id = refId(feature, r.num);
2724
+ if (seen.has(id)) continue;
2725
+ seen.add(id);
2726
+ out.push({ feature, num: r.num, where: "run" });
2727
+ }
2728
+ return out;
2729
+ }
2730
+ __name(provisionalRefs, "provisionalRefs");
2731
+
2460
2732
  // src/orchestrator.ts
2461
2733
  var execFileAsync4 = promisify4(execFile4);
2462
2734
  function formatErrorMessage(error) {
@@ -2527,21 +2799,7 @@ function parseStatus(body) {
2527
2799
  return m ? m[1].trim().toLowerCase() : "";
2528
2800
  }
2529
2801
  __name(parseStatus, "parseStatus");
2530
- function parseBlockers(body) {
2531
- const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i);
2532
- if (!m) return [];
2533
- const text = m[1];
2534
- if (/none/i.test(text)) return [];
2535
- const nums = [];
2536
- const re = /(\d+)/g;
2537
- let match;
2538
- while (match = re.exec(text)) {
2539
- nums.push(Number(match[1]));
2540
- }
2541
- return nums;
2542
- }
2543
- __name(parseBlockers, "parseBlockers");
2544
- function scanTickets(dir, feature = "") {
2802
+ function scanTickets(dir, feature = "", knownFeatures = /* @__PURE__ */ new Set()) {
2545
2803
  const files = readdirSync2(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
2546
2804
  b,
2547
2805
  void 0,
@@ -2558,7 +2816,10 @@ function scanTickets(dir, feature = "") {
2558
2816
  filepath,
2559
2817
  body,
2560
2818
  status: parseStatus(body),
2561
- blockers: parseBlockers(body),
2819
+ blockers: provisionalRefs(
2820
+ parseBlockerField(body, knownFeatures).refs,
2821
+ feature
2822
+ ),
2562
2823
  feature
2563
2824
  };
2564
2825
  });
@@ -2568,15 +2829,35 @@ function ticketKey(feature, num) {
2568
2829
  return `${feature}:${num}`;
2569
2830
  }
2570
2831
  __name(ticketKey, "ticketKey");
2832
+ function blockerSatisfied(ref, doneKeys) {
2833
+ switch (ref.where) {
2834
+ case "run":
2835
+ return doneKeys.has(ticketKey(ref.feature, ref.num));
2836
+ case "done":
2837
+ return true;
2838
+ case "pending":
2839
+ case "missing":
2840
+ return false;
2841
+ }
2842
+ }
2843
+ __name(blockerSatisfied, "blockerSatisfied");
2844
+ function blockerLabel(ref) {
2845
+ const nn = String(ref.num).padStart(2, "0");
2846
+ const base = ref.feature ? `${ref.feature}/${nn}` : nn;
2847
+ if (ref.where === "pending") {
2848
+ return `${base} (${ref.status ?? "not done"}, not in run)`;
2849
+ }
2850
+ if (ref.where === "missing") return `${base} (not found)`;
2851
+ return base;
2852
+ }
2853
+ __name(blockerLabel, "blockerLabel");
2571
2854
  function findReadyTickets(tickets) {
2572
2855
  const doneKeys = new Set(
2573
2856
  tickets.filter((t) => t.status === "done").map((t) => ticketKey(t.feature, t.num))
2574
2857
  );
2575
2858
  return tickets.filter((t) => {
2576
2859
  if (t.status !== "ready-for-agent") return false;
2577
- return t.blockers.every(
2578
- (b) => doneKeys.has(ticketKey(t.feature, b))
2579
- );
2860
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
2580
2861
  });
2581
2862
  }
2582
2863
  __name(findReadyTickets, "findReadyTickets");
@@ -2585,14 +2866,19 @@ function findBlockedTickets(tickets, failedKeys) {
2585
2866
  tickets.map((t) => [ticketKey(t.feature, t.num), t])
2586
2867
  );
2587
2868
  function collectDead(ticket, visited, found) {
2588
- for (const b of ticket.blockers) {
2589
- const key = ticketKey(ticket.feature, b);
2869
+ for (const ref of ticket.blockers) {
2870
+ const key = ticketKey(ref.feature, ref.num);
2590
2871
  if (visited.has(key)) continue;
2591
2872
  visited.add(key);
2873
+ if (ref.where === "done") continue;
2874
+ if (ref.where === "pending" || ref.where === "missing") {
2875
+ found.set(key, ref);
2876
+ continue;
2877
+ }
2592
2878
  const blocker = byKey.get(key);
2593
2879
  if (!blocker || blocker.status === "done") continue;
2594
2880
  if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
2595
- found.add(key);
2881
+ found.set(key, ref);
2596
2882
  continue;
2597
2883
  }
2598
2884
  collectDead(blocker, visited, found);
@@ -2604,9 +2890,9 @@ function findBlockedTickets(tickets, failedKeys) {
2604
2890
  const key = ticketKey(t.feature, t.num);
2605
2891
  if (t.status !== "ready-for-agent") continue;
2606
2892
  if (failedKeys.has(key)) continue;
2607
- const found = /* @__PURE__ */ new Set();
2893
+ const found = /* @__PURE__ */ new Map();
2608
2894
  collectDead(t, /* @__PURE__ */ new Set([key]), found);
2609
- if (found.size > 0) blocked.set(key, [...found]);
2895
+ if (found.size > 0) blocked.set(key, [...found.values()]);
2610
2896
  }
2611
2897
  return blocked;
2612
2898
  }
@@ -2786,6 +3072,7 @@ function renderPrompt(specBody, ticket, opts) {
2786
3072
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
2787
3073
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
2788
3074
  ...installRule(opts.installCmd),
3075
+ ...subagentRule(opts.preferSubagent),
2789
3076
  "- 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.",
2790
3077
  testGateRule(opts.testCmd, "ticket"),
2791
3078
  LINT_RULE,
@@ -2821,6 +3108,7 @@ function renderMergePrompt(specBody, ticket, opts = {}) {
2821
3108
  "",
2822
3109
  "Do NOT re-implement the ticket from scratch.",
2823
3110
  ...installRule(opts.installCmd),
3111
+ ...subagentRule(opts.preferSubagent),
2824
3112
  "Do NOT read or modify files outside your current directory (the worktree).",
2825
3113
  "Do NOT run git push."
2826
3114
  ].join("\n");
@@ -2829,7 +3117,7 @@ __name(renderMergePrompt, "renderMergePrompt");
2829
3117
  function listTickets(tickets) {
2830
3118
  console.log("\nTickets:");
2831
3119
  for (const t of tickets) {
2832
- const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.join(", ")}]` : "no blockers";
3120
+ const blockerStr = t.blockers.length > 0 ? `blocked by [${t.blockers.map(blockerLabel).join(", ")}]` : "no blockers";
2833
3121
  console.log(
2834
3122
  ` ${t.filename} (${t.status}) ${blockerStr}`
2835
3123
  );
@@ -2853,11 +3141,7 @@ function* simulateWaves(tickets) {
2853
3141
  if (sim.get(key) !== "ready-for-agent") {
2854
3142
  return false;
2855
3143
  }
2856
- return t.blockers.every(
2857
- (b) => doneKeys.has(
2858
- ticketKey(t.feature, b)
2859
- )
2860
- );
3144
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
2861
3145
  });
2862
3146
  if (ready.length === 0) break;
2863
3147
  yield { wave, ready };
@@ -2890,9 +3174,7 @@ function detectDeadlocks(tickets) {
2890
3174
  (t) => ticketKey(t.feature, t.num) === key
2891
3175
  );
2892
3176
  if (ticket.blockers.every(
2893
- (b) => doneKeys.has(
2894
- ticketKey(ticket.feature, b)
2895
- )
3177
+ (r) => blockerSatisfied(r, doneKeys)
2896
3178
  )) {
2897
3179
  doneKeys.add(key);
2898
3180
  readyKeys.delete(key);
@@ -2934,40 +3216,37 @@ function getProviderArgs(cmd) {
2934
3216
  return PROVIDER_ARGS[name] ?? [];
2935
3217
  }
2936
3218
  __name(getProviderArgs, "getProviderArgs");
2937
- function formatStreamLine(line) {
2938
- let obj;
2939
- try {
2940
- obj = JSON.parse(line);
2941
- } catch {
2942
- return line;
2943
- }
3219
+ function formatStreamObject(value) {
3220
+ if (!isRecord(value)) return null;
3221
+ const obj = value;
2944
3222
  if (obj.type === "assistant") {
2945
- const msg = obj.message;
2946
- const content = msg?.content;
3223
+ if (obj.parent_tool_use_id != null) return null;
3224
+ const msg = isRecord(obj.message) ? obj.message : void 0;
3225
+ const content = Array.isArray(msg?.content) ? msg.content : void 0;
2947
3226
  if (!content) return null;
2948
3227
  for (const block of content) {
2949
- if (block.type === "text") {
2950
- return String(block.text);
2951
- }
3228
+ if (!isRecord(block)) continue;
3229
+ if (block.type !== "text") continue;
3230
+ if (typeof block.text === "string") return block.text;
2952
3231
  }
2953
3232
  return null;
2954
3233
  }
2955
3234
  if (obj.type === "result") {
2956
- const sub = obj.subtype ?? "done";
2957
- const cost = obj.total_cost_usd;
2958
- const costStr = cost != null ? ` ($${cost.toFixed(2)})` : "";
3235
+ const sub = typeof obj.subtype === "string" ? obj.subtype : "done";
3236
+ const cost = typeof obj.total_cost_usd === "number" ? obj.total_cost_usd : void 0;
3237
+ const costStr = cost !== void 0 ? ` ($${cost.toFixed(2)})` : "";
2959
3238
  return `${sub}${costStr}`;
2960
3239
  }
2961
3240
  if (obj.type === "system") {
2962
- const sub = obj.subtype;
3241
+ const sub = typeof obj.subtype === "string" ? obj.subtype : void 0;
2963
3242
  if (sub === "api_retry") {
2964
- const error = obj.error;
2965
- return `retry: ${error ?? "unknown"}`;
3243
+ const error = typeof obj.error === "string" ? obj.error : "unknown";
3244
+ return `retry: ${error}`;
2966
3245
  }
2967
3246
  }
2968
3247
  return null;
2969
3248
  }
2970
- __name(formatStreamLine, "formatStreamLine");
3249
+ __name(formatStreamObject, "formatStreamObject");
2971
3250
  function createPiStreamFormatter() {
2972
3251
  const calls = /* @__PURE__ */ new Map();
2973
3252
  let textBuf = "";
@@ -3120,10 +3399,15 @@ function spawnAgent(opts) {
3120
3399
  opts.logPath,
3121
3400
  { flags: "w" }
3122
3401
  );
3402
+ const env = isClaude && opts.preferSubagent ? {
3403
+ ...process.env,
3404
+ CLAUDE_CODE_SUBAGENT_MODEL: opts.preferSubagent
3405
+ } : void 0;
3123
3406
  const proc = spawn2(opts.agentCmd, args, {
3124
3407
  stdio: ["pipe", "pipe", "pipe"],
3125
3408
  cwd: opts.cwd,
3126
- detached: true
3409
+ detached: true,
3410
+ ...env ? { env } : {}
3127
3411
  });
3128
3412
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
3129
3413
  proc.stdout.pipe(logStream, { end: false });
@@ -3141,10 +3425,26 @@ function spawnAgent(opts) {
3141
3425
  }
3142
3426
  }
3143
3427
  __name(collectCompleted, "collectCompleted");
3428
+ let streamStats = emptyStreamStats();
3429
+ function formatClaudeLine(line) {
3430
+ let obj;
3431
+ try {
3432
+ obj = JSON.parse(line);
3433
+ } catch {
3434
+ return line;
3435
+ }
3436
+ const next = nextStreamStats(streamStats, obj);
3437
+ if (next !== streamStats) {
3438
+ streamStats = next;
3439
+ opts.onStats?.(next.stats);
3440
+ }
3441
+ return formatStreamObject(obj);
3442
+ }
3443
+ __name(formatClaudeLine, "formatClaudeLine");
3144
3444
  const rl = createInterface({ input: proc.stdout });
3145
3445
  rl.on("line", (line) => {
3146
3446
  if (isClaude) {
3147
- const formatted = formatStreamLine(line);
3447
+ const formatted = formatClaudeLine(line);
3148
3448
  if (formatted !== null) {
3149
3449
  collectCompleted(formatted);
3150
3450
  const output = `${prefix}${formatted}`;
@@ -3236,7 +3536,12 @@ function spawnAgent(opts) {
3236
3536
  rlErr.close();
3237
3537
  logStream.end();
3238
3538
  if (promptFile) cleanupPromptFile(promptFile);
3239
- resolve3({ exitCode, completedItems, timedOut });
3539
+ resolve3({
3540
+ exitCode,
3541
+ completedItems,
3542
+ timedOut,
3543
+ ...isClaude ? streamStats.stats : {}
3544
+ });
3240
3545
  }
3241
3546
  __name(finish, "finish");
3242
3547
  proc.on("exit", (code) => {
@@ -3280,7 +3585,7 @@ function spawnAgent(opts) {
3280
3585
  });
3281
3586
  }
3282
3587
  __name(spawnAgent, "spawnAgent");
3283
- function dryRun(tickets, specBody, testCmd) {
3588
+ function dryRun(tickets, specBody, testCmd, preferSubagent) {
3284
3589
  listTickets(tickets);
3285
3590
  console.log("\nSimulated wave order:");
3286
3591
  for (const { wave, ready } of simulateWaves(tickets)) {
@@ -3296,6 +3601,7 @@ function dryRun(tickets, specBody, testCmd) {
3296
3601
  }
3297
3602
  console.log(`
3298
3603
  Test command: ${testCmd || "(none)"}`);
3604
+ console.log(`Prefer subagent: ${preferSubagent || "(none)"}`);
3299
3605
  const firstReady = findReadyTickets(tickets);
3300
3606
  if (firstReady.length > 0) {
3301
3607
  console.log(
@@ -3307,7 +3613,7 @@ Prompt for first ready ticket (${firstReady[0].filename}):
3307
3613
  renderPrompt(
3308
3614
  specBody,
3309
3615
  firstReady[0],
3310
- { testCmd }
3616
+ { testCmd, preferSubagent }
3311
3617
  )
3312
3618
  );
3313
3619
  }
@@ -3353,7 +3659,8 @@ Prompt for first ready ticket (${firstReady[0].filename}):
3353
3659
  firstReady[0],
3354
3660
  {
3355
3661
  testCmd: opts.testCmd,
3356
- installCmd: opts.installCmd
3662
+ installCmd: opts.installCmd,
3663
+ preferSubagent: opts.preferSubagent
3357
3664
  }
3358
3665
  )
3359
3666
  );
@@ -3369,6 +3676,9 @@ Test command: ${opts.testCmd || "(none)"}`
3369
3676
  console.log(
3370
3677
  `Effort: ${opts.effort ?? "(provider default)"}`
3371
3678
  );
3679
+ console.log(
3680
+ `Prefer subagent: ${opts.preferSubagent || "(none)"}`
3681
+ );
3372
3682
  console.log(
3373
3683
  `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
3374
3684
  );
@@ -3469,7 +3779,10 @@ async function respawnAndRetryMerge(opts) {
3469
3779
  const prompt = renderMergePrompt(
3470
3780
  opts.specBody,
3471
3781
  opts.ticket,
3472
- { installCmd: opts.installCmd }
3782
+ {
3783
+ installCmd: opts.installCmd,
3784
+ preferSubagent: opts.preferSubagent
3785
+ }
3473
3786
  );
3474
3787
  const logPath2 = join6(
3475
3788
  opts.logsDir,
@@ -3488,8 +3801,10 @@ async function respawnAndRetryMerge(opts) {
3488
3801
  cwd: opts.worktreePath,
3489
3802
  label,
3490
3803
  maxTurns: opts.maxTurns,
3804
+ preferSubagent: opts.preferSubagent,
3491
3805
  onLine: opts.onLine
3492
3806
  });
3807
+ opts.onAgentRun?.(result);
3493
3808
  if (result.exitCode !== 0) {
3494
3809
  const msg2 = ` ${opts.ticket.filename}: respawned agent exited ${result.exitCode}`;
3495
3810
  if (opts.onLine) {
@@ -3574,17 +3889,29 @@ function attemptLogPath(logsDir, ticket, attempt, ext) {
3574
3889
  return join6(logsDir, `${stem}${suffix}${ext}`);
3575
3890
  }
3576
3891
  __name(attemptLogPath, "attemptLogPath");
3892
+ function historyFor(learningsPath, ticket) {
3893
+ const featureDir = dirname4(dirname4(ticket.filepath));
3894
+ const scratchDir = ticket.feature ? join6(featureDir, ...ticket.feature.split("/").map(() => "..")) : dirname4(featureDir);
3895
+ const others = new Set(
3896
+ ticket.blockers.map((r) => r.feature).filter((f) => f !== ticket.feature)
3897
+ );
3898
+ const entries = [
3899
+ ...readEntries(learningsPath),
3900
+ ...[...others].flatMap((f) => readEntries(
3901
+ join6(scratchDir, f, "learnings.md")
3902
+ ))
3903
+ ];
3904
+ return renderHistory(selectHistory(entries, {
3905
+ key: ticketKey(ticket.feature, ticket.num),
3906
+ blockerKeys: ticket.blockers.map((r) => {
3907
+ return ticketKey(r.feature, r.num);
3908
+ })
3909
+ }));
3910
+ }
3911
+ __name(historyFor, "historyFor");
3577
3912
  function buildHistory(learningsPath, ticket, d) {
3578
3913
  try {
3579
- return renderHistory(selectHistory(
3580
- readEntries(learningsPath),
3581
- {
3582
- key: ticketKey(ticket.feature, ticket.num),
3583
- blockerKeys: ticket.blockers.map((b) => {
3584
- return ticketKey(ticket.feature, b);
3585
- })
3586
- }
3587
- ));
3914
+ return historyFor(learningsPath, ticket);
3588
3915
  } catch (err) {
3589
3916
  d.log(
3590
3917
  ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
@@ -3661,7 +3988,8 @@ async function runTicketAttempt(opts) {
3661
3988
  opts.learningsPath,
3662
3989
  ticket,
3663
3990
  d
3664
- )
3991
+ ),
3992
+ preferSubagent: opts.preferSubagent
3665
3993
  }
3666
3994
  ),
3667
3995
  agentCmd: opts.agentCmd,
@@ -3677,8 +4005,11 @@ async function runTicketAttempt(opts) {
3677
4005
  label: ticket.filename.replace(/\.md$/, ""),
3678
4006
  maxTurns: opts.maxTurns,
3679
4007
  effort: opts.effort,
4008
+ preferSubagent: opts.preferSubagent,
4009
+ onStats: opts.onStats,
3680
4010
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
3681
4011
  });
4012
+ opts.onAgentRun?.(agentResult);
3682
4013
  let notes = null;
3683
4014
  try {
3684
4015
  notes = collectNotes(worktree.worktreePath);
@@ -3691,7 +4022,9 @@ async function runTicketAttempt(opts) {
3691
4022
  exitCode: agentResult.exitCode,
3692
4023
  completedItems: agentResult.completedItems,
3693
4024
  timedOut: agentResult.timedOut,
3694
- notes
4025
+ notes,
4026
+ ...agentResult.subagents === void 0 ? {} : { subagents: agentResult.subagents },
4027
+ ...agentResult.costUsd === void 0 ? {} : { costUsd: agentResult.costUsd }
3695
4028
  };
3696
4029
  if (agentResult.timedOut) {
3697
4030
  d.log(
@@ -3859,6 +4192,10 @@ function recordAttempt(opts) {
3859
4192
  __name(recordAttempt, "recordAttempt");
3860
4193
  async function runTicketAttempts(opts) {
3861
4194
  const { ticket, d } = opts;
4195
+ let prior = { subagents: 0 };
4196
+ const showStats = /* @__PURE__ */ __name((stats) => {
4197
+ d.setTicketStats(ticket.num, ticket.feature, stats);
4198
+ }, "showStats");
3862
4199
  return runWithRetries({
3863
4200
  maxAttempts: opts.retries + 1,
3864
4201
  retryDelay: opts.retryDelay,
@@ -3883,7 +4220,12 @@ async function runTicketAttempts(opts) {
3883
4220
  outcome = await runTicketAttempt({
3884
4221
  ...opts,
3885
4222
  attempt,
3886
- effort
4223
+ effort,
4224
+ onStats: /* @__PURE__ */ __name((stats) => showStats(addStats(prior, stats)), "onStats"),
4225
+ onAgentRun: /* @__PURE__ */ __name((result) => {
4226
+ prior = addStats(prior, result);
4227
+ opts.onAgentRun?.(result);
4228
+ }, "onAgentRun")
3887
4229
  });
3888
4230
  } catch (error) {
3889
4231
  d.log(
@@ -3941,15 +4283,77 @@ function indentFilenames(tickets) {
3941
4283
  return tickets.map((t) => ` ${t.filename}`).join("\n");
3942
4284
  }
3943
4285
  __name(indentFilenames, "indentFilenames");
3944
- function renderFinishSummary(tickets, totalCompleted, failedKeys) {
4286
+ function explainOpen(tickets, failedKeys, warnings) {
3945
4287
  const remaining = tickets.filter(
3946
4288
  (t) => t.status === "ready-for-agent"
3947
4289
  );
4290
+ const open = remaining.filter(
4291
+ (t) => !failedKeys.has(ticketKey(t.feature, t.num))
4292
+ );
4293
+ const dead = findBlockedTickets(tickets, failedKeys);
4294
+ const deadRefs = /* @__PURE__ */ __name((t) => dead.get(ticketKey(t.feature, t.num)) ?? [], "deadRefs");
4295
+ const lines = [];
3948
4296
  const human = tickets.filter((t) => t.status === HUMAN_STATUS);
3949
- const humanNums = new Set(human.map((t) => t.num));
3950
- const blockedByHuman = remaining.filter(
3951
- (t) => !failedKeys.has(ticketKey(t.feature, t.num)) && t.blockers.some((b) => humanNums.has(b))
4297
+ if (human.length > 0) {
4298
+ const humanKeys = new Set(
4299
+ human.map((t) => ticketKey(t.feature, t.num))
4300
+ );
4301
+ const blockedByHuman = open.filter((t) => deadRefs(t).some(
4302
+ (r) => humanKeys.has(ticketKey(r.feature, r.num))
4303
+ ));
4304
+ const noun = human.length === 1 ? "ticket is" : "tickets are";
4305
+ lines.push(
4306
+ "",
4307
+ (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:",
4308
+ indentFilenames(human)
4309
+ );
4310
+ if (blockedByHuman.length > 0) {
4311
+ const n = blockedByHuman.length;
4312
+ lines.push(
4313
+ `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
4314
+ indentFilenames(blockedByHuman)
4315
+ );
4316
+ }
4317
+ lines.push(
4318
+ "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
4319
+ );
4320
+ }
4321
+ const blockers = [];
4322
+ const outside = open.filter(
4323
+ (t) => deadRefs(t).some((r) => r.where === "pending")
3952
4324
  );
4325
+ if (outside.length > 0) {
4326
+ const n = outside.length;
4327
+ blockers.push(
4328
+ `${n} ready-for-agent ticket${n === 1 ? " waits" : "s wait"} on tickets outside this run. Select those features too, or finish them first:`,
4329
+ ...outside.map((t) => ` ${ticketLabel(toRef(t))} waits on ` + deadRefs(t).filter((r) => r.where === "pending").map(blockerLabel).join(", "))
4330
+ );
4331
+ }
4332
+ const missing = open.flatMap((t) => t.blockers.filter((r) => r.where === "missing").map((r) => ` ${ticketLabel(toRef(t))}: ${blockerLabel(r)}`));
4333
+ if (missing.length > 0) {
4334
+ const n = missing.length;
4335
+ blockers.push(
4336
+ `${n} blocker reference${n === 1 ? "" : "s"} match no ticket. Fix the **Blocked by:** line:`,
4337
+ ...missing
4338
+ );
4339
+ }
4340
+ const typos = warnings.filter((w) => w.kind === "typo");
4341
+ if (typos.length > 0) {
4342
+ const n = typos.length;
4343
+ blockers.push(
4344
+ `${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:`,
4345
+ ...typos.map((w) => ` ${w.ticket}: ${w.text}`)
4346
+ );
4347
+ }
4348
+ if (blockers.length > 0) lines.push("", ...blockers);
4349
+ return lines;
4350
+ }
4351
+ __name(explainOpen, "explainOpen");
4352
+ function renderFinishSummary(tickets, totalCompleted, failedKeys, warnings = []) {
4353
+ const remaining = tickets.filter(
4354
+ (t) => t.status === "ready-for-agent"
4355
+ );
4356
+ const human = tickets.filter((t) => t.status === HUMAN_STATUS);
3953
4357
  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` : "");
3954
4358
  const lines = [counts];
3955
4359
  const features = new Set(
@@ -3978,26 +4382,54 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
3978
4382
  lines.push(detail);
3979
4383
  }
3980
4384
  }
3981
- if (human.length === 0) return lines.join("\n");
3982
- const noun = human.length === 1 ? "ticket is" : "tickets are";
3983
- lines.push(
3984
- "",
3985
- (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:",
3986
- indentFilenames(human)
4385
+ lines.push(...explainOpen(tickets, failedKeys, warnings));
4386
+ return lines.join("\n");
4387
+ }
4388
+ __name(renderFinishSummary, "renderFinishSummary");
4389
+ function nothingReady(tickets) {
4390
+ return findReadyTickets(tickets).length === 0 && tickets.some((t) => t.status === "ready-for-agent");
4391
+ }
4392
+ __name(nothingReady, "nothingReady");
4393
+ function renderNothingReady(tickets, warnings) {
4394
+ const lines = [
4395
+ "Nothing to run: every ready-for-agent ticket is waiting on a blocker.",
4396
+ ...explainOpen(tickets, /* @__PURE__ */ new Set(), warnings)
4397
+ ];
4398
+ const dead = findBlockedTickets(tickets, /* @__PURE__ */ new Set());
4399
+ const byKey = new Map(
4400
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
3987
4401
  );
3988
- if (blockedByHuman.length > 0) {
3989
- const n = blockedByHuman.length;
4402
+ const other = [];
4403
+ for (const [key, refs] of dead) {
4404
+ const stuck = refs.flatMap((r) => {
4405
+ const b = byKey.get(ticketKey(r.feature, r.num));
4406
+ return b && b.status !== HUMAN_STATUS ? [`${ticketLabel(toRef(b))} (${b.status})`] : [];
4407
+ });
4408
+ const t = byKey.get(key);
4409
+ if (t && stuck.length > 0) {
4410
+ other.push(
4411
+ ` ${ticketLabel(toRef(t))} waits on ` + stuck.join(", ")
4412
+ );
4413
+ }
4414
+ }
4415
+ if (other.length > 0) {
3990
4416
  lines.push(
3991
- `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
3992
- indentFilenames(blockedByHuman)
4417
+ "",
4418
+ "These tickets wait on tickets that are not ready-for-agent:",
4419
+ ...other,
4420
+ "Set those tickets to ready-for-agent or done, or fix the **Blocked by:** lines."
4421
+ );
4422
+ }
4423
+ const cycle = detectDeadlocks(tickets).filter((t) => !dead.has(ticketKey(t.feature, t.num)));
4424
+ if (cycle.length > 0) {
4425
+ lines.push(
4426
+ "",
4427
+ "Deadlocked tickets (waiting on each other): " + cycle.map((t) => ticketLabel(toRef(t))).join(", ") + ". Fix their **Blocked by:** lines."
3993
4428
  );
3994
4429
  }
3995
- lines.push(
3996
- "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
3997
- );
3998
4430
  return lines.join("\n");
3999
4431
  }
4000
- __name(renderFinishSummary, "renderFinishSummary");
4432
+ __name(renderNothingReady, "renderNothingReady");
4001
4433
  function sessionTickets(tickets) {
4002
4434
  return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
4003
4435
  }
@@ -4016,7 +4448,10 @@ function reportBlocked(d, tickets, failedKeys) {
4016
4448
  )) {
4017
4449
  const ticket = byKey.get(key);
4018
4450
  if (!ticket) continue;
4019
- const labels = dead.map((k) => byKey.get(k)).filter((t) => t !== void 0).map((t) => ticketLabel(toRef(t)));
4451
+ const labels = dead.map((ref) => {
4452
+ const t = byKey.get(ticketKey(ref.feature, ref.num));
4453
+ return t ? ticketLabel(toRef(t)) : blockerLabel(ref);
4454
+ });
4020
4455
  d.setBlocked(ticket.num, ticket.feature, labels);
4021
4456
  }
4022
4457
  }
@@ -4028,7 +4463,7 @@ function runProgress(tickets) {
4028
4463
  };
4029
4464
  }
4030
4465
  __name(runProgress, "runProgress");
4031
- async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
4466
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency, warnings = [], preferSubagent) {
4032
4467
  mkdirSync3(logsDir, { recursive: true });
4033
4468
  const d = display ?? createDisplay();
4034
4469
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
@@ -4042,6 +4477,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
4042
4477
  let waveNum = 0;
4043
4478
  const failedKeys = /* @__PURE__ */ new Set();
4044
4479
  const session = sessionTickets(tickets);
4480
+ let runStats = emptyRunStats();
4045
4481
  while (true) {
4046
4482
  if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
4047
4483
  const ready = findReadyTickets(tickets).filter(
@@ -4072,7 +4508,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
4072
4508
  const prompt = renderPrompt(
4073
4509
  specBody,
4074
4510
  t,
4075
- { testCmd }
4511
+ { testCmd, preferSubagent }
4076
4512
  );
4077
4513
  const label = t.filename.replace(
4078
4514
  /\.md$/,
@@ -4084,6 +4520,18 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
4084
4520
  t.feature
4085
4521
  );
4086
4522
  d.log(` Starting ${t.filename}`);
4523
+ let prior = { subagents: 0 };
4524
+ const onStats = /* @__PURE__ */ __name((stats) => {
4525
+ d.setTicketStats(
4526
+ t.num,
4527
+ t.feature,
4528
+ addStats(prior, stats)
4529
+ );
4530
+ }, "onStats");
4531
+ const tally = /* @__PURE__ */ __name((r) => {
4532
+ prior = addStats(prior, r);
4533
+ runStats = addRun(runStats, r);
4534
+ }, "tally");
4087
4535
  let level = effort;
4088
4536
  let result = await spawnAgent({
4089
4537
  prompt,
@@ -4094,8 +4542,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
4094
4542
  label,
4095
4543
  maxTurns,
4096
4544
  effort: level,
4545
+ preferSubagent,
4546
+ onStats,
4097
4547
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
4098
4548
  });
4549
+ tally(result);
4099
4550
  for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
4100
4551
  if (isKnown) {
4101
4552
  level = escalateEffort(
@@ -4122,8 +4573,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
4122
4573
  label,
4123
4574
  maxTurns,
4124
4575
  effort: level,
4576
+ preferSubagent,
4577
+ onStats,
4125
4578
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
4126
4579
  });
4580
+ tally(result);
4127
4581
  }
4128
4582
  return { ticket: t, result };
4129
4583
  }
@@ -4182,8 +4636,11 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
4182
4636
  d.log("\n" + renderFinishSummary(
4183
4637
  tickets,
4184
4638
  totalCompleted,
4185
- failedKeys
4639
+ failedKeys,
4640
+ warnings
4186
4641
  ));
4642
+ const statsLine = renderRunStats(runStats);
4643
+ if (statsLine) d.log(statsLine);
4187
4644
  d.cleanup();
4188
4645
  return { allDone: remaining.length === 0 };
4189
4646
  }
@@ -4270,6 +4727,10 @@ async function runWavesIsolated(opts) {
4270
4727
  let waveNum = 0;
4271
4728
  const failedKeys = /* @__PURE__ */ new Set();
4272
4729
  const session = sessionTickets(opts.tickets);
4730
+ let runStats = emptyRunStats();
4731
+ const onAgentRun = /* @__PURE__ */ __name((result) => {
4732
+ runStats = addRun(runStats, result);
4733
+ }, "onAgentRun");
4273
4734
  while (true) {
4274
4735
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
4275
4736
  const ready = findReadyTickets(opts.tickets).filter(
@@ -4311,12 +4772,14 @@ async function runWavesIsolated(opts) {
4311
4772
  installCmd: opts.installCmd,
4312
4773
  maxTurns: opts.maxTurns,
4313
4774
  effort: opts.effort,
4775
+ preferSubagent: opts.preferSubagent,
4314
4776
  learningsPath: learningsPathFor(
4315
4777
  tracker.pathFor(t.feature)
4316
4778
  ),
4317
4779
  retries,
4318
4780
  retryDelay,
4319
- d
4781
+ d,
4782
+ onAgentRun
4320
4783
  })
4321
4784
  );
4322
4785
  const touched = new Set(ready.map((t) => {
@@ -4536,7 +4999,9 @@ async function runWavesIsolated(opts) {
4536
4999
  testCmd: opts.testCmd,
4537
5000
  installCmd: opts.installCmd,
4538
5001
  maxTurns: opts.maxTurns,
4539
- onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
5002
+ preferSubagent: opts.preferSubagent,
5003
+ onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine"),
5004
+ onAgentRun
4540
5005
  }) : false;
4541
5006
  if (recovered) {
4542
5007
  markDone(ticket);
@@ -4693,10 +5158,18 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
4693
5158
  installCmd: opts.installCmd,
4694
5159
  maxTurns: opts.maxTurns,
4695
5160
  effort: opts.effort,
5161
+ preferSubagent: opts.preferSubagent,
4696
5162
  retries,
4697
5163
  retryDelay,
4698
5164
  log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
4699
- }, { spawnAgent, registry: runningAgents });
5165
+ }, {
5166
+ spawnAgent: /* @__PURE__ */ __name(async (spawnOpts) => {
5167
+ const result = await spawnAgent(spawnOpts);
5168
+ onAgentRun(result);
5169
+ return result;
5170
+ }, "spawnAgent"),
5171
+ registry: runningAgents
5172
+ });
4700
5173
  reviewLine = renderReviewSummary(
4701
5174
  outcome,
4702
5175
  relative2(repoRoot, opts.review.reviewPath)
@@ -4712,8 +5185,11 @@ Review failed: ${formatErrorMessage(err)}`);
4712
5185
  d.log("\n" + renderFinishSummary(
4713
5186
  opts.tickets,
4714
5187
  totalCompleted,
4715
- failedKeys
5188
+ failedKeys,
5189
+ opts.warnings ?? []
4716
5190
  ));
5191
+ const statsLine = renderRunStats(runStats);
5192
+ if (statsLine) d.log(statsLine);
4717
5193
  if (reviewLine) d.log(reviewLine);
4718
5194
  d.log(`Work is on ${baseBranch}`);
4719
5195
  d.cleanup();
@@ -4797,6 +5273,86 @@ function findFeatureDirs(cwd) {
4797
5273
  return results;
4798
5274
  }
4799
5275
  __name(findFeatureDirs, "findFeatureDirs");
5276
+ function knownFeatureNames(scratchDir) {
5277
+ const names = /* @__PURE__ */ new Set();
5278
+ const walk = /* @__PURE__ */ __name((rel) => {
5279
+ const dir = rel === "" ? scratchDir : join8(scratchDir, rel);
5280
+ for (const e of readdirSync3(dir, { withFileTypes: true })) {
5281
+ if (!e.isDirectory() || e.name === "issues") continue;
5282
+ const name = rel === "" ? e.name : `${rel}/${e.name}`;
5283
+ if (existsSync6(join8(scratchDir, name, "issues"))) {
5284
+ names.add(name);
5285
+ }
5286
+ walk(name);
5287
+ }
5288
+ }, "walk");
5289
+ if (existsSync6(scratchDir)) walk("");
5290
+ return names;
5291
+ }
5292
+ __name(knownFeatureNames, "knownFeatureNames");
5293
+ function loadSelection(scratchDir, names) {
5294
+ const known = knownFeatureNames(scratchDir);
5295
+ const issuesDir = /* @__PURE__ */ __name((name) => join8(scratchDir, name, "issues"), "issuesDir");
5296
+ const tickets = [];
5297
+ const selected = /* @__PURE__ */ new Map();
5298
+ for (const name of new Set(names)) {
5299
+ const dir = issuesDir(name);
5300
+ const found = existsSync6(dir) ? scanTickets(dir, name, known) : [];
5301
+ selected.set(name, new Set(found.map((t) => t.num)));
5302
+ tickets.push(...found);
5303
+ }
5304
+ const outside = /* @__PURE__ */ new Map();
5305
+ const lookup = /* @__PURE__ */ __name((feature, num) => {
5306
+ if (!known.has(feature)) return void 0;
5307
+ let statuses = outside.get(feature);
5308
+ if (!statuses) {
5309
+ statuses = new Map(
5310
+ scanTickets(issuesDir(feature), feature).map((t) => [t.num, t.status])
5311
+ );
5312
+ outside.set(feature, statuses);
5313
+ }
5314
+ return statuses.get(num);
5315
+ }, "lookup");
5316
+ const warnings = [];
5317
+ for (const t of tickets) {
5318
+ const parsed = parseBlockerField(t.body, known);
5319
+ t.blockers = resolveBlockers(
5320
+ parsed.refs,
5321
+ t.feature,
5322
+ selected,
5323
+ lookup
5324
+ );
5325
+ const ticket = ticketLabel(t);
5326
+ for (const word of parsed.typos) {
5327
+ warnings.push({ ticket, kind: "typo", text: word });
5328
+ }
5329
+ for (const ref of t.blockers) {
5330
+ if (ref.where !== "missing") continue;
5331
+ warnings.push({
5332
+ ticket,
5333
+ kind: "missing",
5334
+ text: blockerLabel(ref)
5335
+ });
5336
+ }
5337
+ }
5338
+ return { tickets, warnings };
5339
+ }
5340
+ __name(loadSelection, "loadSelection");
5341
+ function featureCounts(scratchDir, name) {
5342
+ const { tickets, warnings } = loadSelection(scratchDir, [name]);
5343
+ return {
5344
+ ready: findReadyTickets(tickets).length,
5345
+ total: tickets.length,
5346
+ badBlockers: warnings.filter((w) => w.kind === "missing").length
5347
+ };
5348
+ }
5349
+ __name(featureCounts, "featureCounts");
5350
+ function featureChoiceLabel(scratchDir, name) {
5351
+ const c = featureCounts(scratchDir, name);
5352
+ const bad = c.badBlockers === 0 ? "" : `, ${c.badBlockers} bad blocker` + (c.badBlockers === 1 ? "" : "s");
5353
+ return `${name} (${c.ready}/${c.total} ready${bad})`;
5354
+ }
5355
+ __name(featureChoiceLabel, "featureChoiceLabel");
4800
5356
  var ALL_VALUE = "__all__";
4801
5357
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
4802
5358
  if (specName) {
@@ -4819,14 +5375,11 @@ async function resolveIssuesDir(specName, cwd = process.cwd()) {
4819
5375
  console.log(`Using feature: ${f.name}`);
4820
5376
  return [{ dir: f.dir, spec: f.spec, name: f.name }];
4821
5377
  }
4822
- const featureChoices = features.map((f) => {
4823
- const tickets = scanTickets(f.dir);
4824
- const ready = findReadyTickets(tickets).length;
4825
- return {
4826
- name: `${f.name} (${ready}/${tickets.length} ready)`,
4827
- value: f.name
4828
- };
4829
- });
5378
+ const scratchDir = join8(cwd, ".scratch");
5379
+ const featureChoices = features.map((f) => ({
5380
+ name: featureChoiceLabel(scratchDir, f.name),
5381
+ value: f.name
5382
+ }));
4830
5383
  while (true) {
4831
5384
  const selected = await checkbox({
4832
5385
  message: "Which features?",
@@ -4933,6 +5486,9 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
4933
5486
  type: "string",
4934
5487
  choices: ALL_EFFORT_LEVELS,
4935
5488
  describe: "Starting effort level. Omitted = provider default. Retries step up from here."
5489
+ }).option("prefer-subagent", {
5490
+ type: "string",
5491
+ describe: 'claude only: model that subagents default to (CLAUDE_CODE_SUBAGENT_MODEL), plus a prompt rule to hand read-only work to them. "" disables it.'
4936
5492
  }).option("retries", {
4937
5493
  type: "number",
4938
5494
  default: DEFAULT_RETRIES,
@@ -4962,6 +5518,7 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
4962
5518
  stopOnConflict: argv.X,
4963
5519
  maxTurns: argv.maxTurns ?? argv.turns,
4964
5520
  effort: argv.effort,
5521
+ preferSubagent: argv.preferSubagent,
4965
5522
  retries: argv.retries,
4966
5523
  retryDelay: argv.retryDelay,
4967
5524
  concurrency: argv.concurrency,
@@ -5006,6 +5563,8 @@ function loadTickets(selections, log) {
5006
5563
  let firstFeatureName = "";
5007
5564
  let firstLogsDir = "";
5008
5565
  const missing = [];
5566
+ const valid = [];
5567
+ const specs = /* @__PURE__ */ new Map();
5009
5568
  for (const selection of selections) {
5010
5569
  const { dir, spec, name } = selection;
5011
5570
  if (!existsSync7(dir)) {
@@ -5016,11 +5575,18 @@ function loadTickets(selections, log) {
5016
5575
  missing.push(`Spec file not found: ${spec}`);
5017
5576
  continue;
5018
5577
  }
5019
- const specBody = stripSpecSection(
5578
+ specs.set(name, stripSpecSection(
5020
5579
  readFileSync7(spec, "utf8"),
5021
5580
  "User Stories"
5022
- );
5023
- const tickets = scanTickets(dir, name);
5581
+ ));
5582
+ valid.push(selection);
5583
+ }
5584
+ const loaded = valid.length > 0 ? loadSelection(
5585
+ join9(process.cwd(), ".scratch"),
5586
+ valid.map((s) => s.name)
5587
+ ) : { tickets: [], warnings: [] };
5588
+ for (const { dir, spec, name } of valid) {
5589
+ const tickets = loaded.tickets.filter((t) => t.feature === name);
5024
5590
  if (tickets.length === 0) {
5025
5591
  log(
5026
5592
  `No ticket files found for ${name}. Skipping.`
@@ -5030,23 +5596,7 @@ function loadTickets(selections, log) {
5030
5596
  log(
5031
5597
  `[${name}] Found ${tickets.length} tickets`
5032
5598
  );
5033
- const knownNums = new Set(
5034
- tickets.map((t) => t.num)
5035
- );
5036
- for (const t of tickets) {
5037
- const unknown = t.blockers.filter(
5038
- (b) => !knownNums.has(b)
5039
- );
5040
- if (unknown.length > 0) {
5041
- log(
5042
- `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
5043
- );
5044
- }
5045
- t.blockers = t.blockers.filter(
5046
- (b) => knownNums.has(b)
5047
- );
5048
- }
5049
- specBodies.set(name, specBody);
5599
+ specBodies.set(name, specs.get(name));
5050
5600
  progressPaths.set(
5051
5601
  name,
5052
5602
  join9(dirname6(dir), "progress.md")
@@ -5065,7 +5615,8 @@ function loadTickets(selections, log) {
5065
5615
  issuesDirs,
5066
5616
  firstFeatureName,
5067
5617
  firstLogsDir,
5068
- missing
5618
+ missing,
5619
+ warnings: loaded.warnings
5069
5620
  };
5070
5621
  }
5071
5622
  __name(loadTickets, "loadTickets");
@@ -5078,7 +5629,7 @@ function loadTicketsOrExit(selections) {
5078
5629
  return loaded;
5079
5630
  }
5080
5631
  __name(loadTicketsOrExit, "loadTicketsOrExit");
5081
- function failStaleResume(plan, problems) {
5632
+ function checkoutLaunchBranch(plan) {
5082
5633
  try {
5083
5634
  execFileSync2(
5084
5635
  "git",
@@ -5087,6 +5638,10 @@ function failStaleResume(plan, problems) {
5087
5638
  );
5088
5639
  } catch {
5089
5640
  }
5641
+ }
5642
+ __name(checkoutLaunchBranch, "checkoutLaunchBranch");
5643
+ function failStaleResume(plan, problems) {
5644
+ checkoutLaunchBranch(plan);
5090
5645
  const n = plan.behind;
5091
5646
  console.error(
5092
5647
  `Cannot resume ${plan.branch}: ${problems.join("; ")}. It is ${n} commit${n === 1 ? "" : "s"} behind ${plan.launchBranch}. Merge ${plan.launchBranch} into it, or delete it with \`git branch -D ${plan.branch}\`. Back on ${plan.launchBranch}.`
@@ -5094,6 +5649,25 @@ function failStaleResume(plan, problems) {
5094
5649
  process.exit(2);
5095
5650
  }
5096
5651
  __name(failStaleResume, "failStaleResume");
5652
+ function stopNothingReady(plan, loaded) {
5653
+ checkoutLaunchBranch(plan);
5654
+ console.log(
5655
+ `Nothing to run on ${plan.branch}. Back on ${plan.launchBranch}.
5656
+ ` + renderNothingReady(loaded.allTickets, loaded.warnings)
5657
+ );
5658
+ process.exit(0);
5659
+ }
5660
+ __name(stopNothingReady, "stopNothingReady");
5661
+ function willResume(features) {
5662
+ const root = getRepoRoot();
5663
+ const name = planIntegrationBranch(
5664
+ getCurrentBranch(root),
5665
+ getDefaultBranch(root),
5666
+ features
5667
+ );
5668
+ return name !== null && localBranchExists(root, name);
5669
+ }
5670
+ __name(willResume, "willResume");
5097
5671
  function reviewPathFor(issuesDirs) {
5098
5672
  const featureDir = dirname6(issuesDirs[0]);
5099
5673
  return issuesDirs.length === 1 ? join9(featureDir, "review.md") : join9(dirname6(featureDir), "review.md");
@@ -5105,6 +5679,14 @@ async function runExecute(argv) {
5105
5679
  );
5106
5680
  let loaded = loadTicketsOrExit(selections);
5107
5681
  if (loaded.allTickets.length === 0) return;
5682
+ const resuming = /* @__PURE__ */ __name(() => !argv.dryRun && isGitRepo() && willResume([...loaded.specBodies.keys()]), "resuming");
5683
+ if (nothingReady(loaded.allTickets) && !resuming()) {
5684
+ console.log(renderNothingReady(
5685
+ loaded.allTickets,
5686
+ loaded.warnings
5687
+ ));
5688
+ return;
5689
+ }
5108
5690
  const providerPath = resolveExecutable(argv.provider);
5109
5691
  const providerIsPath = argv.provider.includes("/");
5110
5692
  const providerMissing = providerIsPath ? "not found or not executable" : "not found on PATH";
@@ -5115,6 +5697,14 @@ async function runExecute(argv) {
5115
5697
  process.exit(2);
5116
5698
  }
5117
5699
  const agentCmd = providerIsPath ? providerPath ?? argv.provider : argv.provider;
5700
+ let preferSubagent = argv.preferSubagent || void 0;
5701
+ const providerName = basename6(argv.provider);
5702
+ if (preferSubagent && providerName !== "claude") {
5703
+ console.warn(
5704
+ `--prefer-subagent not supported by ${providerName}, skipping`
5705
+ );
5706
+ preferSubagent = void 0;
5707
+ }
5118
5708
  const isolated = isGitRepo();
5119
5709
  const featureNames = [...loaded.specBodies.keys()];
5120
5710
  let plan = null;
@@ -5144,6 +5734,9 @@ async function runExecute(argv) {
5144
5734
  if (loaded.allTickets.length === 0) {
5145
5735
  failStaleResume(plan, ["no tickets"]);
5146
5736
  }
5737
+ if (nothingReady(loaded.allTickets)) {
5738
+ stopNothingReady(plan, loaded);
5739
+ }
5147
5740
  }
5148
5741
  }
5149
5742
  const {
@@ -5183,7 +5776,8 @@ async function runExecute(argv) {
5183
5776
  effort: argv.effort,
5184
5777
  retries: argv.retries,
5185
5778
  retryDelay: argv.retryDelay,
5186
- concurrency: argv.concurrency
5779
+ concurrency: argv.concurrency,
5780
+ preferSubagent
5187
5781
  });
5188
5782
  console.log(
5189
5783
  describeIntegrationBranch(
@@ -5194,7 +5788,12 @@ async function runExecute(argv) {
5194
5788
  const reviewState = argv.review === false ? "off" : "on";
5195
5789
  console.log(`Review: ${reviewState}`);
5196
5790
  } else {
5197
- dryRun(allTickets, fallbackSpec, testCmd);
5791
+ dryRun(
5792
+ allTickets,
5793
+ fallbackSpec,
5794
+ testCmd,
5795
+ preferSubagent
5796
+ );
5198
5797
  }
5199
5798
  return;
5200
5799
  }
@@ -5227,7 +5826,9 @@ async function runExecute(argv) {
5227
5826
  retries: argv.retries,
5228
5827
  retryDelay: argv.retryDelay,
5229
5828
  concurrency: argv.concurrency,
5829
+ preferSubagent,
5230
5830
  progressPaths,
5831
+ warnings: loaded.warnings,
5231
5832
  review: argv.review === false ? void 0 : {
5232
5833
  reviewPath: reviewPathFor(loaded.issuesDirs),
5233
5834
  forkedFrom: plan?.forkedFrom ?? void 0
@@ -5247,7 +5848,9 @@ async function runExecute(argv) {
5247
5848
  argv.effort,
5248
5849
  argv.retries,
5249
5850
  argv.retryDelay,
5250
- argv.concurrency
5851
+ argv.concurrency,
5852
+ loaded.warnings,
5853
+ preferSubagent
5251
5854
  );
5252
5855
  }
5253
5856
  if (!result.allDone) {