nightralph 0.0.48 → 0.0.52

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
@@ -7,7 +7,7 @@ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:f
7
7
  import { dirname as dirname4, join as join6 } from "node:path";
8
8
  import yargs from "yargs";
9
9
  import { hideBin } from "yargs/helpers";
10
- import { select as select2 } from "@inquirer/prompts";
10
+ import { select } from "@inquirer/prompts";
11
11
 
12
12
  // src/setup.ts
13
13
  import {
@@ -257,18 +257,19 @@ async function branchExists(repoRoot, branchName) {
257
257
  __name(branchExists, "branchExists");
258
258
  function buildBranchName(repoName, ticket) {
259
259
  const num = String(ticket.num).padStart(2, "0");
260
- return `${repoName}--${num}-${ticket.slug}`;
260
+ const prefix = ticket.feature ? `${ticket.feature}-` : "";
261
+ return `${repoName}--${prefix}${num}-${ticket.slug}`;
261
262
  }
262
263
  __name(buildBranchName, "buildBranchName");
263
264
  async function createWorktreeNow(opts) {
264
265
  const { repoRoot, repoName, baseBranch, ticket } = opts;
265
266
  const branchName = buildBranchName(repoName, ticket);
267
+ const num = String(ticket.num).padStart(2, "0");
268
+ const prefix = ticket.feature ? `${ticket.feature}-` : "";
266
269
  const worktreePath = join2(
267
270
  repoRoot,
268
271
  "..",
269
- `${repoName}.${String(
270
- ticket.num
271
- ).padStart(2, "0")}-${ticket.slug}`
272
+ `${repoName}.${prefix}${num}-${ticket.slug}`
272
273
  );
273
274
  if (existsSync2(worktreePath)) {
274
275
  try {
@@ -403,6 +404,39 @@ function generateProgress(feature, tickets) {
403
404
  };
404
405
  }
405
406
  __name(generateProgress, "generateProgress");
407
+ function generateMultiFeatureProgress(tickets) {
408
+ const byFeature = /* @__PURE__ */ new Map();
409
+ for (const t of tickets) {
410
+ const key = t.feature || "";
411
+ const arr = byFeature.get(key) ?? [];
412
+ arr.push(t);
413
+ byFeature.set(key, arr);
414
+ }
415
+ const result = /* @__PURE__ */ new Map();
416
+ for (const [feat, ft] of byFeature) {
417
+ result.set(feat, generateProgress(feat, ft));
418
+ }
419
+ return result;
420
+ }
421
+ __name(generateMultiFeatureProgress, "generateMultiFeatureProgress");
422
+ function createProgressTracker(opts) {
423
+ return {
424
+ dataFor(feature) {
425
+ return opts.dataMap.get(feature) ?? opts.fallbackData;
426
+ },
427
+ pathFor(feature) {
428
+ return opts.progressPaths.get(feature) ?? opts.fallbackPath;
429
+ }
430
+ };
431
+ }
432
+ __name(createProgressTracker, "createProgressTracker");
433
+ function isSpecComplete(progressPath) {
434
+ const data = readProgress(progressPath);
435
+ if (!data) return false;
436
+ if (data.tickets.length === 0) return false;
437
+ return data.tickets.every((t) => t.done);
438
+ }
439
+ __name(isSpecComplete, "isSpecComplete");
406
440
  function readProgress(progressPath) {
407
441
  let content;
408
442
  try {
@@ -478,8 +512,8 @@ function renderProgress(data) {
478
512
  ""
479
513
  ];
480
514
  for (const ticket of data.tickets) {
481
- const checkbox = ticket.done ? "[x]" : "[ ]";
482
- lines.push(`- ${checkbox} ${ticket.filename}`);
515
+ const checkbox2 = ticket.done ? "[x]" : "[ ]";
516
+ lines.push(`- ${checkbox2} ${ticket.filename}`);
483
517
  if (ticket.done || ticket.exitCode !== null) {
484
518
  if (ticket.branch) {
485
519
  lines.push(
@@ -788,6 +822,14 @@ var color = {
788
822
  return `${ESC}2m${text}${ESC}0m`;
789
823
  }
790
824
  };
825
+ function ticketLabel(ticket) {
826
+ if (ticket.feature) {
827
+ const padded = String(ticket.num).padStart(2, "0");
828
+ return `${ticket.feature}/${padded}`;
829
+ }
830
+ return ticket.filename;
831
+ }
832
+ __name(ticketLabel, "ticketLabel");
791
833
  var FILLED_DOT = "\u25CF";
792
834
  var EMPTY_DOT = "\u25CB";
793
835
  var SEPARATOR_WIDTH = 50;
@@ -883,9 +925,11 @@ var StatusDisplay = class {
883
925
  }
884
926
  this.render();
885
927
  }
886
- setTicketStatus(num, status) {
928
+ setTicketStatus(num, status, feature) {
887
929
  if (!this.wave) return;
888
- const ticket = this.wave.tickets.find((t) => t.num === num);
930
+ const ticket = feature ? this.wave.tickets.find(
931
+ (t) => t.num === num && t.feature === feature
932
+ ) : this.wave.tickets.find((t) => t.num === num);
889
933
  if (ticket) {
890
934
  ticket.status = status;
891
935
  if (status === "in-progress") {
@@ -951,7 +995,10 @@ var StatusDisplay = class {
951
995
  if (tickets.length <= availableRows) {
952
996
  for (const ticket of tickets) {
953
997
  const dot = renderDot(ticket.status);
954
- const name = fitLine(ticket.filename, width - 2);
998
+ const name = fitLine(
999
+ ticketLabel(ticket),
1000
+ width - 2
1001
+ );
955
1002
  output += `${dot} ${name}
956
1003
  `;
957
1004
  }
@@ -996,7 +1043,10 @@ var StatusDisplay = class {
996
1043
  );
997
1044
  for (const ticket of visibleTickets) {
998
1045
  const dot = renderDot(ticket.status);
999
- const name = fitLine(ticket.filename, width - 2);
1046
+ const name = fitLine(
1047
+ ticketLabel(ticket),
1048
+ width - 2
1049
+ );
1000
1050
  output += `${dot} ${name}
1001
1051
  `;
1002
1052
  }
@@ -1037,10 +1087,10 @@ var PlainDisplay = class {
1037
1087
  const pct = progress && progress.total > 0 ? ` (${progress.completed}/${progress.total} done)` : "";
1038
1088
  console.log(
1039
1089
  `
1040
- Wave ${waveNum}${pct}: ` + tickets.map((t) => t.filename).join(", ")
1090
+ Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
1041
1091
  );
1042
1092
  }
1043
- setTicketStatus(_num, _status) {
1093
+ setTicketStatus(_num, _status, _feature) {
1044
1094
  }
1045
1095
  log(message) {
1046
1096
  console.log(message);
@@ -1212,7 +1262,7 @@ function parseBlockers(body) {
1212
1262
  return nums;
1213
1263
  }
1214
1264
  __name(parseBlockers, "parseBlockers");
1215
- function scanTickets(dir) {
1265
+ function scanTickets(dir, feature = "") {
1216
1266
  const files = readdirSync2(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
1217
1267
  b,
1218
1268
  void 0,
@@ -1229,18 +1279,25 @@ function scanTickets(dir) {
1229
1279
  filepath,
1230
1280
  body,
1231
1281
  status: parseStatus(body),
1232
- blockers: parseBlockers(body)
1282
+ blockers: parseBlockers(body),
1283
+ feature
1233
1284
  };
1234
1285
  });
1235
1286
  }
1236
1287
  __name(scanTickets, "scanTickets");
1288
+ function ticketKey(feature, num) {
1289
+ return `${feature}:${num}`;
1290
+ }
1291
+ __name(ticketKey, "ticketKey");
1237
1292
  function findReadyTickets(tickets) {
1238
- const doneNums = new Set(
1239
- tickets.filter((t) => t.status === "done").map((t) => t.num)
1293
+ const doneKeys = new Set(
1294
+ tickets.filter((t) => t.status === "done").map((t) => ticketKey(t.feature, t.num))
1240
1295
  );
1241
1296
  return tickets.filter((t) => {
1242
1297
  if (t.status !== "ready-for-agent") return false;
1243
- return t.blockers.every((b) => doneNums.has(b));
1298
+ return t.blockers.every(
1299
+ (b) => doneKeys.has(ticketKey(t.feature, b))
1300
+ );
1244
1301
  });
1245
1302
  }
1246
1303
  __name(findReadyTickets, "findReadyTickets");
@@ -1384,6 +1441,11 @@ function stripSpecSection(specBody, heading) {
1384
1441
  return out.join("\n");
1385
1442
  }
1386
1443
  __name(stripSpecSection, "stripSpecSection");
1444
+ function lookupSpec(specBodies, fallback, feature) {
1445
+ if (!feature || !specBodies) return fallback;
1446
+ return specBodies.get(feature) ?? fallback;
1447
+ }
1448
+ __name(lookupSpec, "lookupSpec");
1387
1449
  function renderPrompt(specBody, ticket, opts) {
1388
1450
  return [
1389
1451
  "---- SPEC CONTEXT ----",
@@ -1451,58 +1513,72 @@ function listTickets(tickets) {
1451
1513
  __name(listTickets, "listTickets");
1452
1514
  function* simulateWaves(tickets) {
1453
1515
  const sim = new Map(
1454
- tickets.map((t) => [t.num, t.status])
1516
+ tickets.map((t) => [
1517
+ ticketKey(t.feature, t.num),
1518
+ t.status
1519
+ ])
1455
1520
  );
1456
1521
  let wave = 1;
1457
1522
  while (true) {
1458
- const doneNums = new Set(
1459
- [...sim.entries()].filter(([, s]) => s === "done").map(([n]) => n)
1523
+ const doneKeys = new Set(
1524
+ [...sim.entries()].filter(([, s]) => s === "done").map(([k]) => k)
1460
1525
  );
1461
1526
  const ready = [...tickets].filter((t) => {
1462
- if (sim.get(t.num) !== "ready-for-agent") {
1527
+ const key = ticketKey(t.feature, t.num);
1528
+ if (sim.get(key) !== "ready-for-agent") {
1463
1529
  return false;
1464
1530
  }
1465
1531
  return t.blockers.every(
1466
- (b) => doneNums.has(b)
1532
+ (b) => doneKeys.has(
1533
+ ticketKey(t.feature, b)
1534
+ )
1467
1535
  );
1468
1536
  });
1469
1537
  if (ready.length === 0) break;
1470
1538
  yield { wave, ready };
1471
1539
  for (const t of ready) {
1472
- sim.set(t.num, "done");
1540
+ sim.set(
1541
+ ticketKey(t.feature, t.num),
1542
+ "done"
1543
+ );
1473
1544
  }
1474
1545
  wave++;
1475
1546
  }
1476
1547
  }
1477
1548
  __name(simulateWaves, "simulateWaves");
1478
1549
  function detectDeadlocks(tickets) {
1479
- const doneNums = /* @__PURE__ */ new Set();
1480
- const ready = /* @__PURE__ */ new Set();
1550
+ const doneKeys = /* @__PURE__ */ new Set();
1551
+ const readyKeys = /* @__PURE__ */ new Set();
1481
1552
  for (const t of tickets) {
1553
+ const key = ticketKey(t.feature, t.num);
1482
1554
  if (t.status === "ready-for-agent") {
1483
- ready.add(t.num);
1555
+ readyKeys.add(key);
1484
1556
  } else if (t.status === "done") {
1485
- doneNums.add(t.num);
1557
+ doneKeys.add(key);
1486
1558
  }
1487
1559
  }
1488
1560
  let changed = true;
1489
1561
  while (changed) {
1490
1562
  changed = false;
1491
- for (const num of ready) {
1563
+ for (const key of readyKeys) {
1492
1564
  const ticket = tickets.find(
1493
- (t) => t.num === num
1565
+ (t) => ticketKey(t.feature, t.num) === key
1494
1566
  );
1495
1567
  if (ticket.blockers.every(
1496
- (b) => doneNums.has(b)
1568
+ (b) => doneKeys.has(
1569
+ ticketKey(ticket.feature, b)
1570
+ )
1497
1571
  )) {
1498
- doneNums.add(num);
1499
- ready.delete(num);
1572
+ doneKeys.add(key);
1573
+ readyKeys.delete(key);
1500
1574
  changed = true;
1501
1575
  }
1502
1576
  }
1503
1577
  }
1504
- return [...ready].map(
1505
- (num) => tickets.find((t) => t.num === num)
1578
+ return [...readyKeys].map(
1579
+ (key) => tickets.find(
1580
+ (t) => ticketKey(t.feature, t.num) === key
1581
+ )
1506
1582
  );
1507
1583
  }
1508
1584
  __name(detectDeadlocks, "detectDeadlocks");
@@ -1956,7 +2032,8 @@ function dryRunIsolated(opts) {
1956
2032
  opts.repoName,
1957
2033
  t
1958
2034
  );
1959
- const worktreePath = `../${opts.repoName}.${num}-${t.slug}`;
2035
+ const prefix = t.feature ? `${t.feature}.` : "";
2036
+ const worktreePath = `../${opts.repoName}.${prefix}${num}-${t.slug}`;
1960
2037
  console.log(` worktree: ${worktreePath}`);
1961
2038
  console.log(` branch: ${branchName}`);
1962
2039
  }
@@ -2176,10 +2253,19 @@ async function runTicketAttempt(opts) {
2176
2253
  } catch (error) {
2177
2254
  return { kind: "worktree-error", error };
2178
2255
  }
2179
- d.setTicketStatus(ticket.num, "in-progress");
2256
+ d.setTicketStatus(
2257
+ ticket.num,
2258
+ "in-progress",
2259
+ ticket.feature
2260
+ );
2261
+ const ticketSpec = lookupSpec(
2262
+ opts.specBodies,
2263
+ opts.specBody,
2264
+ ticket.feature
2265
+ );
2180
2266
  const agentResult = await spawnAgent({
2181
2267
  prompt: renderPrompt(
2182
- opts.specBody,
2268
+ ticketSpec,
2183
2269
  ticket,
2184
2270
  { testCmd: opts.testCmd }
2185
2271
  ),
@@ -2309,7 +2395,11 @@ async function runTicketAttempts(opts) {
2309
2395
  return { kind: "error", error };
2310
2396
  }
2311
2397
  if (outcome.kind === "ready") {
2312
- d.setTicketStatus(ticket.num, "awaiting-merge");
2398
+ d.setTicketStatus(
2399
+ ticket.num,
2400
+ "awaiting-merge",
2401
+ ticket.feature
2402
+ );
2313
2403
  return outcome;
2314
2404
  }
2315
2405
  if (outcome.kind === "worktree-error") return outcome;
@@ -2333,24 +2423,50 @@ function indentFilenames(tickets) {
2333
2423
  return tickets.map((t) => ` ${t.filename}`).join("\n");
2334
2424
  }
2335
2425
  __name(indentFilenames, "indentFilenames");
2336
- function renderFinishSummary(tickets, totalCompleted, failedNums) {
2426
+ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
2337
2427
  const remaining = tickets.filter(
2338
2428
  (t) => t.status === "ready-for-agent"
2339
2429
  );
2340
2430
  const human = tickets.filter((t) => t.status === HUMAN_STATUS);
2341
2431
  const humanNums = new Set(human.map((t) => t.num));
2342
2432
  const blockedByHuman = remaining.filter(
2343
- (t) => !failedNums.has(t.num) && t.blockers.some((b) => humanNums.has(b))
2433
+ (t) => !failedKeys.has(ticketKey(t.feature, t.num)) && t.blockers.some((b) => humanNums.has(b))
2344
2434
  );
2345
- const counts = `Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedNums.size > 0 ? `, ${failedNums.size} failed` : "") + (human.length > 0 ? `, ${human.length} need a human` : "");
2346
- if (human.length === 0) return counts;
2435
+ 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` : "");
2436
+ const lines = [counts];
2437
+ const features = new Set(
2438
+ tickets.map((t) => t.feature).filter(Boolean)
2439
+ );
2440
+ if (features.size > 1) {
2441
+ lines.push("");
2442
+ for (const feat of [...features].sort()) {
2443
+ const ft = tickets.filter(
2444
+ (t) => t.feature === feat
2445
+ );
2446
+ const done = ft.filter(
2447
+ (t) => t.status === "done"
2448
+ ).length;
2449
+ const failed = ft.filter(
2450
+ (t) => failedKeys.has(
2451
+ ticketKey(t.feature, t.num)
2452
+ )
2453
+ ).length;
2454
+ const rem = ft.filter(
2455
+ (t) => t.status === "ready-for-agent"
2456
+ ).length;
2457
+ let detail = ` ${feat}: ${done} completed`;
2458
+ if (rem > 0) detail += `, ${rem} remaining`;
2459
+ if (failed > 0) detail += `, ${failed} failed`;
2460
+ lines.push(detail);
2461
+ }
2462
+ }
2463
+ if (human.length === 0) return lines.join("\n");
2347
2464
  const noun = human.length === 1 ? "ticket is" : "tickets are";
2348
- const lines = [
2349
- counts,
2465
+ lines.push(
2350
2466
  "",
2351
- (remaining.length === 0 && failedNums.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:",
2467
+ (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:",
2352
2468
  indentFilenames(human)
2353
- ];
2469
+ );
2354
2470
  if (blockedByHuman.length > 0) {
2355
2471
  const n = blockedByHuman.length;
2356
2472
  lines.push(
@@ -2383,28 +2499,34 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2383
2499
  for (const _ of simulateWaves(tickets)) totalWaves++;
2384
2500
  let totalCompleted = 0;
2385
2501
  let waveNum = 0;
2386
- const failedNums = /* @__PURE__ */ new Set();
2502
+ const failedKeys = /* @__PURE__ */ new Set();
2387
2503
  while (true) {
2388
2504
  const ready = findReadyTickets(tickets).filter(
2389
- (t) => !failedNums.has(t.num)
2505
+ (t) => !failedKeys.has(
2506
+ ticketKey(t.feature, t.num)
2507
+ )
2390
2508
  );
2391
2509
  if (ready.length === 0) break;
2392
2510
  waveNum++;
2393
- const readyNums = new Set(ready.map((t) => t.num));
2511
+ const readyKeys = new Set(
2512
+ ready.map((t) => ticketKey(t.feature, t.num))
2513
+ );
2394
2514
  const remaining2 = tickets.filter(
2395
- (t) => t.status === "ready-for-agent" && !readyNums.has(t.num)
2515
+ (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
2396
2516
  );
2397
2517
  d.startWave(
2398
2518
  waveNum,
2399
2519
  totalWaves,
2400
2520
  ready.map((t) => ({
2401
2521
  num: t.num,
2402
- filename: t.filename
2522
+ filename: t.filename,
2523
+ feature: t.feature
2403
2524
  })),
2404
2525
  runProgress(tickets),
2405
2526
  remaining2.map((t) => ({
2406
2527
  num: t.num,
2407
- filename: t.filename
2528
+ filename: t.filename,
2529
+ feature: t.feature
2408
2530
  }))
2409
2531
  );
2410
2532
  const results = await runPool(
@@ -2420,7 +2542,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2420
2542
  /\.md$/,
2421
2543
  ""
2422
2544
  );
2423
- d.setTicketStatus(t.num, "in-progress");
2545
+ d.setTicketStatus(
2546
+ t.num,
2547
+ "in-progress",
2548
+ t.feature
2549
+ );
2424
2550
  d.log(` Starting ${t.filename}`);
2425
2551
  let level = effort;
2426
2552
  let result = await spawnAgent({
@@ -2471,8 +2597,14 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2471
2597
  if (r.status === "rejected") continue;
2472
2598
  const { ticket, result } = r.value;
2473
2599
  if (result.exitCode !== 0) {
2474
- failedNums.add(ticket.num);
2475
- d.setTicketStatus(ticket.num, "failed");
2600
+ failedKeys.add(
2601
+ ticketKey(ticket.feature, ticket.num)
2602
+ );
2603
+ d.setTicketStatus(
2604
+ ticket.num,
2605
+ "failed",
2606
+ ticket.feature
2607
+ );
2476
2608
  d.log(
2477
2609
  ` ${ticket.filename}: agent exited ${result.exitCode}`
2478
2610
  );
@@ -2482,13 +2614,19 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2482
2614
  markDone(ticket);
2483
2615
  waveCompleted++;
2484
2616
  totalCompleted++;
2485
- d.setTicketStatus(ticket.num, "done");
2617
+ d.setTicketStatus(
2618
+ ticket.num,
2619
+ "done",
2620
+ ticket.feature
2621
+ );
2486
2622
  d.log(
2487
2623
  ` ${ticket.filename}: done`
2488
2624
  );
2489
2625
  }
2490
2626
  if (waveCompleted === 0) {
2491
- const remaining3 = findReadyTickets(tickets).filter((t) => !failedNums.has(t.num));
2627
+ const remaining3 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
2628
+ ticketKey(t.feature, t.num)
2629
+ ));
2492
2630
  if (remaining3.length === 0) {
2493
2631
  d.log(
2494
2632
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
@@ -2507,7 +2645,7 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
2507
2645
  d.log("\n" + renderFinishSummary(
2508
2646
  tickets,
2509
2647
  totalCompleted,
2510
- failedNums
2648
+ failedKeys
2511
2649
  ));
2512
2650
  d.cleanup();
2513
2651
  return { allDone: remaining.length === 0 };
@@ -2529,45 +2667,97 @@ async function runWavesIsolated(opts) {
2529
2667
  "Working tree is dirty; commit or stash changes before running"
2530
2668
  );
2531
2669
  }
2532
- const progressPath = join4(
2670
+ const features = new Set(
2671
+ opts.tickets.map((t) => t.feature).filter(Boolean)
2672
+ );
2673
+ const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
2674
+ const fallbackProgressPath = join4(
2533
2675
  dirname2(opts.logsDir),
2534
2676
  "progress.md"
2535
2677
  );
2536
- const progress = mergeProgress(
2537
- generateProgress(opts.featureName, opts.tickets),
2538
- readProgress(progressPath)
2539
- );
2540
- await saveProgress(
2541
- repoRoot,
2542
- progressPath,
2543
- progress,
2544
- `nightralph: start ${opts.featureName}`,
2545
- d
2546
- );
2678
+ let tracker;
2679
+ if (multiFeature) {
2680
+ const multiData = generateMultiFeatureProgress(
2681
+ opts.tickets
2682
+ );
2683
+ const dataMap = /* @__PURE__ */ new Map();
2684
+ for (const [feat, fresh] of multiData) {
2685
+ const path = opts.progressPaths.get(feat) ?? fallbackProgressPath;
2686
+ const merged = mergeProgress(
2687
+ fresh,
2688
+ readProgress(path)
2689
+ );
2690
+ dataMap.set(feat, merged);
2691
+ await saveProgress(
2692
+ repoRoot,
2693
+ path,
2694
+ merged,
2695
+ `nightralph: start ${feat}`,
2696
+ d
2697
+ );
2698
+ }
2699
+ tracker = createProgressTracker({
2700
+ progressPaths: opts.progressPaths,
2701
+ dataMap,
2702
+ fallbackPath: fallbackProgressPath,
2703
+ fallbackData: generateProgress(
2704
+ opts.featureName,
2705
+ []
2706
+ )
2707
+ });
2708
+ } else {
2709
+ const progressPath = fallbackProgressPath;
2710
+ const progress = mergeProgress(
2711
+ generateProgress(
2712
+ opts.featureName,
2713
+ opts.tickets
2714
+ ),
2715
+ readProgress(progressPath)
2716
+ );
2717
+ await saveProgress(
2718
+ repoRoot,
2719
+ progressPath,
2720
+ progress,
2721
+ `nightralph: start ${opts.featureName}`,
2722
+ d
2723
+ );
2724
+ tracker = createProgressTracker({
2725
+ progressPaths: /* @__PURE__ */ new Map(),
2726
+ dataMap: /* @__PURE__ */ new Map(),
2727
+ fallbackPath: progressPath,
2728
+ fallbackData: progress
2729
+ });
2730
+ }
2547
2731
  let totalCompleted = 0;
2548
2732
  let waveNum = 0;
2549
- const failedNums = /* @__PURE__ */ new Set();
2733
+ const failedKeys = /* @__PURE__ */ new Set();
2550
2734
  while (true) {
2551
2735
  const ready = findReadyTickets(opts.tickets).filter(
2552
- (t) => !failedNums.has(t.num)
2736
+ (t) => !failedKeys.has(
2737
+ ticketKey(t.feature, t.num)
2738
+ )
2553
2739
  );
2554
2740
  if (ready.length === 0) break;
2555
2741
  waveNum++;
2556
- const readyNums = new Set(ready.map((t) => t.num));
2742
+ const readyKeys = new Set(
2743
+ ready.map((t) => ticketKey(t.feature, t.num))
2744
+ );
2557
2745
  const remaining2 = opts.tickets.filter(
2558
- (t) => t.status === "ready-for-agent" && !readyNums.has(t.num)
2746
+ (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
2559
2747
  );
2560
2748
  d.startWave(
2561
2749
  waveNum,
2562
2750
  totalWaves,
2563
2751
  ready.map((t) => ({
2564
2752
  num: t.num,
2565
- filename: t.filename
2753
+ filename: t.filename,
2754
+ feature: t.feature
2566
2755
  })),
2567
2756
  runProgress(opts.tickets),
2568
2757
  remaining2.map((t) => ({
2569
2758
  num: t.num,
2570
- filename: t.filename
2759
+ filename: t.filename,
2760
+ feature: t.feature
2571
2761
  }))
2572
2762
  );
2573
2763
  const settled = await runPool(
@@ -2579,6 +2769,7 @@ async function runWavesIsolated(opts) {
2579
2769
  repoName,
2580
2770
  baseBranch,
2581
2771
  specBody: opts.specBody,
2772
+ specBodies: opts.specBodies,
2582
2773
  agentCmd: opts.agentCmd,
2583
2774
  model: opts.model,
2584
2775
  timeout: opts.timeout,
@@ -2596,86 +2787,104 @@ async function runWavesIsolated(opts) {
2596
2787
  );
2597
2788
  let waveCompleted = 0;
2598
2789
  const successfulBranches = [];
2599
- const ticketsByNum = /* @__PURE__ */ new Map();
2600
- const worktreePathsByNum = /* @__PURE__ */ new Map();
2790
+ const ticketsByKey = /* @__PURE__ */ new Map();
2791
+ const worktreePathsByKey = /* @__PURE__ */ new Map();
2792
+ const branchToKey = /* @__PURE__ */ new Map();
2601
2793
  for (let i = 0; i < outcomes.length; i++) {
2602
2794
  const outcome = outcomes[i];
2603
2795
  const ticket = ready[i];
2796
+ const key = ticketKey(ticket.feature, ticket.num);
2604
2797
  if (outcome.kind === "worktree-error") {
2605
- failedNums.add(ticket.num);
2606
- d.setTicketStatus(ticket.num, "failed");
2798
+ failedKeys.add(key);
2799
+ d.setTicketStatus(
2800
+ ticket.num,
2801
+ "failed",
2802
+ ticket.feature
2803
+ );
2607
2804
  d.log(
2608
2805
  ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(outcome.error)}`
2609
2806
  );
2610
- checkTicket(progress, ticket.num, {
2807
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2611
2808
  branch: "",
2612
2809
  exitCode: -1,
2613
2810
  done: false
2614
2811
  });
2615
2812
  await saveProgress(
2616
2813
  repoRoot,
2617
- progressPath,
2618
- progress,
2814
+ tracker.pathFor(ticket.feature),
2815
+ tracker.dataFor(ticket.feature),
2619
2816
  `nightralph: ${ticket.filename} worktree error`,
2620
2817
  d
2621
2818
  );
2622
2819
  continue;
2623
2820
  }
2624
2821
  if (outcome.kind === "error") {
2625
- failedNums.add(ticket.num);
2626
- d.setTicketStatus(ticket.num, "failed");
2822
+ failedKeys.add(key);
2823
+ d.setTicketStatus(
2824
+ ticket.num,
2825
+ "failed",
2826
+ ticket.feature
2827
+ );
2627
2828
  d.log(
2628
2829
  ` ${ticket.filename}: attempt error: ` + formatErrorMessage(outcome.error)
2629
2830
  );
2630
- checkTicket(progress, ticket.num, {
2831
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2631
2832
  branch: outcome.branchName ?? "",
2632
2833
  exitCode: -1,
2633
2834
  done: false
2634
2835
  });
2635
2836
  await saveProgress(
2636
2837
  repoRoot,
2637
- progressPath,
2638
- progress,
2838
+ tracker.pathFor(ticket.feature),
2839
+ tracker.dataFor(ticket.feature),
2639
2840
  `nightralph: ${ticket.filename} error`,
2640
2841
  d
2641
2842
  );
2642
2843
  continue;
2643
2844
  }
2644
2845
  if (outcome.kind === "no-commits") {
2645
- failedNums.add(ticket.num);
2646
- d.setTicketStatus(ticket.num, "failed");
2846
+ failedKeys.add(key);
2847
+ d.setTicketStatus(
2848
+ ticket.num,
2849
+ "failed",
2850
+ ticket.feature
2851
+ );
2647
2852
  d.log(
2648
2853
  ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
2649
2854
  );
2650
- checkTicket(progress, ticket.num, {
2855
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2651
2856
  branch: outcome.branchName,
2652
2857
  exitCode: 0,
2653
2858
  done: false
2654
2859
  });
2655
2860
  await saveProgress(
2656
2861
  repoRoot,
2657
- progressPath,
2658
- progress,
2862
+ tracker.pathFor(ticket.feature),
2863
+ tracker.dataFor(ticket.feature),
2659
2864
  `nightralph: ${ticket.filename} no commits`,
2660
2865
  d
2661
2866
  );
2662
2867
  continue;
2663
2868
  }
2664
2869
  if (outcome.kind === "failed") {
2665
- failedNums.add(ticket.num);
2666
- d.setTicketStatus(ticket.num, "failed");
2870
+ failedKeys.add(key);
2871
+ d.setTicketStatus(
2872
+ ticket.num,
2873
+ "failed",
2874
+ ticket.feature
2875
+ );
2667
2876
  if (outcome.reason === "exit") {
2668
2877
  checkItems(ticket, outcome.completedItems);
2669
2878
  }
2670
- checkTicket(progress, ticket.num, {
2879
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2671
2880
  branch: outcome.branchName,
2672
2881
  exitCode: outcome.exitCode,
2673
2882
  done: false
2674
2883
  });
2675
2884
  await saveProgress(
2676
2885
  repoRoot,
2677
- progressPath,
2678
- progress,
2886
+ tracker.pathFor(ticket.feature),
2887
+ tracker.dataFor(ticket.feature),
2679
2888
  `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
2680
2889
  d
2681
2890
  );
@@ -2685,11 +2894,12 @@ async function runWavesIsolated(opts) {
2685
2894
  d.log(
2686
2895
  ` ${ticket.filename}: agent finished, awaiting merge`
2687
2896
  );
2688
- ticketsByNum.set(ticket.num, ticket);
2689
- worktreePathsByNum.set(
2690
- ticket.num,
2897
+ ticketsByKey.set(key, ticket);
2898
+ worktreePathsByKey.set(
2899
+ key,
2691
2900
  outcome.worktreePath
2692
2901
  );
2902
+ branchToKey.set(outcome.branchName, key);
2693
2903
  successfulBranches.push({
2694
2904
  branch: outcome.branchName,
2695
2905
  ticketNum: ticket.num,
@@ -2699,9 +2909,10 @@ async function runWavesIsolated(opts) {
2699
2909
  if (successfulBranches.length > 0) {
2700
2910
  await commitTicketFiles(
2701
2911
  repoRoot,
2702
- successfulBranches.map(
2703
- (sb) => ticketsByNum.get(sb.ticketNum)
2704
- ),
2912
+ successfulBranches.map((sb) => {
2913
+ const k = branchToKey.get(sb.branch);
2914
+ return ticketsByKey.get(k);
2915
+ }),
2705
2916
  d
2706
2917
  );
2707
2918
  const mergeResult = await mergeWave({
@@ -2711,32 +2922,35 @@ async function runWavesIsolated(opts) {
2711
2922
  strategy: opts.conflictStrategy
2712
2923
  });
2713
2924
  for (const branchResult of mergeResult.merged) {
2714
- const ticket = ticketsByNum.get(
2715
- branchResult.ticketNum
2925
+ const bk = branchToKey.get(
2926
+ branchResult.branch
2716
2927
  );
2928
+ const ticket = bk ? ticketsByKey.get(bk) : void 0;
2717
2929
  if (!ticket) continue;
2718
2930
  if (branchResult.merged) {
2719
2931
  markDone(ticket);
2720
2932
  waveCompleted++;
2721
2933
  totalCompleted++;
2722
- d.setTicketStatus(ticket.num, "done");
2934
+ d.setTicketStatus(
2935
+ ticket.num,
2936
+ "done",
2937
+ ticket.feature
2938
+ );
2723
2939
  d.log(
2724
2940
  ` ${ticket.filename}: done`
2725
2941
  );
2726
- checkTicket(progress, ticket.num, {
2942
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2727
2943
  branch: branchResult.branch,
2728
2944
  exitCode: 0
2729
2945
  });
2730
2946
  await saveProgress(
2731
2947
  repoRoot,
2732
- progressPath,
2733
- progress,
2948
+ tracker.pathFor(ticket.feature),
2949
+ tracker.dataFor(ticket.feature),
2734
2950
  `nightralph: ${ticket.filename} done`,
2735
2951
  d
2736
2952
  );
2737
- const worktreePath = worktreePathsByNum.get(
2738
- ticket.num
2739
- );
2953
+ const worktreePath = bk ? worktreePathsByKey.get(bk) : void 0;
2740
2954
  if (worktreePath) {
2741
2955
  await removeWorktreeQuietly(
2742
2956
  worktreePath,
@@ -2744,16 +2958,21 @@ async function runWavesIsolated(opts) {
2744
2958
  );
2745
2959
  }
2746
2960
  } else {
2747
- const worktreePath = worktreePathsByNum.get(
2748
- ticket.num
2961
+ const wtKey = branchToKey.get(
2962
+ branchResult.branch
2749
2963
  );
2964
+ const worktreePath = wtKey ? worktreePathsByKey.get(wtKey) : void 0;
2750
2965
  const recovered = opts.conflictStrategy === "respawn" && worktreePath ? await respawnAndRetryMerge({
2751
2966
  repoRoot,
2752
2967
  baseBranch,
2753
2968
  branch: branchResult.branch,
2754
2969
  ticket,
2755
2970
  worktreePath,
2756
- specBody: opts.specBody,
2971
+ specBody: lookupSpec(
2972
+ opts.specBodies,
2973
+ opts.specBody,
2974
+ ticket.feature
2975
+ ),
2757
2976
  agentCmd: opts.agentCmd,
2758
2977
  model: opts.model,
2759
2978
  timeout: opts.timeout,
@@ -2766,18 +2985,22 @@ async function runWavesIsolated(opts) {
2766
2985
  markDone(ticket);
2767
2986
  waveCompleted++;
2768
2987
  totalCompleted++;
2769
- d.setTicketStatus(ticket.num, "done");
2988
+ d.setTicketStatus(
2989
+ ticket.num,
2990
+ "done",
2991
+ ticket.feature
2992
+ );
2770
2993
  d.log(
2771
2994
  ` ${ticket.filename}: done (resolved via respawn)`
2772
2995
  );
2773
- checkTicket(progress, ticket.num, {
2996
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2774
2997
  branch: branchResult.branch,
2775
2998
  exitCode: 0
2776
2999
  });
2777
3000
  await saveProgress(
2778
3001
  repoRoot,
2779
- progressPath,
2780
- progress,
3002
+ tracker.pathFor(ticket.feature),
3003
+ tracker.dataFor(ticket.feature),
2781
3004
  `nightralph: ${ticket.filename} done (respawn)`,
2782
3005
  d
2783
3006
  );
@@ -2794,7 +3017,9 @@ async function runWavesIsolated(opts) {
2794
3017
  }
2795
3018
  }
2796
3019
  } else {
2797
- failedNums.add(ticket.num);
3020
+ failedKeys.add(
3021
+ ticketKey(ticket.feature, ticket.num)
3022
+ );
2798
3023
  d.log(
2799
3024
  ` ${ticket.filename}: merge failed, staying ready-for-agent`
2800
3025
  );
@@ -2803,15 +3028,15 @@ async function runWavesIsolated(opts) {
2803
3028
  " git: " + branchResult.stderr.trim()
2804
3029
  );
2805
3030
  }
2806
- checkTicket(progress, ticket.num, {
3031
+ checkTicket(tracker.dataFor(ticket.feature), ticket.num, {
2807
3032
  branch: branchResult.branch,
2808
3033
  exitCode: 0,
2809
3034
  done: false
2810
3035
  });
2811
3036
  await saveProgress(
2812
3037
  repoRoot,
2813
- progressPath,
2814
- progress,
3038
+ tracker.pathFor(ticket.feature),
3039
+ tracker.dataFor(ticket.feature),
2815
3040
  `nightralph: ${ticket.filename} merge failed`,
2816
3041
  d
2817
3042
  );
@@ -2824,7 +3049,8 @@ async function runWavesIsolated(opts) {
2824
3049
  Merge stopped at ${mergeResult.stoppedAt.branch}`
2825
3050
  );
2826
3051
  for (const sb of successfulBranches) {
2827
- const ticket = ticketsByNum.get(sb.ticketNum);
3052
+ const sbk = branchToKey.get(sb.branch);
3053
+ const ticket = sbk ? ticketsByKey.get(sbk) : void 0;
2828
3054
  if (ticket && ticket.status === "done") continue;
2829
3055
  try {
2830
3056
  await removeWorktree(
@@ -2843,7 +3069,9 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
2843
3069
  if (waveCompleted === 0) {
2844
3070
  const remaining3 = findReadyTickets(
2845
3071
  opts.tickets
2846
- ).filter((t) => !failedNums.has(t.num));
3072
+ ).filter((t) => !failedKeys.has(
3073
+ ticketKey(t.feature, t.num)
3074
+ ));
2847
3075
  if (remaining3.length === 0) {
2848
3076
  d.log(
2849
3077
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
@@ -2862,7 +3090,7 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
2862
3090
  d.log("\n" + renderFinishSummary(
2863
3091
  opts.tickets,
2864
3092
  totalCompleted,
2865
- failedNums
3093
+ failedKeys
2866
3094
  ));
2867
3095
  d.cleanup();
2868
3096
  return { allDone: remaining.length === 0 };
@@ -2873,7 +3101,7 @@ __name(runWavesIsolated, "runWavesIsolated");
2873
3101
  import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
2874
3102
  import { dirname as dirname3, join as join5 } from "node:path";
2875
3103
  import { fileURLToPath } from "node:url";
2876
- import { select } from "@inquirer/prompts";
3104
+ import { checkbox, Separator } from "@inquirer/prompts";
2877
3105
  function getScriptDir(metaUrl) {
2878
3106
  return dirname3(fileURLToPath(metaUrl));
2879
3107
  }
@@ -2898,24 +3126,32 @@ function findFeatureDirs(cwd) {
2898
3126
  entry.name,
2899
3127
  "spec.md"
2900
3128
  );
2901
- if (existsSync4(issuesDir)) {
2902
- results.push({
2903
- name: entry.name,
2904
- dir: issuesDir,
2905
- spec: specFile
2906
- });
2907
- }
3129
+ if (!existsSync4(issuesDir)) continue;
3130
+ const progressFile = join5(
3131
+ scratchDir,
3132
+ entry.name,
3133
+ "progress.md"
3134
+ );
3135
+ if (isSpecComplete(progressFile)) continue;
3136
+ results.push({
3137
+ name: entry.name,
3138
+ dir: issuesDir,
3139
+ spec: specFile
3140
+ });
2908
3141
  }
3142
+ results.sort((a, b) => a.name.localeCompare(b.name));
2909
3143
  return results;
2910
3144
  }
2911
3145
  __name(findFeatureDirs, "findFeatureDirs");
3146
+ var ALL_VALUE = "__all__";
2912
3147
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
2913
3148
  if (specName) {
2914
3149
  const featureDir = join5(cwd, ".scratch", specName);
2915
- return {
3150
+ return [{
2916
3151
  dir: join5(featureDir, "issues"),
2917
- spec: join5(featureDir, "spec.md")
2918
- };
3152
+ spec: join5(featureDir, "spec.md"),
3153
+ name: specName
3154
+ }];
2919
3155
  }
2920
3156
  const features = findFeatureDirs(cwd);
2921
3157
  if (features.length === 0) {
@@ -2924,21 +3160,40 @@ async function resolveIssuesDir(specName, cwd = process.cwd()) {
2924
3160
  );
2925
3161
  process.exit(2);
2926
3162
  }
2927
- let chosen;
2928
3163
  if (features.length === 1) {
2929
- chosen = features[0];
2930
- console.log(`Using feature: ${chosen.name}`);
2931
- } else {
2932
- const name = await select({
2933
- message: "Which feature?",
2934
- choices: features.map((f) => ({
2935
- name: f.name,
2936
- value: f.name
2937
- }))
3164
+ const f = features[0];
3165
+ console.log(`Using feature: ${f.name}`);
3166
+ return [{ dir: f.dir, spec: f.spec, name: f.name }];
3167
+ }
3168
+ const featureChoices = features.map((f) => {
3169
+ const tickets = scanTickets(f.dir);
3170
+ const ready = findReadyTickets(tickets).length;
3171
+ return {
3172
+ name: `${f.name} (${ready}/${tickets.length} ready)`,
3173
+ value: f.name
3174
+ };
3175
+ });
3176
+ while (true) {
3177
+ const selected = await checkbox({
3178
+ message: "Which features?",
3179
+ choices: [
3180
+ { name: "All", value: ALL_VALUE },
3181
+ new Separator(),
3182
+ ...featureChoices
3183
+ ]
3184
+ });
3185
+ if (selected.length === 0) {
3186
+ console.log("Select at least one feature");
3187
+ continue;
3188
+ }
3189
+ const names = selected.includes(ALL_VALUE) ? features.map((f) => f.name) : selected;
3190
+ return names.map((name) => {
3191
+ const f = features.find(
3192
+ (ff) => ff.name === name
3193
+ );
3194
+ return { dir: f.dir, spec: f.spec, name: f.name };
2938
3195
  });
2939
- chosen = features.find((f) => f.name === name);
2940
3196
  }
2941
- return { dir: chosen.dir, spec: chosen.spec };
2942
3197
  }
2943
3198
  __name(resolveIssuesDir, "resolveIssuesDir");
2944
3199
 
@@ -2949,7 +3204,7 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2949
3204
  "Install bundled skills and doc templates",
2950
3205
  (y) => y,
2951
3206
  async () => {
2952
- const tracker = await select2({
3207
+ const tracker = await select({
2953
3208
  message: "Issue tracker format",
2954
3209
  choices: [
2955
3210
  {
@@ -3072,61 +3327,86 @@ function extractFeatureName(spec) {
3072
3327
  }
3073
3328
  __name(extractFeatureName, "extractFeatureName");
3074
3329
  async function runExecute(argv) {
3075
- const { dir, spec } = await resolveIssuesDir(
3330
+ const selections = await resolveIssuesDir(
3076
3331
  argv.spec
3077
3332
  );
3078
- if (!existsSync5(dir)) {
3079
- console.error(`Directory not found: ${dir}`);
3080
- process.exit(2);
3081
- }
3082
- if (!existsSync5(spec)) {
3083
- console.error(`Spec file not found: ${spec}`);
3084
- process.exit(2);
3085
- }
3086
- const specBody = stripSpecSection(
3087
- readFileSync5(spec, "utf8"),
3088
- "User Stories"
3089
- );
3090
- const tickets = scanTickets(dir);
3091
- if (tickets.length === 0) {
3092
- console.log("No ticket files found. Exiting.");
3093
- return;
3094
- }
3095
- console.log(`Found ${tickets.length} tickets`);
3096
- const knownNums = new Set(tickets.map((t) => t.num));
3097
- for (const t of tickets) {
3098
- const unknown = t.blockers.filter(
3099
- (b) => !knownNums.has(b)
3333
+ const allTickets = [];
3334
+ const specBodies = /* @__PURE__ */ new Map();
3335
+ const progressPaths = /* @__PURE__ */ new Map();
3336
+ let firstFeatureName = "";
3337
+ let firstLogsDir = "";
3338
+ for (const selection of selections) {
3339
+ const { dir, spec, name } = selection;
3340
+ if (!existsSync5(dir)) {
3341
+ console.error(
3342
+ `Directory not found: ${dir}`
3343
+ );
3344
+ process.exit(2);
3345
+ }
3346
+ if (!existsSync5(spec)) {
3347
+ console.error(
3348
+ `Spec file not found: ${spec}`
3349
+ );
3350
+ process.exit(2);
3351
+ }
3352
+ const specBody = stripSpecSection(
3353
+ readFileSync5(spec, "utf8"),
3354
+ "User Stories"
3355
+ );
3356
+ const tickets = scanTickets(dir, name);
3357
+ if (tickets.length === 0) {
3358
+ console.log(
3359
+ `No ticket files found for ${name}. Skipping.`
3360
+ );
3361
+ continue;
3362
+ }
3363
+ console.log(
3364
+ `[${name}] Found ${tickets.length} tickets`
3365
+ );
3366
+ const knownNums = new Set(
3367
+ tickets.map((t) => t.num)
3100
3368
  );
3101
- if (unknown.length > 0) {
3102
- console.warn(
3103
- `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
3369
+ for (const t of tickets) {
3370
+ const unknown = t.blockers.filter(
3371
+ (b) => !knownNums.has(b)
3372
+ );
3373
+ if (unknown.length > 0) {
3374
+ console.warn(
3375
+ `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
3376
+ );
3377
+ }
3378
+ t.blockers = t.blockers.filter(
3379
+ (b) => knownNums.has(b)
3104
3380
  );
3105
3381
  }
3106
- t.blockers = t.blockers.filter(
3107
- (b) => knownNums.has(b)
3382
+ specBodies.set(name, specBody);
3383
+ progressPaths.set(
3384
+ name,
3385
+ join6(dirname4(dir), "progress.md")
3108
3386
  );
3387
+ allTickets.push(...tickets);
3388
+ if (!firstFeatureName) {
3389
+ firstFeatureName = extractFeatureName(spec);
3390
+ firstLogsDir = join6(dirname4(dir), "logs");
3391
+ }
3109
3392
  }
3110
- const logsDir = join6(dirname4(dir), "logs");
3393
+ if (allTickets.length === 0) return;
3111
3394
  const timeout = argv.timeout ?? 900;
3112
3395
  const testCmd = argv.testCmd || detectTestCmd(
3113
3396
  isGitRepo() ? getRepoRoot() : process.cwd()
3114
3397
  ) || "npm test";
3115
- const featureName = extractFeatureName(spec);
3116
3398
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
3117
- const progressPath = join6(
3118
- dirname4(dir),
3119
- "progress.md"
3120
- );
3399
+ const logsDir = firstLogsDir;
3400
+ const fallbackSpec = specBodies.values().next().value;
3121
3401
  if (argv.dryRun) {
3122
3402
  if (isGitRepo()) {
3123
3403
  const repoName = getRepoName();
3124
3404
  dryRunIsolated({
3125
- tickets,
3126
- specBody,
3405
+ tickets: allTickets,
3406
+ specBody: fallbackSpec,
3127
3407
  repoName,
3128
3408
  conflictStrategy,
3129
- progressPath,
3409
+ progressPath: progressPaths.values().next().value,
3130
3410
  testCmd,
3131
3411
  effort: argv.effort,
3132
3412
  retries: argv.retries,
@@ -3134,7 +3414,7 @@ async function runExecute(argv) {
3134
3414
  concurrency: argv.concurrency
3135
3415
  });
3136
3416
  } else {
3137
- dryRun(tickets, specBody, testCmd);
3417
+ dryRun(allTickets, fallbackSpec, testCmd);
3138
3418
  }
3139
3419
  return;
3140
3420
  }
@@ -3150,13 +3430,14 @@ async function runExecute(argv) {
3150
3430
  let result;
3151
3431
  if (isGitRepo()) {
3152
3432
  result = await runWavesIsolated({
3153
- tickets,
3154
- specBody,
3433
+ tickets: allTickets,
3434
+ specBody: fallbackSpec,
3435
+ specBodies,
3155
3436
  agentCmd: argv.provider,
3156
3437
  model: argv.model ?? null,
3157
3438
  timeout,
3158
3439
  logsDir,
3159
- featureName,
3440
+ featureName: firstFeatureName,
3160
3441
  conflictStrategy,
3161
3442
  display,
3162
3443
  testCmd,
@@ -3164,12 +3445,13 @@ async function runExecute(argv) {
3164
3445
  effort: argv.effort,
3165
3446
  retries: argv.retries,
3166
3447
  retryDelay: argv.retryDelay,
3167
- concurrency: argv.concurrency
3448
+ concurrency: argv.concurrency,
3449
+ progressPaths
3168
3450
  });
3169
3451
  } else {
3170
3452
  result = await runWaves(
3171
- tickets,
3172
- specBody,
3453
+ allTickets,
3454
+ fallbackSpec,
3173
3455
  argv.provider,
3174
3456
  argv.model ?? null,
3175
3457
  timeout,