nightralph 0.0.53 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +181 -19
  2. package/dist/blockers.js +149 -0
  3. package/dist/blockers.js.map +7 -0
  4. package/dist/display.js +12 -4
  5. package/dist/display.js.map +2 -2
  6. package/dist/docs-templates/issue-tracker.md +9 -1
  7. package/dist/index.js +2735 -415
  8. package/dist/index.js.map +4 -4
  9. package/dist/learnings.js +360 -0
  10. package/dist/learnings.js.map +7 -0
  11. package/dist/meta.json +289 -16
  12. package/dist/orchestrator.js +753 -188
  13. package/dist/orchestrator.js.map +3 -3
  14. package/dist/preflight.js +34 -0
  15. package/dist/preflight.js.map +7 -0
  16. package/dist/progress.js +5 -4
  17. package/dist/progress.js.map +2 -2
  18. package/dist/resolve.js +98 -9
  19. package/dist/resolve.js.map +2 -2
  20. package/dist/retry.js +83 -0
  21. package/dist/retry.js.map +7 -0
  22. package/dist/review.js +669 -0
  23. package/dist/review.js.map +7 -0
  24. package/dist/rules.js +67 -0
  25. package/dist/rules.js.map +7 -0
  26. package/dist/skills/to-tickets/SKILL.md +5 -5
  27. package/dist/src/blockers.d.ts +45 -0
  28. package/dist/src/blockers.d.ts.map +1 -0
  29. package/dist/src/display.d.ts +9 -0
  30. package/dist/src/display.d.ts.map +1 -1
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/learnings.d.ts +70 -0
  33. package/dist/src/learnings.d.ts.map +1 -0
  34. package/dist/src/orchestrator.d.ts +85 -28
  35. package/dist/src/orchestrator.d.ts.map +1 -1
  36. package/dist/src/preflight.d.ts +6 -0
  37. package/dist/src/preflight.d.ts.map +1 -0
  38. package/dist/src/progress.d.ts +1 -1
  39. package/dist/src/progress.d.ts.map +1 -1
  40. package/dist/src/resolve.d.ts +24 -0
  41. package/dist/src/resolve.d.ts.map +1 -1
  42. package/dist/src/retry.d.ts +26 -0
  43. package/dist/src/retry.d.ts.map +1 -0
  44. package/dist/src/review.d.ts +131 -0
  45. package/dist/src/review.d.ts.map +1 -0
  46. package/dist/src/rules.d.ts +11 -0
  47. package/dist/src/rules.d.ts.map +1 -0
  48. package/dist/src/stats.d.ts +43 -0
  49. package/dist/src/stats.d.ts.map +1 -0
  50. package/dist/src/testcmd.d.ts +1 -0
  51. package/dist/src/testcmd.d.ts.map +1 -1
  52. package/dist/src/worktree.d.ts +30 -1
  53. package/dist/src/worktree.d.ts.map +1 -1
  54. package/dist/stats.js +111 -0
  55. package/dist/stats.js.map +7 -0
  56. package/dist/testcmd.js +13 -0
  57. package/dist/testcmd.js.map +2 -2
  58. package/dist/worktree.js +236 -32
  59. package/dist/worktree.js.map +2 -2
  60. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -3,8 +3,9 @@ var __defProp = Object.defineProperty;
3
3
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
4
4
 
5
5
  // src/index.ts
6
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
7
- import { dirname as dirname4, join as join6 } from "node:path";
6
+ import { execFileSync as execFileSync2 } from "node:child_process";
7
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
8
+ import { basename as basename6, dirname as dirname6, join as join9 } from "node:path";
8
9
  import yargs from "yargs";
9
10
  import { hideBin } from "yargs/helpers";
10
11
  import { select } from "@inquirer/prompts";
@@ -198,14 +199,15 @@ __name(setup, "setup");
198
199
  import { spawn as spawn2, execFile as execFile4 } from "node:child_process";
199
200
  import {
200
201
  createWriteStream,
201
- mkdirSync as mkdirSync2,
202
- readFileSync as readFileSync4,
203
- writeFileSync as writeFileSync3,
202
+ mkdirSync as mkdirSync3,
203
+ readFileSync as readFileSync6,
204
+ writeFileSync as writeFileSync5,
204
205
  unlinkSync,
205
- readdirSync as readdirSync2
206
+ readdirSync as readdirSync2,
207
+ statSync
206
208
  } from "node:fs";
207
209
  import { tmpdir } from "node:os";
208
- import { join as join4, dirname as dirname2, basename as basename2 } from "node:path";
210
+ import { join as join6, dirname as dirname4, basename as basename5, relative as relative2 } from "node:path";
209
211
  import { createInterface } from "node:readline";
210
212
  import { promisify as promisify4 } from "node:util";
211
213
 
@@ -233,15 +235,138 @@ function getRepoName() {
233
235
  return basename(dirname(resolve(commonDir)));
234
236
  }
235
237
  __name(getRepoName, "getRepoName");
236
- function getCurrentBranch() {
238
+ function getCurrentBranch(cwd) {
237
239
  const stdout = execFileSync(
238
240
  "git",
239
241
  ["rev-parse", "--abbrev-ref", "HEAD"],
240
- { encoding: "utf8" }
242
+ { cwd, encoding: "utf8" }
241
243
  );
242
244
  return stdout.trim();
243
245
  }
244
246
  __name(getCurrentBranch, "getCurrentBranch");
247
+ function localBranchExists(repoRoot, name) {
248
+ try {
249
+ execFileSync(
250
+ "git",
251
+ ["show-ref", "--verify", "--quiet", `refs/heads/${name}`],
252
+ { cwd: repoRoot, stdio: "ignore" }
253
+ );
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+ __name(localBranchExists, "localBranchExists");
260
+ function getDefaultBranch(repoRoot) {
261
+ try {
262
+ const ref = execFileSync(
263
+ "git",
264
+ [
265
+ "symbolic-ref",
266
+ "--quiet",
267
+ "--short",
268
+ "refs/remotes/origin/HEAD"
269
+ ],
270
+ {
271
+ cwd: repoRoot,
272
+ encoding: "utf8",
273
+ stdio: ["ignore", "pipe", "ignore"]
274
+ }
275
+ ).trim();
276
+ if (ref) return ref.replace(/^origin\//, "");
277
+ } catch {
278
+ }
279
+ for (const name of ["main", "master"]) {
280
+ if (localBranchExists(repoRoot, name)) return name;
281
+ }
282
+ return null;
283
+ }
284
+ __name(getDefaultBranch, "getDefaultBranch");
285
+ function integrationBranchName(features) {
286
+ const distinct = [...new Set(features.filter(Boolean))];
287
+ return distinct.length === 1 ? `nightralph--${distinct[0]}` : "nightralph--all";
288
+ }
289
+ __name(integrationBranchName, "integrationBranchName");
290
+ function planIntegrationBranch(current, defaultBranch, features) {
291
+ if (!defaultBranch || current === "HEAD") return null;
292
+ if (current !== defaultBranch) return null;
293
+ return integrationBranchName(features);
294
+ }
295
+ __name(planIntegrationBranch, "planIntegrationBranch");
296
+ function gitText(cwd, args) {
297
+ return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
298
+ }
299
+ __name(gitText, "gitText");
300
+ function prepareIntegrationBranch(repoRoot, features) {
301
+ const current = getCurrentBranch(repoRoot);
302
+ const defaultBranch = getDefaultBranch(repoRoot);
303
+ const name = planIntegrationBranch(
304
+ current,
305
+ defaultBranch,
306
+ features
307
+ );
308
+ if (!name) {
309
+ const onIntegration = defaultBranch !== null && current === integrationBranchName(features);
310
+ return {
311
+ branch: current,
312
+ created: false,
313
+ resumed: false,
314
+ behind: 0,
315
+ fastForwarded: false,
316
+ launchBranch: current,
317
+ forkedFrom: onIntegration ? defaultBranch : null
318
+ };
319
+ }
320
+ if (isDirty(repoRoot)) {
321
+ throw new Error(
322
+ "Working tree is dirty; commit or stash changes before running"
323
+ );
324
+ }
325
+ if (localBranchExists(repoRoot, name)) {
326
+ const contained = isAncestor(repoRoot, name, current);
327
+ gitText(repoRoot, ["checkout", "--quiet", name]);
328
+ let behind = Number(gitText(
329
+ repoRoot,
330
+ ["rev-list", "--count", `${name}..${current}`]
331
+ ));
332
+ const fastForwarded = contained && behind > 0;
333
+ if (fastForwarded) {
334
+ gitText(repoRoot, ["merge", "--ff-only", "--quiet", current]);
335
+ behind = 0;
336
+ }
337
+ return {
338
+ branch: name,
339
+ created: false,
340
+ resumed: true,
341
+ behind,
342
+ fastForwarded,
343
+ launchBranch: current,
344
+ forkedFrom: defaultBranch
345
+ };
346
+ }
347
+ gitText(repoRoot, ["checkout", "--quiet", "-b", name]);
348
+ return {
349
+ branch: name,
350
+ created: true,
351
+ resumed: false,
352
+ behind: 0,
353
+ fastForwarded: false,
354
+ launchBranch: current,
355
+ forkedFrom: defaultBranch
356
+ };
357
+ }
358
+ __name(prepareIntegrationBranch, "prepareIntegrationBranch");
359
+ function describeIntegrationBranch(repoRoot, features) {
360
+ const current = getCurrentBranch(repoRoot);
361
+ const name = planIntegrationBranch(
362
+ current,
363
+ getDefaultBranch(repoRoot),
364
+ features
365
+ );
366
+ if (!name) return `Branch: merges into ${current}`;
367
+ return localBranchExists(repoRoot, name) ? `Branch: would resume ${name}` : `Branch: would create ${name} from ${current}`;
368
+ }
369
+ __name(describeIntegrationBranch, "describeIntegrationBranch");
245
370
  async function branchExists(repoRoot, branchName) {
246
371
  try {
247
372
  await execFileAsync(
@@ -261,16 +386,20 @@ function buildBranchName(repoName, ticket) {
261
386
  return `${repoName}--${prefix}${num}-${ticket.slug}`;
262
387
  }
263
388
  __name(buildBranchName, "buildBranchName");
264
- async function createWorktreeNow(opts) {
265
- const { repoRoot, repoName, baseBranch, ticket } = opts;
266
- const branchName = buildBranchName(repoName, ticket);
267
- const num = String(ticket.num).padStart(2, "0");
268
- const prefix = ticket.feature ? `${ticket.feature}-` : "";
269
- const worktreePath = join2(
270
- repoRoot,
271
- "..",
272
- `${repoName}.${prefix}${num}-${ticket.slug}`
273
- );
389
+ async function removeWorktreeNow(worktreePath, opts) {
390
+ const args = [
391
+ "-C",
392
+ worktreePath,
393
+ "worktree",
394
+ "remove"
395
+ ];
396
+ if (opts?.force) args.push("--force");
397
+ args.push(worktreePath);
398
+ await execFileAsync("git", args);
399
+ }
400
+ __name(removeWorktreeNow, "removeWorktreeNow");
401
+ async function addWorktreeNow(opts) {
402
+ const { repoRoot, baseBranch, branchName, worktreePath } = opts;
274
403
  if (existsSync2(worktreePath)) {
275
404
  try {
276
405
  await execFileAsync(
@@ -310,36 +439,56 @@ async function createWorktreeNow(opts) {
310
439
  );
311
440
  return { worktreePath, branchName };
312
441
  }
313
- __name(createWorktreeNow, "createWorktreeNow");
442
+ __name(addWorktreeNow, "addWorktreeNow");
314
443
  var worktreeQueue = Promise.resolve();
315
- function createWorktree(opts) {
316
- const result = worktreeQueue.then(() => createWorktreeNow(opts));
444
+ function enqueue(fn) {
445
+ const result = worktreeQueue.then(fn);
317
446
  worktreeQueue = result.catch(() => {
318
447
  });
319
448
  return result;
320
449
  }
450
+ __name(enqueue, "enqueue");
451
+ function createWorktree(opts) {
452
+ const { repoRoot, repoName, ticket } = opts;
453
+ const num = String(ticket.num).padStart(2, "0");
454
+ const prefix = ticket.feature ? `${ticket.feature}-` : "";
455
+ return enqueue(() => addWorktreeNow({
456
+ repoRoot,
457
+ baseBranch: opts.baseBranch,
458
+ branchName: buildBranchName(repoName, ticket),
459
+ worktreePath: join2(
460
+ repoRoot,
461
+ "..",
462
+ `${repoName}.${prefix}${num}-${ticket.slug}`
463
+ )
464
+ }));
465
+ }
321
466
  __name(createWorktree, "createWorktree");
322
- async function removeWorktreeNow(worktreePath, opts) {
323
- const args = [
324
- "-C",
325
- worktreePath,
326
- "worktree",
327
- "remove"
328
- ];
329
- if (opts?.force) args.push("--force");
330
- args.push(worktreePath);
331
- await execFileAsync("git", args);
467
+ function createNamedWorktree(opts) {
468
+ const { repoRoot, repoName, name } = opts;
469
+ return enqueue(() => addWorktreeNow({
470
+ repoRoot,
471
+ baseBranch: opts.baseBranch,
472
+ branchName: `${repoName}--${name}`,
473
+ worktreePath: join2(repoRoot, "..", `${repoName}.${name}`)
474
+ }));
332
475
  }
333
- __name(removeWorktreeNow, "removeWorktreeNow");
476
+ __name(createNamedWorktree, "createNamedWorktree");
334
477
  function removeWorktree(worktreePath, opts) {
335
- const result = worktreeQueue.then(
336
- () => removeWorktreeNow(worktreePath, opts)
337
- );
338
- worktreeQueue = result.catch(() => {
339
- });
340
- return result;
478
+ return enqueue(() => removeWorktreeNow(worktreePath, opts));
341
479
  }
342
480
  __name(removeWorktree, "removeWorktree");
481
+ function deleteBranch(repoRoot, name) {
482
+ return enqueue(async () => {
483
+ if (!await branchExists(repoRoot, name)) return;
484
+ await execFileAsync(
485
+ "git",
486
+ ["branch", "-D", name],
487
+ { cwd: repoRoot }
488
+ );
489
+ });
490
+ }
491
+ __name(deleteBranch, "deleteBranch");
343
492
  function hasNewCommits(repoRoot, base, branch) {
344
493
  const stdout = execFileSync(
345
494
  "git",
@@ -349,6 +498,51 @@ function hasNewCommits(repoRoot, base, branch) {
349
498
  return Number(stdout.trim()) > 0;
350
499
  }
351
500
  __name(hasNewCommits, "hasNewCommits");
501
+ function headCommit(cwd) {
502
+ return gitText(cwd, ["rev-parse", "HEAD"]);
503
+ }
504
+ __name(headCommit, "headCommit");
505
+ function mergeBase(cwd, a, b) {
506
+ try {
507
+ return gitText(cwd, ["merge-base", a, b]) || null;
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+ __name(mergeBase, "mergeBase");
513
+ function isAncestor(cwd, ancestor, of) {
514
+ try {
515
+ execFileSync(
516
+ "git",
517
+ ["merge-base", "--is-ancestor", ancestor, of],
518
+ { cwd, stdio: "ignore" }
519
+ );
520
+ return true;
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+ __name(isAncestor, "isAncestor");
526
+ function hasChangesOutside(cwd, from, to, excludeDir) {
527
+ try {
528
+ execFileSync(
529
+ "git",
530
+ [
531
+ "diff",
532
+ "--quiet",
533
+ `${from}..${to}`,
534
+ "--",
535
+ ".",
536
+ `:(exclude)${excludeDir}`
537
+ ],
538
+ { cwd, stdio: "ignore" }
539
+ );
540
+ return false;
541
+ } catch {
542
+ return true;
543
+ }
544
+ }
545
+ __name(hasChangesOutside, "hasChangesOutside");
352
546
  function isDirty(cwd) {
353
547
  const stdout = execFileSync(
354
548
  "git",
@@ -555,16 +749,17 @@ function writeProgress(progressPath, data) {
555
749
  );
556
750
  }
557
751
  __name(writeProgress, "writeProgress");
558
- async function commitProgress(repoRoot, progressPath, message) {
752
+ async function commitProgress(repoRoot, paths, message) {
753
+ const list = typeof paths === "string" ? [paths] : paths;
559
754
  await execFileAsync2(
560
755
  "git",
561
- ["add", "--force", progressPath],
756
+ ["add", "--force", "--", ...list],
562
757
  { cwd: repoRoot }
563
758
  );
564
759
  try {
565
760
  await execFileAsync2(
566
761
  "git",
567
- ["diff", "--cached", "--quiet", "--", progressPath],
762
+ ["diff", "--cached", "--quiet", "--", ...list],
568
763
  { cwd: repoRoot }
569
764
  );
570
765
  return false;
@@ -572,7 +767,7 @@ async function commitProgress(repoRoot, progressPath, message) {
572
767
  }
573
768
  await execFileAsync2(
574
769
  "git",
575
- ["commit", "-m", message, "--", progressPath],
770
+ ["commit", "-m", message, "--", ...list],
576
771
  { cwd: repoRoot }
577
772
  );
578
773
  return true;
@@ -778,6 +973,106 @@ async function mergeWave(opts) {
778
973
  }
779
974
  __name(mergeWave, "mergeWave");
780
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
+
781
1076
  // src/display.ts
782
1077
  var ESC = "\x1B[";
783
1078
  var ansi = {
@@ -839,10 +1134,9 @@ var MAX_MESSAGE_BUFFER = 200;
839
1134
  var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
840
1135
  function rowText(ticket) {
841
1136
  const label = ticketLabel(ticket);
842
- if (ticket.status !== "blocked" || !ticket.blockedBy?.length) {
843
- return label;
844
- }
845
- 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}`;
846
1140
  }
847
1141
  __name(rowText, "rowText");
848
1142
  function fitLine(line, width) {
@@ -971,6 +1265,12 @@ var StatusDisplay = class {
971
1265
  ticket.blockedBy = [...blockedBy];
972
1266
  this.render();
973
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
+ }
974
1274
  log(message) {
975
1275
  if (!this.wave) {
976
1276
  this.config.stream.write(message + "\n");
@@ -1105,95 +1405,1329 @@ var PlainDisplay = class {
1105
1405
  Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
1106
1406
  );
1107
1407
  }
1108
- setTicketStatus(_num, _status, _feature) {
1109
- }
1110
- setBlocked(_num, _feature, _blockedBy) {
1111
- }
1112
- log(message) {
1113
- console.log(message);
1114
- }
1115
- cleanup() {
1116
- }
1117
- };
1118
- function createDisplay(stream) {
1119
- const s = stream ?? process.stdout;
1120
- if (s.isTTY) {
1121
- return new StatusDisplay({ stream: s });
1122
- }
1123
- return new PlainDisplay();
1408
+ setTicketStatus(_num, _status, _feature) {
1409
+ }
1410
+ setBlocked(_num, _feature, _blockedBy) {
1411
+ }
1412
+ setTicketStats(_num, _feature, _stats) {
1413
+ }
1414
+ log(message) {
1415
+ console.log(message);
1416
+ }
1417
+ cleanup() {
1418
+ }
1419
+ };
1420
+ function createDisplay(stream) {
1421
+ const s = stream ?? process.stdout;
1422
+ if (s.isTTY) {
1423
+ return new StatusDisplay({ stream: s });
1424
+ }
1425
+ return new PlainDisplay();
1426
+ }
1427
+ __name(createDisplay, "createDisplay");
1428
+
1429
+ // src/testcmd.ts
1430
+ import { spawn } from "node:child_process";
1431
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1432
+ import { join as join3 } from "node:path";
1433
+ function detectTestCmd(projectDir) {
1434
+ const pkgPath = join3(projectDir, "package.json");
1435
+ if (!existsSync3(pkgPath)) return null;
1436
+ try {
1437
+ const pkg = JSON.parse(
1438
+ readFileSync3(pkgPath, "utf8")
1439
+ );
1440
+ return pkg.scripts?.test ? "npm test" : null;
1441
+ } catch {
1442
+ return null;
1443
+ }
1444
+ }
1445
+ __name(detectTestCmd, "detectTestCmd");
1446
+ function detectInstallCmd(projectDir) {
1447
+ const has = /* @__PURE__ */ __name((name) => existsSync3(join3(projectDir, name)), "has");
1448
+ if (!has("package.json")) return null;
1449
+ if (has("pnpm-lock.yaml")) {
1450
+ return "pnpm install --prefer-offline --frozen-lockfile";
1451
+ }
1452
+ if (has("package-lock.json")) {
1453
+ return "npm ci --prefer-offline --no-audit --no-fund";
1454
+ }
1455
+ return "npm install --prefer-offline --no-audit --no-fund --no-package-lock";
1456
+ }
1457
+ __name(detectInstallCmd, "detectInstallCmd");
1458
+ function runTestCmd(opts) {
1459
+ return new Promise((resolve3) => {
1460
+ let settled = false;
1461
+ const child = spawn("sh", ["-c", opts.cmd], {
1462
+ cwd: opts.cwd,
1463
+ stdio: ["ignore", "pipe", "pipe"],
1464
+ detached: true
1465
+ });
1466
+ if (child.pid !== void 0 && opts.registry) {
1467
+ opts.registry.add(child.pid);
1468
+ }
1469
+ const chunks = [];
1470
+ child.stdout.on("data", (d) => chunks.push(String(d)));
1471
+ child.stderr.on("data", (d) => chunks.push(String(d)));
1472
+ const timer = setTimeout(() => {
1473
+ if (child.pid) {
1474
+ try {
1475
+ process.kill(-child.pid, "SIGKILL");
1476
+ } catch {
1477
+ child.kill("SIGKILL");
1478
+ }
1479
+ } else {
1480
+ child.kill("SIGKILL");
1481
+ }
1482
+ }, opts.timeout * 1e3);
1483
+ child.on("error", (err) => {
1484
+ if (settled) return;
1485
+ settled = true;
1486
+ clearTimeout(timer);
1487
+ if (child.pid !== void 0 && opts.registry) {
1488
+ opts.registry.delete(child.pid);
1489
+ }
1490
+ chunks.push(String(err));
1491
+ resolve3({
1492
+ exitCode: 1,
1493
+ output: chunks.join("")
1494
+ });
1495
+ });
1496
+ child.on("close", (code) => {
1497
+ if (settled) return;
1498
+ settled = true;
1499
+ clearTimeout(timer);
1500
+ if (child.pid !== void 0 && opts.registry) {
1501
+ opts.registry.delete(child.pid);
1502
+ }
1503
+ resolve3({
1504
+ exitCode: code ?? 1,
1505
+ output: chunks.join("")
1506
+ });
1507
+ });
1508
+ });
1509
+ }
1510
+ __name(runTestCmd, "runTestCmd");
1511
+
1512
+ // src/learnings.ts
1513
+ import {
1514
+ appendFileSync,
1515
+ existsSync as existsSync4,
1516
+ mkdirSync as mkdirSync2,
1517
+ readFileSync as readFileSync4,
1518
+ rmSync,
1519
+ writeFileSync as writeFileSync3
1520
+ } from "node:fs";
1521
+ import { basename as basename2, dirname as dirname2, join as join4 } from "node:path";
1522
+ var MAX_OWN_ENTRIES = 4;
1523
+ var MAX_ENTRY_CHARS = 4e3;
1524
+ var LOG_TAIL_LINES = 40;
1525
+ var NOTES_PATH = ".nightralph/notes.md";
1526
+ var SENTINEL_PREFIX = "<!-- nightralph:entry";
1527
+ var TRUNCATED_MARKER = "[truncated]";
1528
+ var LEARNING_OUTCOMES = [
1529
+ "done",
1530
+ "exit",
1531
+ "tests",
1532
+ "install",
1533
+ "no-commits",
1534
+ "error",
1535
+ "merge"
1536
+ ];
1537
+ function encodeKey(key) {
1538
+ return key.replace(/%/g, "%25").replace(/\s/g, (c) => encodeURIComponent(c));
1539
+ }
1540
+ __name(encodeKey, "encodeKey");
1541
+ function fence(content) {
1542
+ const longest = Math.max(
1543
+ 0,
1544
+ ...[...content.matchAll(/`+/g)].map((m) => m[0].length)
1545
+ );
1546
+ const ticks = "`".repeat(Math.max(3, longest + 1));
1547
+ return `${ticks}
1548
+ ${content}
1549
+ ${ticks}`;
1550
+ }
1551
+ __name(fence, "fence");
1552
+ function sanitize(text) {
1553
+ if (!text) return "";
1554
+ return text.replace(
1555
+ new RegExp(SENTINEL_PREFIX, "g"),
1556
+ "<!-- nightralph-entry"
1557
+ );
1558
+ }
1559
+ __name(sanitize, "sanitize");
1560
+ function renderAgentSummary(run, effort) {
1561
+ const effortLine = effort ? `Effort: ${effort}.` : "";
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)}.`;
1565
+ const itemsText = run.completedItems.length > 0 ? run.completedItems.map(
1566
+ (item) => `"${sanitize(item)}"`
1567
+ ).join(", ") + "." : "none.";
1568
+ const itemsLine = `Checked items: ${itemsText}`;
1569
+ return [
1570
+ effortLine,
1571
+ exitLine,
1572
+ subagentsLine,
1573
+ costLine,
1574
+ itemsLine
1575
+ ].filter(Boolean).join(" ");
1576
+ }
1577
+ __name(renderAgentSummary, "renderAgentSummary");
1578
+ function renderNotesSection(notes) {
1579
+ const notesSanitized = sanitize(notes);
1580
+ return notesSanitized.trim() ? `### Agent notes
1581
+ ${notesSanitized}` : "The agent left no notes.";
1582
+ }
1583
+ __name(renderNotesSection, "renderNotesSection");
1584
+ function formatDate(date) {
1585
+ const year = date.getFullYear();
1586
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1587
+ const day = String(date.getDate()).padStart(2, "0");
1588
+ const hours = String(date.getHours()).padStart(2, "0");
1589
+ const minutes = String(date.getMinutes()).padStart(2, "0");
1590
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
1591
+ }
1592
+ __name(formatDate, "formatDate");
1593
+ function isLearningOutcome(value) {
1594
+ return LEARNING_OUTCOMES.some((o) => o === value);
1595
+ }
1596
+ __name(isLearningOutcome, "isLearningOutcome");
1597
+ function renderEntry(input) {
1598
+ const encodedKey = encodeKey(input.key);
1599
+ const hasRun = "run" in input;
1600
+ const timedOut = hasRun && input.run.timedOut;
1601
+ const timedOutStr = timedOut ? " timedout=true" : "";
1602
+ const sentinel = `<!-- nightralph:entry ticket=${encodedKey} attempt=${input.attempt} outcome=${input.outcome}${timedOutStr} -->`;
1603
+ const timestamp = formatDate(input.now);
1604
+ const attemptStr = input.outcome === "merge" ? "" : ` attempt ${input.attempt}`;
1605
+ let description;
1606
+ switch (input.outcome) {
1607
+ case "done":
1608
+ description = "done";
1609
+ break;
1610
+ case "exit":
1611
+ description = `failed (exit ${input.run.exitCode})`;
1612
+ break;
1613
+ case "tests":
1614
+ description = "failed (tests)";
1615
+ break;
1616
+ case "install":
1617
+ description = "failed (install)";
1618
+ break;
1619
+ case "no-commits":
1620
+ description = "no commits";
1621
+ break;
1622
+ case "error":
1623
+ description = "error";
1624
+ break;
1625
+ case "merge":
1626
+ description = "merge failed";
1627
+ break;
1628
+ default: {
1629
+ const _exhaustive = input;
1630
+ description = _exhaustive;
1631
+ }
1632
+ }
1633
+ const timedOutPrefix = timedOut ? "timed out, then " : "";
1634
+ const heading = `## ${timestamp} -- ${input.label}${attemptStr} -- ${timedOutPrefix}${description}`;
1635
+ let body;
1636
+ switch (input.outcome) {
1637
+ case "done":
1638
+ case "exit":
1639
+ case "no-commits": {
1640
+ const run = input.run;
1641
+ const summary = renderAgentSummary(run, input.effort);
1642
+ const notesSection = renderNotesSection(run.notes);
1643
+ body = summary + "\n\n" + notesSection;
1644
+ break;
1645
+ }
1646
+ case "tests": {
1647
+ const logTail = sanitize(input.logTail);
1648
+ const fenced = fence(logTail);
1649
+ const summary = renderAgentSummary(
1650
+ input.run,
1651
+ input.effort
1652
+ );
1653
+ const notesSection = renderNotesSection(input.run.notes);
1654
+ const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
1655
+ body = summary + `
1656
+
1657
+ Test gate failed.
1658
+ ${lastNLines}
1659
+ ${fenced}
1660
+
1661
+ ` + notesSection;
1662
+ break;
1663
+ }
1664
+ case "install": {
1665
+ const logTail = sanitize(input.logTail);
1666
+ const fenced = fence(logTail);
1667
+ const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
1668
+ body = `Install failed.
1669
+ ${lastNLines}
1670
+ ` + fenced;
1671
+ break;
1672
+ }
1673
+ case "error": {
1674
+ const errorText = sanitize(input.error);
1675
+ const fenced = fence(errorText);
1676
+ body = fenced;
1677
+ break;
1678
+ }
1679
+ case "merge": {
1680
+ const stderrText = sanitize(input.stderr);
1681
+ const fenced = fence(stderrText);
1682
+ body = `Merge failed.
1683
+ ${fenced}`;
1684
+ break;
1685
+ }
1686
+ default: {
1687
+ const unknown = input;
1688
+ throw new Error(`Unknown outcome: ${JSON.stringify(unknown)}`);
1689
+ }
1690
+ }
1691
+ return `${sentinel}
1692
+ ${heading}
1693
+
1694
+ ${body}
1695
+
1696
+ ---
1697
+ `;
1698
+ }
1699
+ __name(renderEntry, "renderEntry");
1700
+ function parseEntries(text) {
1701
+ const lines = text.split("\n").map((line) => {
1702
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1703
+ });
1704
+ const entries = [];
1705
+ let currentSentinel = null;
1706
+ let currentBody = [];
1707
+ const sentinelRegex = /^<!-- nightralph:entry (.*) -->$/;
1708
+ for (const line of lines) {
1709
+ const match = line.match(sentinelRegex);
1710
+ if (match) {
1711
+ if (currentSentinel !== null) {
1712
+ const entry = parseSentinel(currentSentinel, currentBody);
1713
+ if (entry) {
1714
+ entries.push(entry);
1715
+ }
1716
+ }
1717
+ currentSentinel = match[1];
1718
+ currentBody = [];
1719
+ } else if (currentSentinel !== null) {
1720
+ currentBody.push(line);
1721
+ }
1722
+ }
1723
+ if (currentSentinel !== null) {
1724
+ const entry = parseSentinel(currentSentinel, currentBody);
1725
+ if (entry) {
1726
+ entries.push(entry);
1727
+ }
1728
+ }
1729
+ return entries;
1730
+ }
1731
+ __name(parseEntries, "parseEntries");
1732
+ function parseSentinel(sentinel, bodyLines) {
1733
+ const parts = sentinel.split(/\s+/).filter(Boolean);
1734
+ const attrs = {};
1735
+ for (const part of parts) {
1736
+ const idx = part.indexOf("=");
1737
+ if (idx > 0) {
1738
+ const key2 = part.slice(0, idx);
1739
+ const value = part.slice(idx + 1);
1740
+ if (value) {
1741
+ attrs[key2] = value;
1742
+ }
1743
+ }
1744
+ }
1745
+ let key = attrs.ticket;
1746
+ if (!key) return null;
1747
+ try {
1748
+ key = decodeURIComponent(key);
1749
+ } catch (_e) {
1750
+ return null;
1751
+ }
1752
+ if (!key.trim()) return null;
1753
+ const attemptStr = attrs.attempt;
1754
+ if (!attemptStr) return null;
1755
+ if (!/^\d+$/.test(attemptStr)) return null;
1756
+ const attempt = Number(attemptStr);
1757
+ const outcome = attrs.outcome;
1758
+ if (!outcome || !isLearningOutcome(outcome)) return null;
1759
+ const timedOut = attrs.timedout === "true";
1760
+ let body = bodyLines.join("\n").trim();
1761
+ const lines = body.split("\n");
1762
+ if (lines.length > 0 && lines[lines.length - 1] === "---") {
1763
+ body = lines.slice(0, -1).join("\n").trim();
1764
+ }
1765
+ return {
1766
+ key,
1767
+ attempt,
1768
+ outcome,
1769
+ timedOut,
1770
+ body
1771
+ };
1772
+ }
1773
+ __name(parseSentinel, "parseSentinel");
1774
+ function selectHistory(entries, opts) {
1775
+ const own = entries.filter((e) => e.key === opts.key).slice(-MAX_OWN_ENTRIES);
1776
+ const blockers = opts.blockerKeys.flatMap((k) => {
1777
+ const done = entries.filter((e) => {
1778
+ return e.key === k && e.outcome === "done";
1779
+ });
1780
+ const last = done[done.length - 1];
1781
+ return last ? [last] : [];
1782
+ });
1783
+ return { own, blockers };
1784
+ }
1785
+ __name(selectHistory, "selectHistory");
1786
+ function renderHistory(selection) {
1787
+ if (selection.own.length === 0 && selection.blockers.length === 0) {
1788
+ return "";
1789
+ }
1790
+ const framing = "These are records of earlier runs. The ticket or spec may have changed since then; where they disagree, follow the ticket and spec. Do not repeat an approach these notes say failed unless you can see why it failed.";
1791
+ const parts = [framing, ""];
1792
+ if (selection.own.length > 0) {
1793
+ parts.push("---- PREVIOUS ATTEMPTS AT THIS TICKET ----");
1794
+ parts.push(selection.own.map((e) => capEntry(e)).join("\n\n"));
1795
+ parts.push("");
1796
+ }
1797
+ if (selection.blockers.length > 0) {
1798
+ parts.push("---- WHAT THE BLOCKING TICKETS DID ----");
1799
+ parts.push(selection.blockers.map((e) => capEntry(e)).join("\n\n"));
1800
+ }
1801
+ return parts.join("\n");
1802
+ }
1803
+ __name(renderHistory, "renderHistory");
1804
+ function capEntry(entry) {
1805
+ if (entry.body.length <= MAX_ENTRY_CHARS) {
1806
+ return entry.body;
1807
+ }
1808
+ return entry.body.slice(0, MAX_ENTRY_CHARS) + "\n" + TRUNCATED_MARKER;
1809
+ }
1810
+ __name(capEntry, "capEntry");
1811
+ function tailLines(text, n) {
1812
+ let lines = text.split("\n");
1813
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
1814
+ lines = lines.slice(0, -1);
1815
+ }
1816
+ if (lines.length > n) {
1817
+ lines = lines.slice(-n);
1818
+ }
1819
+ return lines.join("\n");
1820
+ }
1821
+ __name(tailLines, "tailLines");
1822
+ function learningsPathFor(progressPath) {
1823
+ return join4(dirname2(progressPath), "learnings.md");
1824
+ }
1825
+ __name(learningsPathFor, "learningsPathFor");
1826
+ function readEntries(path) {
1827
+ if (!existsSync4(path)) return [];
1828
+ return parseEntries(readFileSync4(path, "utf8"));
1829
+ }
1830
+ __name(readEntries, "readEntries");
1831
+ function appendEntry(path, text) {
1832
+ mkdirSync2(dirname2(path), { recursive: true });
1833
+ appendFileSync(path, text);
1834
+ }
1835
+ __name(appendEntry, "appendEntry");
1836
+ function collectNotes(worktreePath, file = basename2(NOTES_PATH)) {
1837
+ const dir = join4(worktreePath, dirname2(NOTES_PATH));
1838
+ const path = join4(dir, file);
1839
+ const text = existsSync4(path) ? readFileSync4(path, "utf8").trim() : "";
1840
+ rmSync(dir, { recursive: true, force: true });
1841
+ return text || null;
1842
+ }
1843
+ __name(collectNotes, "collectNotes");
1844
+ function ensureNotesIgnored(worktreePath) {
1845
+ const dir = join4(worktreePath, dirname2(NOTES_PATH));
1846
+ mkdirSync2(dir, { recursive: true });
1847
+ writeFileSync3(join4(dir, ".gitignore"), "*\n");
1848
+ }
1849
+ __name(ensureNotesIgnored, "ensureNotesIgnored");
1850
+
1851
+ // src/rules.ts
1852
+ function readDocsRule(subject) {
1853
+ return `- Read the files directly relevant to the ${subject} before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.`;
1854
+ }
1855
+ __name(readDocsRule, "readDocsRule");
1856
+ function installRule(installCmd) {
1857
+ if (!installCmd) return [];
1858
+ return [
1859
+ `- Dependencies are already installed with \`${installCmd}\`. Do NOT reinstall them with any other command; to reinstall, run that same command. To add a dependency, use the package manager that command uses, so its lockfile stays in sync.`
1860
+ ];
1861
+ }
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");
1875
+ function testGateRule(testCmd, subject) {
1876
+ if (testCmd === "") {
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}.`;
1878
+ }
1879
+ return `- While iterating, run only the tests relevant to what you changed. Run \`${testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ${subject} if it fails.`;
1880
+ }
1881
+ __name(testGateRule, "testGateRule");
1882
+ var LINT_RULE = "- If the project has a lint script, run it and fix what it reports.";
1883
+ function integrityRule(unit) {
1884
+ return `- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a ${unit} done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and record what blocked you in your notes file.`;
1885
+ }
1886
+ __name(integrityRule, "integrityRule");
1887
+ var CONFINEMENT_RULES = [
1888
+ "- Do NOT read or modify files outside your current directory (the worktree).",
1889
+ "- Do NOT run git push."
1890
+ ];
1891
+ function commitRule(unit) {
1892
+ return `- Commit after each ${unit}, and make sure everything is committed when finished, except your notes file.`;
1893
+ }
1894
+ __name(commitRule, "commitRule");
1895
+ function notesRule(unit, subject) {
1896
+ return `- Keep notes for this attempt in \`${NOTES_PATH}\` (create the directory). Update it after each ${unit} and as soon as you hit a blocker, not only at the end. Record what you implemented, files changed, how you read any vague acceptance criteria, what blocked you, and anything the next attempt at this ${subject} should know. Never commit, git add, or delete this file; the orchestrator collects it.`;
1897
+ }
1898
+ __name(notesRule, "notesRule");
1899
+ function agentsMdRule(subject) {
1900
+ return `- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ${subject}-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory. Reusable patterns go in AGENTS.md; anything specific to this ${subject} goes in your notes file.`;
1901
+ }
1902
+ __name(agentsMdRule, "agentsMdRule");
1903
+
1904
+ // src/review.ts
1905
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
1906
+ import { basename as basename4, dirname as dirname3, join as join5, relative } from "node:path";
1907
+
1908
+ // src/retry.ts
1909
+ import { basename as basename3 } from "node:path";
1910
+ var EFFORT_LADDERS = {
1911
+ pi: [
1912
+ "off",
1913
+ "minimal",
1914
+ "low",
1915
+ "medium",
1916
+ "high",
1917
+ "xhigh",
1918
+ "max"
1919
+ ],
1920
+ claude: [
1921
+ "low",
1922
+ "medium",
1923
+ "high",
1924
+ "xhigh",
1925
+ "max"
1926
+ ],
1927
+ codex: ["low", "medium", "high"]
1928
+ };
1929
+ var ALL_EFFORT_LEVELS = [
1930
+ "off",
1931
+ "minimal",
1932
+ "low",
1933
+ "medium",
1934
+ "high",
1935
+ "xhigh",
1936
+ "max"
1937
+ ];
1938
+ function escalateEffort(provider, level) {
1939
+ if (!level) return "high";
1940
+ const ladder = EFFORT_LADDERS[provider];
1941
+ const i = ladder.indexOf(level);
1942
+ if (i === -1) return level;
1943
+ return ladder[Math.min(i + 1, ladder.length - 1)];
1944
+ }
1945
+ __name(escalateEffort, "escalateEffort");
1946
+ function retryDelayFor(base, attempt) {
1947
+ return base * 2 ** (attempt - 2);
1948
+ }
1949
+ __name(retryDelayFor, "retryDelayFor");
1950
+ function sleep(ms) {
1951
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1952
+ }
1953
+ __name(sleep, "sleep");
1954
+ async function runWithRetries(opts, run) {
1955
+ const maxAttempts = Math.max(1, opts.maxAttempts);
1956
+ const providerName = basename3(opts.agentCmd);
1957
+ const isKnown = providerName in EFFORT_LADDERS;
1958
+ const wait = opts.wait ?? sleep;
1959
+ let effort = opts.effort;
1960
+ for (let attempt = 1; ; attempt++) {
1961
+ if (attempt === 1) {
1962
+ opts.log(` Starting ${opts.label}`);
1963
+ } else {
1964
+ if (isKnown) {
1965
+ effort = escalateEffort(
1966
+ providerName,
1967
+ effort
1968
+ );
1969
+ }
1970
+ const delay = retryDelayFor(opts.retryDelay, attempt);
1971
+ opts.log(
1972
+ ` ${opts.label}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1973
+ );
1974
+ if (delay > 0) await wait(delay * 1e3);
1975
+ }
1976
+ const step = await run(attempt, effort);
1977
+ if (step.stop || attempt >= maxAttempts) return step.result;
1978
+ }
1979
+ }
1980
+ __name(runWithRetries, "runWithRetries");
1981
+
1982
+ // src/review.ts
1983
+ var FINDINGS_FILE = "findings.md";
1984
+ var FINDINGS_PATH = `${dirname3(NOTES_PATH)}/${FINDINGS_FILE}`;
1985
+ var NO_FINDINGS = "No findings.";
1986
+ var REVIEW_EXCLUDE = ".scratch";
1987
+ var REVIEW_MARKER = "---- REVIEW INSTRUCTIONS ----";
1988
+ var FIX_MARKER = "---- REVIEW FINDINGS ----";
1989
+ function reviewExcludeFor(repoRoot, reviewPath) {
1990
+ let dir = dirname3(reviewPath);
1991
+ while (dirname3(dir) !== dir) {
1992
+ if (basename4(dir) === ".scratch") {
1993
+ const rel = relative(repoRoot, dir);
1994
+ if (rel && !rel.startsWith("..")) return rel;
1995
+ break;
1996
+ }
1997
+ dir = dirname3(dir);
1998
+ }
1999
+ return REVIEW_EXCLUDE;
2000
+ }
2001
+ __name(reviewExcludeFor, "reviewExcludeFor");
2002
+ function reviewDiffCommand(from, to, exclude = REVIEW_EXCLUDE) {
2003
+ return `git diff ${from}..${to} -- . ':(exclude)${exclude}'`;
2004
+ }
2005
+ __name(reviewDiffCommand, "reviewDiffCommand");
2006
+ function renderReviewPrompt(opts) {
2007
+ return [
2008
+ "---- SPEC CONTEXT ----",
2009
+ opts.specBody,
2010
+ "",
2011
+ REVIEW_MARKER,
2012
+ "You are reviewing work that autonomous agents merged into this branch, one ticket at a time. Review the combined change as a whole.",
2013
+ "",
2014
+ "The change to review:",
2015
+ "",
2016
+ ` ${reviewDiffCommand(opts.from, opts.to, opts.exclude)}`,
2017
+ "",
2018
+ "Follow these rules:",
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.",
2020
+ "- Look for correctness bugs, missing or weak tests, and code that breaks a documented contract. Ignore style preferences.",
2021
+ "- You may run the tests or the type checker to confirm a suspicion.",
2022
+ ...subagentRule(opts.preferSubagent, true),
2023
+ "- Do NOT edit, stage, or commit code. Anything you change here is discarded.",
2024
+ ...CONFINEMENT_RULES,
2025
+ "",
2026
+ `Write your findings to \`${FINDINGS_PATH}\` (create the directory), most severe first, in exactly this form:`,
2027
+ "",
2028
+ "- [ ] <one-line summary of the finding>",
2029
+ " <file:line, and a concrete input or state that goes wrong>",
2030
+ "",
2031
+ "If you find nothing worth fixing, write the single line: " + NO_FINDINGS,
2032
+ "Never git add or commit that file; the orchestrator collects it.",
2033
+ ""
2034
+ ].join("\n");
2035
+ }
2036
+ __name(renderReviewPrompt, "renderReviewPrompt");
2037
+ var FIX_FAILURE_TEXT = {
2038
+ install: "dependency install failed",
2039
+ exit: "the agent exited with an error",
2040
+ "no-commits": "the agent made no commits",
2041
+ tests: "the test command failed after the agent finished",
2042
+ merge: "the fix branch did not merge cleanly"
2043
+ };
2044
+ function findingLines(findings) {
2045
+ return findings.flatMap((f) => [
2046
+ `- [ ] ${f.summary}`,
2047
+ ...f.detail ? f.detail.split("\n").map((l) => ` ${l}`) : []
2048
+ ]);
2049
+ }
2050
+ __name(findingLines, "findingLines");
2051
+ function previousBlock(previous) {
2052
+ if (!previous) return [];
2053
+ return [
2054
+ "---- PREVIOUS FIX ATTEMPT ----",
2055
+ `It failed: ${FIX_FAILURE_TEXT[previous.reason]}.`,
2056
+ previous.notes ?? "It left no notes.",
2057
+ ""
2058
+ ];
2059
+ }
2060
+ __name(previousBlock, "previousBlock");
2061
+ function renderFixPrompt(opts) {
2062
+ return [
2063
+ "---- SPEC CONTEXT ----",
2064
+ opts.specBody,
2065
+ "",
2066
+ FIX_MARKER,
2067
+ ...findingLines(opts.findings),
2068
+ "",
2069
+ ...previousBlock(opts.previous),
2070
+ "---- AGENT INSTRUCTIONS ----",
2071
+ "You are an autonomous coding agent. Fix the review findings above using /tdd.",
2072
+ "",
2073
+ "Follow these rules:",
2074
+ readDocsRule("findings"),
2075
+ "- Fix ONLY the listed findings. Do not fix unrelated problems you notice.",
2076
+ ...installRule(opts.installCmd),
2077
+ ...subagentRule(opts.preferSubagent),
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.",
2079
+ testGateRule(opts.testCmd, "fix"),
2080
+ LINT_RULE,
2081
+ integrityRule("finding"),
2082
+ ...CONFINEMENT_RULES,
2083
+ commitRule("finding"),
2084
+ notesRule("finding", "fix"),
2085
+ "- After fixing each finding, print a line: [x] <finding summary> (matching the summary text exactly).",
2086
+ "- If a finding is wrong or cannot be fixed honestly, leave it unchecked and explain why in your notes file.",
2087
+ agentsMdRule("fix"),
2088
+ ""
2089
+ ].join("\n");
2090
+ }
2091
+ __name(renderFixPrompt, "renderFixPrompt");
2092
+ var SENTINEL_PREFIX2 = "<!-- nightralph:review";
2093
+ var SENTINEL_RE = /^<!-- nightralph:review (.*) -->$/;
2094
+ var SHA_RE = /^[0-9a-f]{4,64}$/;
2095
+ var FIX_HEADING = "### Fix";
2096
+ var FINDING_RE = /^ ?[-*+]\s+\[([ xX])\]\s+(.+)$/;
2097
+ var REVIEW_STATUSES = [
2098
+ "clean",
2099
+ "fixed",
2100
+ "unresolved",
2101
+ "review-failed"
2102
+ ];
2103
+ function isReviewStatus(value) {
2104
+ return REVIEW_STATUSES.includes(value);
2105
+ }
2106
+ __name(isReviewStatus, "isReviewStatus");
2107
+ function collapse(s) {
2108
+ return s.replace(/\s+/g, " ").trim();
2109
+ }
2110
+ __name(collapse, "collapse");
2111
+ function normalize(s) {
2112
+ return collapse(s).toLowerCase();
2113
+ }
2114
+ __name(normalize, "normalize");
2115
+ function sanitize2(text) {
2116
+ return text.replace(
2117
+ new RegExp(SENTINEL_PREFIX2, "g"),
2118
+ "<!-- nightralph-review"
2119
+ );
2120
+ }
2121
+ __name(sanitize2, "sanitize");
2122
+ function splitLines(text) {
2123
+ return text.split("\n").map((line) => {
2124
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
2125
+ });
2126
+ }
2127
+ __name(splitLines, "splitLines");
2128
+ function parseFindings(text) {
2129
+ const findings = [];
2130
+ const details = [];
2131
+ for (const line of splitLines(text)) {
2132
+ const m = line.match(FINDING_RE);
2133
+ if (m) {
2134
+ findings.push({
2135
+ summary: collapse(m[2]),
2136
+ detail: "",
2137
+ checked: m[1] !== " "
2138
+ });
2139
+ details.push([]);
2140
+ } else if (findings.length > 0 && line.trim()) {
2141
+ details[details.length - 1].push(line.trim());
2142
+ }
2143
+ }
2144
+ if (findings.length > 0) {
2145
+ return findings.map((f, i) => {
2146
+ return { ...f, detail: details[i].join("\n") };
2147
+ });
2148
+ }
2149
+ const clean = splitLines(text).some((line) => {
2150
+ return normalize(line) === NO_FINDINGS.toLowerCase();
2151
+ });
2152
+ return clean ? [] : null;
2153
+ }
2154
+ __name(parseFindings, "parseFindings");
2155
+ function markFixed(findings, completedItems) {
2156
+ const done = new Set(completedItems.map(normalize));
2157
+ return findings.map((f) => {
2158
+ return { ...f, checked: done.has(normalize(f.summary)) };
2159
+ });
2160
+ }
2161
+ __name(markFixed, "markFixed");
2162
+ function headline(input) {
2163
+ const n = input.findings.length;
2164
+ const fixed = input.findings.filter((f) => f.checked).length;
2165
+ switch (input.status) {
2166
+ case "clean":
2167
+ return "no findings";
2168
+ case "fixed":
2169
+ return `${fixed} of ${n} fixed`;
2170
+ case "unresolved":
2171
+ return `${n} finding${n === 1 ? "" : "s"}, fix failed`;
2172
+ case "review-failed":
2173
+ return "review failed";
2174
+ default: {
2175
+ const _exhaustive = input.status;
2176
+ return _exhaustive;
2177
+ }
2178
+ }
2179
+ }
2180
+ __name(headline, "headline");
2181
+ function renderFindings(input) {
2182
+ if (input.status === "review-failed") {
2183
+ return ["The reviewer left no usable findings file."];
2184
+ }
2185
+ if (input.findings.length === 0) return [NO_FINDINGS];
2186
+ return input.findings.flatMap((f) => {
2187
+ const box = f.checked ? "[x]" : "[ ]";
2188
+ const detail = f.detail ? f.detail.split("\n").map((l) => ` ${sanitize2(l)}`) : [];
2189
+ return [`- ${box} ${sanitize2(collapse(f.summary))}`, ...detail];
2190
+ });
2191
+ }
2192
+ __name(renderFindings, "renderFindings");
2193
+ function renderFix(fix) {
2194
+ if (!fix) return [];
2195
+ return [
2196
+ "",
2197
+ FIX_HEADING,
2198
+ `- attempts: ${fix.attempts} of ${fix.maxAttempts}`,
2199
+ ...fix.effort ? [`- effort: ${fix.effort}`] : [],
2200
+ `- agent exit code: ${fix.exitCode ?? "none"}`,
2201
+ `- result: ${sanitize2(fix.result)}`,
2202
+ "",
2203
+ fix.notes ? sanitize2(fix.notes) : "The fixer left no notes."
2204
+ ];
2205
+ }
2206
+ __name(renderFix, "renderFix");
2207
+ function renderReviewEntry(input) {
2208
+ return [
2209
+ `${SENTINEL_PREFIX2} from=${input.from} to=${input.to} status=${input.status} -->`,
2210
+ `## ${formatDate(input.now)} -- review ${input.from.slice(0, 7)}..${input.to.slice(0, 7)} -- ${headline(input)}`,
2211
+ ...renderFindings(input),
2212
+ ...renderFix(input.fix),
2213
+ "---",
2214
+ ""
2215
+ ].join("\n");
2216
+ }
2217
+ __name(renderReviewEntry, "renderReviewEntry");
2218
+ function parseEntry(sentinel, bodyLines) {
2219
+ const attrs = {};
2220
+ for (const part of sentinel.split(/\s+/).filter(Boolean)) {
2221
+ const idx = part.indexOf("=");
2222
+ if (idx > 0) attrs[part.slice(0, idx)] = part.slice(idx + 1);
2223
+ }
2224
+ const { from, to, status } = attrs;
2225
+ if (!from || !SHA_RE.test(from)) return null;
2226
+ if (!to || !SHA_RE.test(to)) return null;
2227
+ if (!status || !isReviewStatus(status)) return null;
2228
+ const lines = [...bodyLines];
2229
+ while (lines.length > 0 && !lines[lines.length - 1].trim()) {
2230
+ lines.pop();
2231
+ }
2232
+ if (lines[lines.length - 1] === "---") lines.pop();
2233
+ const fixAt = lines.indexOf(FIX_HEADING);
2234
+ const findingLines2 = fixAt === -1 ? lines : lines.slice(0, fixAt);
2235
+ return {
2236
+ from,
2237
+ to,
2238
+ status,
2239
+ findings: parseFindings(findingLines2.join("\n")) ?? [],
2240
+ body: lines.join("\n").trim()
2241
+ };
2242
+ }
2243
+ __name(parseEntry, "parseEntry");
2244
+ function parseReviewEntries(text) {
2245
+ const entries = [];
2246
+ let sentinel = null;
2247
+ let body = [];
2248
+ const flush = /* @__PURE__ */ __name(() => {
2249
+ if (sentinel === null) return;
2250
+ const entry = parseEntry(sentinel, body);
2251
+ if (entry) entries.push(entry);
2252
+ }, "flush");
2253
+ for (const line of splitLines(text)) {
2254
+ const m = line.match(SENTINEL_RE);
2255
+ if (m) {
2256
+ flush();
2257
+ sentinel = m[1];
2258
+ body = [];
2259
+ } else if (sentinel !== null) {
2260
+ body.push(line);
2261
+ }
2262
+ }
2263
+ flush();
2264
+ return entries;
2265
+ }
2266
+ __name(parseReviewEntries, "parseReviewEntries");
2267
+ function selectReviewBase(entries, opts) {
2268
+ const last = [...entries].reverse().find((e) => {
2269
+ return e.status !== "review-failed";
2270
+ });
2271
+ if (last && opts.isAncestor(last.to)) return last.to;
2272
+ return opts.forkPoint ?? opts.launchCommit;
2273
+ }
2274
+ __name(selectReviewBase, "selectReviewBase");
2275
+ function renderReviewSummary(outcome, reviewPath) {
2276
+ const see = ` -- see ${reviewPath}`;
2277
+ const plural = outcome.findings === 1 ? "" : "s";
2278
+ switch (outcome.status) {
2279
+ case "skipped":
2280
+ return null;
2281
+ case "clean":
2282
+ return `Review: no findings${see}`;
2283
+ case "fixed":
2284
+ return `Review: ${outcome.findings} finding${plural}, ${outcome.fixed} fixed${see}`;
2285
+ case "unresolved":
2286
+ return `Review: ${outcome.findings} finding${plural}, fix failed${see}`;
2287
+ case "review-failed":
2288
+ return `Review: the reviewer failed${see}`;
2289
+ default: {
2290
+ const _exhaustive = outcome.status;
2291
+ return _exhaustive;
2292
+ }
2293
+ }
2294
+ }
2295
+ __name(renderReviewSummary, "renderReviewSummary");
2296
+ function logPath(logsDir, stem, attempt, ext) {
2297
+ const suffix = attempt > 1 ? `.attempt${attempt}` : "";
2298
+ return join5(logsDir, `${stem}${suffix}${ext}`);
2299
+ }
2300
+ __name(logPath, "logPath");
2301
+ async function runStep(opts, deps, cmd, cwd, path) {
2302
+ const result = await runTestCmd({
2303
+ cmd,
2304
+ cwd,
2305
+ timeout: opts.timeout,
2306
+ registry: deps.registry
2307
+ });
2308
+ writeFileSync4(path, result.output);
2309
+ return result.exitCode === 0;
2310
+ }
2311
+ __name(runStep, "runStep");
2312
+ async function discardWorktree(opts, wt) {
2313
+ try {
2314
+ await removeWorktree(wt.worktreePath, { force: true });
2315
+ await deleteBranch(opts.repoRoot, wt.branchName);
2316
+ } catch (err) {
2317
+ opts.log(
2318
+ ` Failed to remove ${wt.worktreePath}: ` + (err instanceof Error ? err.message : String(err))
2319
+ );
2320
+ }
2321
+ }
2322
+ __name(discardWorktree, "discardWorktree");
2323
+ async function runReviewer(opts, deps, range) {
2324
+ return runWithRetries({
2325
+ maxAttempts: opts.retries + 1,
2326
+ retryDelay: opts.retryDelay,
2327
+ agentCmd: opts.agentCmd,
2328
+ effort: opts.effort,
2329
+ label: "review",
2330
+ log: opts.log
2331
+ }, async (attempt, effort) => {
2332
+ const wt = await createNamedWorktree({
2333
+ repoRoot: opts.repoRoot,
2334
+ repoName: opts.repoName,
2335
+ baseBranch: opts.baseBranch,
2336
+ name: "review"
2337
+ });
2338
+ try {
2339
+ if (opts.installCmd && !await runStep(
2340
+ opts,
2341
+ deps,
2342
+ opts.installCmd,
2343
+ wt.worktreePath,
2344
+ logPath(opts.logsDir, "review", attempt, ".install.log")
2345
+ )) {
2346
+ opts.log(" review: install failed");
2347
+ return { result: null, stop: false };
2348
+ }
2349
+ ensureNotesIgnored(wt.worktreePath);
2350
+ await deps.spawnAgent({
2351
+ prompt: renderReviewPrompt({
2352
+ specBody: opts.specBody,
2353
+ ...range,
2354
+ exclude: reviewExcludeFor(
2355
+ opts.repoRoot,
2356
+ opts.reviewPath
2357
+ ),
2358
+ preferSubagent: opts.preferSubagent
2359
+ }),
2360
+ agentCmd: opts.agentCmd,
2361
+ model: opts.model,
2362
+ timeout: opts.timeout,
2363
+ logPath: logPath(opts.logsDir, "review", attempt, ".log"),
2364
+ cwd: wt.worktreePath,
2365
+ label: "review",
2366
+ maxTurns: opts.maxTurns,
2367
+ effort,
2368
+ preferSubagent: opts.preferSubagent,
2369
+ onLine: opts.log
2370
+ });
2371
+ const text = collectNotes(wt.worktreePath, FINDINGS_FILE);
2372
+ const findings = text === null ? null : parseFindings(text);
2373
+ if (findings === null) {
2374
+ opts.log(" review: no usable findings file");
2375
+ }
2376
+ return { result: findings, stop: findings !== null };
2377
+ } finally {
2378
+ await discardWorktree(opts, wt);
2379
+ }
2380
+ });
2381
+ }
2382
+ __name(runReviewer, "runReviewer");
2383
+ async function runFixer(opts, deps, findings) {
2384
+ const unchecked = findings.map((f) => ({ ...f, checked: false }));
2385
+ let previous;
2386
+ return runWithRetries({
2387
+ maxAttempts: opts.retries + 1,
2388
+ retryDelay: opts.retryDelay,
2389
+ agentCmd: opts.agentCmd,
2390
+ effort: opts.effort,
2391
+ label: "review fix",
2392
+ log: opts.log
2393
+ }, async (attempt, effort) => {
2394
+ const fail = /* @__PURE__ */ __name((reason, exitCode, notes) => {
2395
+ previous = { reason, notes };
2396
+ opts.log(` review fix: ${FIX_FAILURE_TEXT[reason]}`);
2397
+ return {
2398
+ result: {
2399
+ merged: false,
2400
+ findings: unchecked,
2401
+ attempts: attempt,
2402
+ effort,
2403
+ exitCode,
2404
+ result: FIX_FAILURE_TEXT[reason],
2405
+ notes
2406
+ },
2407
+ stop: false
2408
+ };
2409
+ }, "fail");
2410
+ const wt = await createNamedWorktree({
2411
+ repoRoot: opts.repoRoot,
2412
+ repoName: opts.repoName,
2413
+ baseBranch: opts.baseBranch,
2414
+ name: "review-fix"
2415
+ });
2416
+ try {
2417
+ if (opts.installCmd && !await runStep(
2418
+ opts,
2419
+ deps,
2420
+ opts.installCmd,
2421
+ wt.worktreePath,
2422
+ logPath(
2423
+ opts.logsDir,
2424
+ "review-fix",
2425
+ attempt,
2426
+ ".install.log"
2427
+ )
2428
+ )) {
2429
+ return fail("install", null, null);
2430
+ }
2431
+ ensureNotesIgnored(wt.worktreePath);
2432
+ const agent = await deps.spawnAgent({
2433
+ prompt: renderFixPrompt({
2434
+ specBody: opts.specBody,
2435
+ findings,
2436
+ testCmd: opts.testCmd,
2437
+ installCmd: opts.installCmd,
2438
+ previous,
2439
+ preferSubagent: opts.preferSubagent
2440
+ }),
2441
+ agentCmd: opts.agentCmd,
2442
+ model: opts.model,
2443
+ timeout: opts.timeout,
2444
+ logPath: logPath(
2445
+ opts.logsDir,
2446
+ "review-fix",
2447
+ attempt,
2448
+ ".log"
2449
+ ),
2450
+ cwd: wt.worktreePath,
2451
+ label: "review-fix",
2452
+ maxTurns: opts.maxTurns,
2453
+ effort,
2454
+ preferSubagent: opts.preferSubagent,
2455
+ onLine: opts.log
2456
+ });
2457
+ const notes = collectNotes(wt.worktreePath);
2458
+ if (!agent.timedOut && agent.exitCode !== 0) {
2459
+ return fail("exit", agent.exitCode, notes);
2460
+ }
2461
+ if (isDirty(wt.worktreePath)) {
2462
+ await commitDirty(
2463
+ wt.worktreePath,
2464
+ "nightralph: auto-commit review fix remaining changes"
2465
+ );
2466
+ }
2467
+ if (!hasNewCommits(
2468
+ opts.repoRoot,
2469
+ opts.baseBranch,
2470
+ wt.branchName
2471
+ )) {
2472
+ return fail("no-commits", agent.exitCode, notes);
2473
+ }
2474
+ if (opts.testCmd === "") {
2475
+ opts.log(" review fix: no test gate");
2476
+ } else if (!await runStep(
2477
+ opts,
2478
+ deps,
2479
+ opts.testCmd,
2480
+ wt.worktreePath,
2481
+ logPath(
2482
+ opts.logsDir,
2483
+ "review-fix",
2484
+ attempt,
2485
+ ".test.log"
2486
+ )
2487
+ )) {
2488
+ return fail("tests", agent.exitCode, notes);
2489
+ }
2490
+ const merged = await mergeBranch(
2491
+ opts.repoRoot,
2492
+ wt.branchName
2493
+ );
2494
+ if (!merged.success) {
2495
+ return fail(
2496
+ "merge",
2497
+ agent.exitCode,
2498
+ [notes, merged.stderr].filter(Boolean).join("\n\n") || null
2499
+ );
2500
+ }
2501
+ opts.log(" review fix: merged");
2502
+ return {
2503
+ result: {
2504
+ merged: true,
2505
+ findings: markFixed(findings, agent.completedItems),
2506
+ attempts: attempt,
2507
+ effort,
2508
+ exitCode: agent.exitCode,
2509
+ result: "merged",
2510
+ notes
2511
+ },
2512
+ stop: true
2513
+ };
2514
+ } finally {
2515
+ await discardWorktree(opts, wt);
2516
+ }
2517
+ });
2518
+ }
2519
+ __name(runFixer, "runFixer");
2520
+ function readReviewEntries(path) {
2521
+ if (!existsSync5(path)) return [];
2522
+ return parseReviewEntries(readFileSync5(path, "utf8"));
2523
+ }
2524
+ __name(readReviewEntries, "readReviewEntries");
2525
+ async function runReviewPass(opts, deps) {
2526
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
2527
+ const to = headCommit(opts.repoRoot);
2528
+ const forkPoint = opts.forkedFrom ? mergeBase(opts.repoRoot, opts.forkedFrom, to) : null;
2529
+ const from = selectReviewBase(readReviewEntries(opts.reviewPath), {
2530
+ isAncestor: /* @__PURE__ */ __name((sha) => isAncestor(opts.repoRoot, sha, to), "isAncestor"),
2531
+ forkPoint,
2532
+ launchCommit: opts.launchCommit
2533
+ });
2534
+ const exclude = reviewExcludeFor(opts.repoRoot, opts.reviewPath);
2535
+ if (!hasChangesOutside(opts.repoRoot, from, to, exclude)) {
2536
+ opts.log("\nReview: nothing new to review");
2537
+ return { status: "skipped", findings: 0, fixed: 0 };
2538
+ }
2539
+ opts.log(`
2540
+ Reviewing ${from.slice(0, 7)}..${to.slice(0, 7)}`);
2541
+ const findings = await runReviewer(opts, deps, { from, to });
2542
+ let entry;
2543
+ if (findings === null) {
2544
+ entry = {
2545
+ from,
2546
+ to,
2547
+ status: "review-failed",
2548
+ now: now(),
2549
+ findings: []
2550
+ };
2551
+ } else if (findings.length === 0) {
2552
+ entry = { from, to, status: "clean", now: now(), findings: [] };
2553
+ } else {
2554
+ opts.log(
2555
+ `Review: ${findings.length} finding${findings.length === 1 ? "" : "s"}`
2556
+ );
2557
+ const fix = await runFixer(opts, deps, findings);
2558
+ entry = {
2559
+ from,
2560
+ // A merged fix counts as reviewed
2561
+ to: fix.merged ? headCommit(opts.repoRoot) : to,
2562
+ status: fix.merged ? "fixed" : "unresolved",
2563
+ now: now(),
2564
+ findings: fix.findings,
2565
+ fix: {
2566
+ attempts: fix.attempts,
2567
+ maxAttempts: opts.retries + 1,
2568
+ effort: fix.effort,
2569
+ exitCode: fix.exitCode,
2570
+ result: fix.result,
2571
+ notes: fix.notes
2572
+ }
2573
+ };
2574
+ }
2575
+ appendEntry(opts.reviewPath, renderReviewEntry(entry));
2576
+ await commitProgress(
2577
+ opts.repoRoot,
2578
+ opts.reviewPath,
2579
+ "nightralph: record review"
2580
+ );
2581
+ return {
2582
+ status: entry.status,
2583
+ findings: entry.findings.length,
2584
+ fixed: entry.findings.filter((f) => f.checked).length
2585
+ };
1124
2586
  }
1125
- __name(createDisplay, "createDisplay");
2587
+ __name(runReviewPass, "runReviewPass");
1126
2588
 
1127
- // src/testcmd.ts
1128
- import { spawn } from "node:child_process";
1129
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1130
- import { join as join3 } from "node:path";
1131
- function detectTestCmd(projectDir) {
1132
- const pkgPath = join3(projectDir, "package.json");
1133
- if (!existsSync3(pkgPath)) return null;
1134
- try {
1135
- const pkg = JSON.parse(
1136
- readFileSync3(pkgPath, "utf8")
1137
- );
1138
- return pkg.scripts?.test ? "npm test" : null;
1139
- } catch {
1140
- return null;
1141
- }
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);
1142
2597
  }
1143
- __name(detectTestCmd, "detectTestCmd");
1144
- function runTestCmd(opts) {
1145
- return new Promise((resolve2) => {
1146
- let settled = false;
1147
- const child = spawn("sh", ["-c", opts.cmd], {
1148
- cwd: opts.cwd,
1149
- stdio: ["ignore", "pipe", "pipe"],
1150
- detached: true
1151
- });
1152
- if (child.pid !== void 0 && opts.registry) {
1153
- opts.registry.add(child.pid);
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;
1154
2623
  }
1155
- const chunks = [];
1156
- child.stdout.on("data", (d) => chunks.push(String(d)));
1157
- child.stderr.on("data", (d) => chunks.push(String(d)));
1158
- const timer = setTimeout(() => {
1159
- if (child.pid) {
1160
- try {
1161
- process.kill(-child.pid, "SIGKILL");
1162
- } catch {
1163
- child.kill("SIGKILL");
1164
- }
1165
- } else {
1166
- child.kill("SIGKILL");
1167
- }
1168
- }, opts.timeout * 1e3);
1169
- child.on("error", (err) => {
1170
- if (settled) return;
1171
- settled = true;
1172
- clearTimeout(timer);
1173
- if (child.pid !== void 0 && opts.registry) {
1174
- opts.registry.delete(child.pid);
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);
1175
2675
  }
1176
- chunks.push(String(err));
1177
- resolve2({
1178
- exitCode: 1,
1179
- output: chunks.join("")
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"
1180
2700
  });
1181
- });
1182
- child.on("close", (code) => {
1183
- if (settled) return;
1184
- settled = true;
1185
- clearTimeout(timer);
1186
- if (child.pid !== void 0 && opts.registry) {
1187
- opts.registry.delete(child.pid);
1188
- }
1189
- resolve2({
1190
- exitCode: code ?? 1,
1191
- output: chunks.join("")
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
1192
2712
  });
1193
- });
1194
- });
2713
+ }
2714
+ }
2715
+ return out;
1195
2716
  }
1196
- __name(runTestCmd, "runTestCmd");
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");
1197
2731
 
1198
2732
  // src/orchestrator.ts
1199
2733
  var execFileAsync4 = promisify4(execFile4);
@@ -1265,21 +2799,7 @@ function parseStatus(body) {
1265
2799
  return m ? m[1].trim().toLowerCase() : "";
1266
2800
  }
1267
2801
  __name(parseStatus, "parseStatus");
1268
- function parseBlockers(body) {
1269
- const m = body.match(/\*\*Blocked by:\*\*\s*(.+)/i);
1270
- if (!m) return [];
1271
- const text = m[1];
1272
- if (/none/i.test(text)) return [];
1273
- const nums = [];
1274
- const re = /(\d+)/g;
1275
- let match;
1276
- while (match = re.exec(text)) {
1277
- nums.push(Number(match[1]));
1278
- }
1279
- return nums;
1280
- }
1281
- __name(parseBlockers, "parseBlockers");
1282
- function scanTickets(dir, feature = "") {
2802
+ function scanTickets(dir, feature = "", knownFeatures = /* @__PURE__ */ new Set()) {
1283
2803
  const files = readdirSync2(dir).filter((f) => TICKET_RE.test(f)).sort((a, b) => a.localeCompare(
1284
2804
  b,
1285
2805
  void 0,
@@ -1287,8 +2807,8 @@ function scanTickets(dir, feature = "") {
1287
2807
  ));
1288
2808
  return files.map((filename) => {
1289
2809
  const parsed = parseTicketFilename(filename);
1290
- const filepath = join4(dir, filename);
1291
- const body = readFileSync4(filepath, "utf8");
2810
+ const filepath = join6(dir, filename);
2811
+ const body = readFileSync6(filepath, "utf8");
1292
2812
  return {
1293
2813
  num: parsed.num,
1294
2814
  slug: parsed.slug,
@@ -1296,7 +2816,10 @@ function scanTickets(dir, feature = "") {
1296
2816
  filepath,
1297
2817
  body,
1298
2818
  status: parseStatus(body),
1299
- blockers: parseBlockers(body),
2819
+ blockers: provisionalRefs(
2820
+ parseBlockerField(body, knownFeatures).refs,
2821
+ feature
2822
+ ),
1300
2823
  feature
1301
2824
  };
1302
2825
  });
@@ -1306,15 +2829,35 @@ function ticketKey(feature, num) {
1306
2829
  return `${feature}:${num}`;
1307
2830
  }
1308
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");
1309
2854
  function findReadyTickets(tickets) {
1310
2855
  const doneKeys = new Set(
1311
2856
  tickets.filter((t) => t.status === "done").map((t) => ticketKey(t.feature, t.num))
1312
2857
  );
1313
2858
  return tickets.filter((t) => {
1314
2859
  if (t.status !== "ready-for-agent") return false;
1315
- return t.blockers.every(
1316
- (b) => doneKeys.has(ticketKey(t.feature, b))
1317
- );
2860
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
1318
2861
  });
1319
2862
  }
1320
2863
  __name(findReadyTickets, "findReadyTickets");
@@ -1323,14 +2866,19 @@ function findBlockedTickets(tickets, failedKeys) {
1323
2866
  tickets.map((t) => [ticketKey(t.feature, t.num), t])
1324
2867
  );
1325
2868
  function collectDead(ticket, visited, found) {
1326
- for (const b of ticket.blockers) {
1327
- const key = ticketKey(ticket.feature, b);
2869
+ for (const ref of ticket.blockers) {
2870
+ const key = ticketKey(ref.feature, ref.num);
1328
2871
  if (visited.has(key)) continue;
1329
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
+ }
1330
2878
  const blocker = byKey.get(key);
1331
2879
  if (!blocker || blocker.status === "done") continue;
1332
2880
  if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
1333
- found.add(key);
2881
+ found.set(key, ref);
1334
2882
  continue;
1335
2883
  }
1336
2884
  collectDead(blocker, visited, found);
@@ -1342,9 +2890,9 @@ function findBlockedTickets(tickets, failedKeys) {
1342
2890
  const key = ticketKey(t.feature, t.num);
1343
2891
  if (t.status !== "ready-for-agent") continue;
1344
2892
  if (failedKeys.has(key)) continue;
1345
- const found = /* @__PURE__ */ new Set();
2893
+ const found = /* @__PURE__ */ new Map();
1346
2894
  collectDead(t, /* @__PURE__ */ new Set([key]), found);
1347
- if (found.size > 0) blocked.set(key, [...found]);
2895
+ if (found.size > 0) blocked.set(key, [...found.values()]);
1348
2896
  }
1349
2897
  return blocked;
1350
2898
  }
@@ -1399,7 +2947,7 @@ function checkItems(ticket, completedItems) {
1399
2947
  if (completedItems.length === 0) return;
1400
2948
  let body;
1401
2949
  try {
1402
- body = readFileSync4(ticket.filepath, "utf8");
2950
+ body = readFileSync6(ticket.filepath, "utf8");
1403
2951
  } catch {
1404
2952
  body = ticket.body;
1405
2953
  }
@@ -1423,7 +2971,7 @@ function checkItems(ticket, completedItems) {
1423
2971
  }
1424
2972
  if (!changed) return;
1425
2973
  body = lines.join("\n");
1426
- writeFileSync3(ticket.filepath, body);
2974
+ writeFileSync5(ticket.filepath, body);
1427
2975
  ticket.body = body;
1428
2976
  }
1429
2977
  __name(checkItems, "checkItems");
@@ -1447,7 +2995,7 @@ __name(checkAllItems, "checkAllItems");
1447
2995
  function markDone(ticket) {
1448
2996
  let body;
1449
2997
  try {
1450
- body = readFileSync4(ticket.filepath, "utf8");
2998
+ body = readFileSync6(ticket.filepath, "utf8");
1451
2999
  } catch {
1452
3000
  body = ticket.body;
1453
3001
  }
@@ -1456,7 +3004,7 @@ function markDone(ticket) {
1456
3004
  /\*\*Status:\*\*\s*.+/i,
1457
3005
  "**Status:** done"
1458
3006
  );
1459
- writeFileSync3(ticket.filepath, updated);
3007
+ writeFileSync5(ticket.filepath, updated);
1460
3008
  ticket.status = "done";
1461
3009
  ticket.body = updated;
1462
3010
  }
@@ -1489,11 +3037,23 @@ function stripSpecSection(specBody, heading) {
1489
3037
  return out.join("\n");
1490
3038
  }
1491
3039
  __name(stripSpecSection, "stripSpecSection");
3040
+ function reviewSpecBody(specBody, specBodies) {
3041
+ if (!specBodies || specBodies.size <= 1) return specBody;
3042
+ return [...specBodies].map(([name, body]) => `# Feature: ${name}
3043
+
3044
+ ${body}`).join("\n\n");
3045
+ }
3046
+ __name(reviewSpecBody, "reviewSpecBody");
1492
3047
  function lookupSpec(specBodies, fallback, feature) {
1493
3048
  if (!feature || !specBodies) return fallback;
1494
3049
  return specBodies.get(feature) ?? fallback;
1495
3050
  }
1496
3051
  __name(lookupSpec, "lookupSpec");
3052
+ function historyBlock(history) {
3053
+ if (!history) return [];
3054
+ return [history, ""];
3055
+ }
3056
+ __name(historyBlock, "historyBlock");
1497
3057
  function renderPrompt(specBody, ticket, opts) {
1498
3058
  return [
1499
3059
  "---- SPEC CONTEXT ----",
@@ -1503,27 +3063,30 @@ function renderPrompt(specBody, ticket, opts) {
1503
3063
  ticket.filename,
1504
3064
  ticket.body,
1505
3065
  "",
3066
+ ...historyBlock(opts.history),
1506
3067
  "---- AGENT INSTRUCTIONS ----",
1507
3068
  "You are an autonomous coding agent. Implement the ticket using /tdd.",
1508
3069
  "",
1509
3070
  "Follow these rules:",
1510
- "- Read the files directly relevant to the ticket before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.",
3071
+ readDocsRule("ticket"),
1511
3072
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
1512
3073
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
1513
- "- 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.",
1514
- `- While iterating, run only the tests relevant to what you changed. Run \`${opts.testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`,
1515
- "- If the project has a lint script, run it and fix what it reports.",
1516
- "- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a checklist item done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and say what blocked you.",
1517
- "- Do NOT read or modify files outside your current directory (the worktree).",
1518
- "- Do NOT run git push.",
1519
- "- Commit after each checklist item, and make sure everything is committed when finished.",
3074
+ ...installRule(opts.installCmd),
3075
+ ...subagentRule(opts.preferSubagent),
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.",
3077
+ testGateRule(opts.testCmd, "ticket"),
3078
+ LINT_RULE,
3079
+ integrityRule("checklist item"),
3080
+ ...CONFINEMENT_RULES,
3081
+ commitRule("checklist item"),
3082
+ notesRule("checklist item", "ticket"),
1520
3083
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
1521
- "- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ticket-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory.",
3084
+ agentsMdRule("ticket"),
1522
3085
  ""
1523
3086
  ].join("\n");
1524
3087
  }
1525
3088
  __name(renderPrompt, "renderPrompt");
1526
- function renderMergePrompt(specBody, ticket) {
3089
+ function renderMergePrompt(specBody, ticket, opts = {}) {
1527
3090
  return [
1528
3091
  "---- SPEC CONTEXT ----",
1529
3092
  specBody,
@@ -1544,6 +3107,8 @@ function renderMergePrompt(specBody, ticket) {
1544
3107
  "4. Commit the resolved files.",
1545
3108
  "",
1546
3109
  "Do NOT re-implement the ticket from scratch.",
3110
+ ...installRule(opts.installCmd),
3111
+ ...subagentRule(opts.preferSubagent),
1547
3112
  "Do NOT read or modify files outside your current directory (the worktree).",
1548
3113
  "Do NOT run git push."
1549
3114
  ].join("\n");
@@ -1552,7 +3117,7 @@ __name(renderMergePrompt, "renderMergePrompt");
1552
3117
  function listTickets(tickets) {
1553
3118
  console.log("\nTickets:");
1554
3119
  for (const t of tickets) {
1555
- 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";
1556
3121
  console.log(
1557
3122
  ` ${t.filename} (${t.status}) ${blockerStr}`
1558
3123
  );
@@ -1576,11 +3141,7 @@ function* simulateWaves(tickets) {
1576
3141
  if (sim.get(key) !== "ready-for-agent") {
1577
3142
  return false;
1578
3143
  }
1579
- return t.blockers.every(
1580
- (b) => doneKeys.has(
1581
- ticketKey(t.feature, b)
1582
- )
1583
- );
3144
+ return t.blockers.every((r) => blockerSatisfied(r, doneKeys));
1584
3145
  });
1585
3146
  if (ready.length === 0) break;
1586
3147
  yield { wave, ready };
@@ -1613,9 +3174,7 @@ function detectDeadlocks(tickets) {
1613
3174
  (t) => ticketKey(t.feature, t.num) === key
1614
3175
  );
1615
3176
  if (ticket.blockers.every(
1616
- (b) => doneKeys.has(
1617
- ticketKey(ticket.feature, b)
1618
- )
3177
+ (r) => blockerSatisfied(r, doneKeys)
1619
3178
  )) {
1620
3179
  doneKeys.add(key);
1621
3180
  readyKeys.delete(key);
@@ -1653,80 +3212,41 @@ var PROVIDER_ARGS = {
1653
3212
  ]
1654
3213
  };
1655
3214
  function getProviderArgs(cmd) {
1656
- const name = basename2(cmd);
3215
+ const name = basename5(cmd);
1657
3216
  return PROVIDER_ARGS[name] ?? [];
1658
3217
  }
1659
3218
  __name(getProviderArgs, "getProviderArgs");
1660
- var EFFORT_LADDERS = {
1661
- pi: [
1662
- "off",
1663
- "minimal",
1664
- "low",
1665
- "medium",
1666
- "high",
1667
- "xhigh",
1668
- "max"
1669
- ],
1670
- claude: [
1671
- "low",
1672
- "medium",
1673
- "high",
1674
- "xhigh",
1675
- "max"
1676
- ],
1677
- codex: ["low", "medium", "high"]
1678
- };
1679
- var ALL_EFFORT_LEVELS = [
1680
- "off",
1681
- "minimal",
1682
- "low",
1683
- "medium",
1684
- "high",
1685
- "xhigh",
1686
- "max"
1687
- ];
1688
- function escalateEffort(provider, level) {
1689
- if (!level) return "high";
1690
- const ladder = EFFORT_LADDERS[provider];
1691
- const i = ladder.indexOf(level);
1692
- if (i === -1) return level;
1693
- return ladder[Math.min(i + 1, ladder.length - 1)];
1694
- }
1695
- __name(escalateEffort, "escalateEffort");
1696
- function formatStreamLine(line) {
1697
- let obj;
1698
- try {
1699
- obj = JSON.parse(line);
1700
- } catch {
1701
- return line;
1702
- }
3219
+ function formatStreamObject(value) {
3220
+ if (!isRecord(value)) return null;
3221
+ const obj = value;
1703
3222
  if (obj.type === "assistant") {
1704
- const msg = obj.message;
1705
- 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;
1706
3226
  if (!content) return null;
1707
3227
  for (const block of content) {
1708
- if (block.type === "text") {
1709
- return String(block.text);
1710
- }
3228
+ if (!isRecord(block)) continue;
3229
+ if (block.type !== "text") continue;
3230
+ if (typeof block.text === "string") return block.text;
1711
3231
  }
1712
3232
  return null;
1713
3233
  }
1714
3234
  if (obj.type === "result") {
1715
- const sub = obj.subtype ?? "done";
1716
- const cost = obj.total_cost_usd;
1717
- 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)})` : "";
1718
3238
  return `${sub}${costStr}`;
1719
3239
  }
1720
3240
  if (obj.type === "system") {
1721
- const sub = obj.subtype;
3241
+ const sub = typeof obj.subtype === "string" ? obj.subtype : void 0;
1722
3242
  if (sub === "api_retry") {
1723
- const error = obj.error;
1724
- return `retry: ${error ?? "unknown"}`;
3243
+ const error = typeof obj.error === "string" ? obj.error : "unknown";
3244
+ return `retry: ${error}`;
1725
3245
  }
1726
3246
  }
1727
3247
  return null;
1728
3248
  }
1729
- __name(formatStreamLine, "formatStreamLine");
3249
+ __name(formatStreamObject, "formatStreamObject");
1730
3250
  function createPiStreamFormatter() {
1731
3251
  const calls = /* @__PURE__ */ new Map();
1732
3252
  let textBuf = "";
@@ -1793,8 +3313,8 @@ function createPiStreamFormatter() {
1793
3313
  __name(createPiStreamFormatter, "createPiStreamFormatter");
1794
3314
  function writePromptFile(prompt) {
1795
3315
  const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
1796
- const filePath = join4(tmpdir(), name);
1797
- writeFileSync3(filePath, prompt);
3316
+ const filePath = join6(tmpdir(), name);
3317
+ writeFileSync5(filePath, prompt);
1798
3318
  return filePath;
1799
3319
  }
1800
3320
  __name(writePromptFile, "writePromptFile");
@@ -1835,7 +3355,7 @@ function spawnAgent(opts) {
1835
3355
  if (opts.model) {
1836
3356
  args.push("--model", opts.model);
1837
3357
  }
1838
- const providerName = basename2(opts.agentCmd);
3358
+ const providerName = basename5(opts.agentCmd);
1839
3359
  const isClaude = providerName === "claude";
1840
3360
  const isPi = providerName === "pi";
1841
3361
  if (opts.maxTurns) {
@@ -1879,10 +3399,15 @@ function spawnAgent(opts) {
1879
3399
  opts.logPath,
1880
3400
  { flags: "w" }
1881
3401
  );
3402
+ const env = isClaude && opts.preferSubagent ? {
3403
+ ...process.env,
3404
+ CLAUDE_CODE_SUBAGENT_MODEL: opts.preferSubagent
3405
+ } : void 0;
1882
3406
  const proc = spawn2(opts.agentCmd, args, {
1883
3407
  stdio: ["pipe", "pipe", "pipe"],
1884
3408
  cwd: opts.cwd,
1885
- detached: true
3409
+ detached: true,
3410
+ ...env ? { env } : {}
1886
3411
  });
1887
3412
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
1888
3413
  proc.stdout.pipe(logStream, { end: false });
@@ -1900,10 +3425,26 @@ function spawnAgent(opts) {
1900
3425
  }
1901
3426
  }
1902
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");
1903
3444
  const rl = createInterface({ input: proc.stdout });
1904
3445
  rl.on("line", (line) => {
1905
3446
  if (isClaude) {
1906
- const formatted = formatStreamLine(line);
3447
+ const formatted = formatClaudeLine(line);
1907
3448
  if (formatted !== null) {
1908
3449
  collectCompleted(formatted);
1909
3450
  const output = `${prefix}${formatted}`;
@@ -1969,7 +3510,7 @@ function spawnAgent(opts) {
1969
3510
  proc.stdin.write(opts.prompt);
1970
3511
  }
1971
3512
  proc.stdin.end();
1972
- return new Promise((resolve2) => {
3513
+ return new Promise((resolve3) => {
1973
3514
  let settled = false;
1974
3515
  let graceTimer;
1975
3516
  function finish(exitCode) {
@@ -1995,7 +3536,12 @@ function spawnAgent(opts) {
1995
3536
  rlErr.close();
1996
3537
  logStream.end();
1997
3538
  if (promptFile) cleanupPromptFile(promptFile);
1998
- resolve2({ exitCode, completedItems, timedOut });
3539
+ resolve3({
3540
+ exitCode,
3541
+ completedItems,
3542
+ timedOut,
3543
+ ...isClaude ? streamStats.stats : {}
3544
+ });
1999
3545
  }
2000
3546
  __name(finish, "finish");
2001
3547
  proc.on("exit", (code) => {
@@ -2019,22 +3565,27 @@ function spawnAgent(opts) {
2019
3565
  }
2020
3566
  rl.close();
2021
3567
  rlErr.close();
2022
- logStream.end();
3568
+ const msg = "agent process error: " + formatErrorMessage(err);
3569
+ logStream.write(`nightralph: ${msg}
3570
+ `);
2023
3571
  if (promptFile) cleanupPromptFile(promptFile);
2024
- console.error(
2025
- "Agent process error:",
2026
- formatErrorMessage(err)
2027
- );
2028
- resolve2({
2029
- exitCode: 1,
2030
- completedItems,
2031
- timedOut
3572
+ if (opts.onLine) {
3573
+ opts.onLine(`${prefix}${msg}`);
3574
+ } else {
3575
+ console.error(`${prefix}${msg}`);
3576
+ }
3577
+ logStream.end(() => {
3578
+ resolve3({
3579
+ exitCode: 1,
3580
+ completedItems,
3581
+ timedOut
3582
+ });
2032
3583
  });
2033
3584
  });
2034
3585
  });
2035
3586
  }
2036
3587
  __name(spawnAgent, "spawnAgent");
2037
- function dryRun(tickets, specBody, testCmd) {
3588
+ function dryRun(tickets, specBody, testCmd, preferSubagent) {
2038
3589
  listTickets(tickets);
2039
3590
  console.log("\nSimulated wave order:");
2040
3591
  for (const { wave, ready } of simulateWaves(tickets)) {
@@ -2048,6 +3599,9 @@ function dryRun(tickets, specBody, testCmd) {
2048
3599
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
2049
3600
  );
2050
3601
  }
3602
+ console.log(`
3603
+ Test command: ${testCmd || "(none)"}`);
3604
+ console.log(`Prefer subagent: ${preferSubagent || "(none)"}`);
2051
3605
  const firstReady = findReadyTickets(tickets);
2052
3606
  if (firstReady.length > 0) {
2053
3607
  console.log(
@@ -2059,7 +3613,7 @@ Prompt for first ready ticket (${firstReady[0].filename}):
2059
3613
  renderPrompt(
2060
3614
  specBody,
2061
3615
  firstReady[0],
2062
- { testCmd }
3616
+ { testCmd, preferSubagent }
2063
3617
  )
2064
3618
  );
2065
3619
  }
@@ -2103,18 +3657,28 @@ Prompt for first ready ticket (${firstReady[0].filename}):
2103
3657
  renderPrompt(
2104
3658
  opts.specBody,
2105
3659
  firstReady[0],
2106
- { testCmd: opts.testCmd }
3660
+ {
3661
+ testCmd: opts.testCmd,
3662
+ installCmd: opts.installCmd,
3663
+ preferSubagent: opts.preferSubagent
3664
+ }
2107
3665
  )
2108
3666
  );
2109
3667
  }
2110
3668
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
2111
3669
  console.log(
2112
3670
  `
2113
- Test command: ${opts.testCmd}`
3671
+ Test command: ${opts.testCmd || "(none)"}`
3672
+ );
3673
+ console.log(
3674
+ `Install: ${opts.installCmd || "(none)"}`
2114
3675
  );
2115
3676
  console.log(
2116
3677
  `Effort: ${opts.effort ?? "(provider default)"}`
2117
3678
  );
3679
+ console.log(
3680
+ `Prefer subagent: ${opts.preferSubagent || "(none)"}`
3681
+ );
2118
3682
  console.log(
2119
3683
  `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
2120
3684
  );
@@ -2131,7 +3695,34 @@ Merge strategy: ${strategyDesc}`
2131
3695
  console.log(`Progress file: ${opts.progressPath}`);
2132
3696
  }
2133
3697
  __name(dryRunIsolated, "dryRunIsolated");
3698
+ async function runInstall(opts) {
3699
+ const result = await runTestCmd({
3700
+ cmd: opts.installCmd,
3701
+ cwd: opts.worktreePath,
3702
+ timeout: opts.timeout,
3703
+ registry: opts.registry
3704
+ });
3705
+ const installLogPath = attemptLogPath(
3706
+ opts.logsDir,
3707
+ opts.ticket,
3708
+ opts.attempt ?? 1,
3709
+ ".install.log"
3710
+ );
3711
+ writeFileSync5(installLogPath, result.output);
3712
+ if (result.exitCode !== 0) {
3713
+ opts.log(
3714
+ ` ${opts.ticket.filename}: install failed (exit ${result.exitCode}); see ${installLogPath}`
3715
+ );
3716
+ return false;
3717
+ }
3718
+ return true;
3719
+ }
3720
+ __name(runInstall, "runInstall");
2134
3721
  async function runTestGate(opts) {
3722
+ if (opts.testCmd === "") {
3723
+ opts.log(` ${opts.ticket.filename}: no test gate`);
3724
+ return true;
3725
+ }
2135
3726
  const result = await runTestCmd({
2136
3727
  cmd: opts.testCmd,
2137
3728
  cwd: opts.worktreePath,
@@ -2144,7 +3735,7 @@ async function runTestGate(opts) {
2144
3735
  opts.attempt ?? 1,
2145
3736
  ".test.log"
2146
3737
  );
2147
- writeFileSync3(testLogPath, result.output);
3738
+ writeFileSync5(testLogPath, result.output);
2148
3739
  if (result.exitCode !== 0) {
2149
3740
  opts.log(
2150
3741
  ` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
@@ -2173,11 +3764,27 @@ async function respawnAndRetryMerge(opts) {
2173
3764
  } else {
2174
3765
  console.log(msg1);
2175
3766
  }
3767
+ if (opts.installCmd) {
3768
+ const installed = await runInstall({
3769
+ installCmd: opts.installCmd,
3770
+ ticket: opts.ticket,
3771
+ worktreePath: opts.worktreePath,
3772
+ timeout: opts.timeout,
3773
+ logsDir: opts.logsDir,
3774
+ log,
3775
+ registry: runningAgents
3776
+ });
3777
+ if (!installed) return false;
3778
+ }
2176
3779
  const prompt = renderMergePrompt(
2177
3780
  opts.specBody,
2178
- opts.ticket
3781
+ opts.ticket,
3782
+ {
3783
+ installCmd: opts.installCmd,
3784
+ preferSubagent: opts.preferSubagent
3785
+ }
2179
3786
  );
2180
- const logPath = join4(
3787
+ const logPath2 = join6(
2181
3788
  opts.logsDir,
2182
3789
  opts.ticket.filename.replace(/\.md$/, ".respawn.log")
2183
3790
  );
@@ -2190,12 +3797,14 @@ async function respawnAndRetryMerge(opts) {
2190
3797
  agentCmd: opts.agentCmd,
2191
3798
  model: opts.model,
2192
3799
  timeout: opts.timeout,
2193
- logPath,
3800
+ logPath: logPath2,
2194
3801
  cwd: opts.worktreePath,
2195
3802
  label,
2196
3803
  maxTurns: opts.maxTurns,
3804
+ preferSubagent: opts.preferSubagent,
2197
3805
  onLine: opts.onLine
2198
3806
  });
3807
+ opts.onAgentRun?.(result);
2199
3808
  if (result.exitCode !== 0) {
2200
3809
  const msg2 = ` ${opts.ticket.filename}: respawned agent exited ${result.exitCode}`;
2201
3810
  if (opts.onLine) {
@@ -2274,20 +3883,43 @@ async function runPool(items, limit, fn) {
2274
3883
  return results;
2275
3884
  }
2276
3885
  __name(runPool, "runPool");
2277
- function retryDelayFor(base, attempt) {
2278
- return base * 2 ** (attempt - 2);
2279
- }
2280
- __name(retryDelayFor, "retryDelayFor");
2281
- function sleep(ms) {
2282
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2283
- }
2284
- __name(sleep, "sleep");
2285
3886
  function attemptLogPath(logsDir, ticket, attempt, ext) {
2286
3887
  const stem = ticket.filename.replace(/\.md$/, "");
2287
3888
  const suffix = attempt > 1 ? `.attempt${attempt}` : "";
2288
- return join4(logsDir, `${stem}${suffix}${ext}`);
3889
+ return join6(logsDir, `${stem}${suffix}${ext}`);
2289
3890
  }
2290
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");
3912
+ function buildHistory(learningsPath, ticket, d) {
3913
+ try {
3914
+ return historyFor(learningsPath, ticket);
3915
+ } catch (err) {
3916
+ d.log(
3917
+ ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
3918
+ );
3919
+ return "";
3920
+ }
3921
+ }
3922
+ __name(buildHistory, "buildHistory");
2291
3923
  async function runTicketAttempt(opts) {
2292
3924
  const { ticket, d } = opts;
2293
3925
  let worktree;
@@ -2301,11 +3933,43 @@ async function runTicketAttempt(opts) {
2301
3933
  } catch (error) {
2302
3934
  return { kind: "worktree-error", error };
2303
3935
  }
2304
- d.setTicketStatus(
2305
- ticket.num,
2306
- "in-progress",
2307
- ticket.feature
2308
- );
3936
+ d.setTicketStatus(
3937
+ ticket.num,
3938
+ "in-progress",
3939
+ ticket.feature
3940
+ );
3941
+ if (opts.installCmd) {
3942
+ const installed = await runInstall({
3943
+ installCmd: opts.installCmd,
3944
+ ticket,
3945
+ worktreePath: worktree.worktreePath,
3946
+ timeout: opts.timeout,
3947
+ logsDir: opts.logsDir,
3948
+ attempt: opts.attempt,
3949
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log"),
3950
+ registry: runningAgents
3951
+ });
3952
+ if (!installed) {
3953
+ await removeWorktreeQuietly(
3954
+ worktree.worktreePath,
3955
+ d
3956
+ );
3957
+ return {
3958
+ kind: "failed",
3959
+ reason: "install",
3960
+ branchName: worktree.branchName,
3961
+ exitCode: 1,
3962
+ completedItems: []
3963
+ };
3964
+ }
3965
+ }
3966
+ try {
3967
+ ensureNotesIgnored(worktree.worktreePath);
3968
+ } catch (err) {
3969
+ d.log(
3970
+ ` ${ticket.filename}: could not set up the notes directory: ${formatErrorMessage(err)}`
3971
+ );
3972
+ }
2309
3973
  const ticketSpec = lookupSpec(
2310
3974
  opts.specBodies,
2311
3975
  opts.specBody,
@@ -2315,7 +3979,18 @@ async function runTicketAttempt(opts) {
2315
3979
  prompt: renderPrompt(
2316
3980
  ticketSpec,
2317
3981
  ticket,
2318
- { testCmd: opts.testCmd }
3982
+ {
3983
+ testCmd: opts.testCmd,
3984
+ installCmd: opts.installCmd,
3985
+ // Rebuilt every attempt, so a retry sees the
3986
+ // entry its previous attempt just wrote
3987
+ history: buildHistory(
3988
+ opts.learningsPath,
3989
+ ticket,
3990
+ d
3991
+ ),
3992
+ preferSubagent: opts.preferSubagent
3993
+ }
2319
3994
  ),
2320
3995
  agentCmd: opts.agentCmd,
2321
3996
  model: opts.model,
@@ -2330,8 +4005,27 @@ async function runTicketAttempt(opts) {
2330
4005
  label: ticket.filename.replace(/\.md$/, ""),
2331
4006
  maxTurns: opts.maxTurns,
2332
4007
  effort: opts.effort,
4008
+ preferSubagent: opts.preferSubagent,
4009
+ onStats: opts.onStats,
2333
4010
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2334
4011
  });
4012
+ opts.onAgentRun?.(agentResult);
4013
+ let notes = null;
4014
+ try {
4015
+ notes = collectNotes(worktree.worktreePath);
4016
+ } catch (err) {
4017
+ d.log(
4018
+ ` ${ticket.filename}: could not collect notes: ${formatErrorMessage(err)}`
4019
+ );
4020
+ }
4021
+ const agentRun = {
4022
+ exitCode: agentResult.exitCode,
4023
+ completedItems: agentResult.completedItems,
4024
+ timedOut: agentResult.timedOut,
4025
+ notes,
4026
+ ...agentResult.subagents === void 0 ? {} : { subagents: agentResult.subagents },
4027
+ ...agentResult.costUsd === void 0 ? {} : { costUsd: agentResult.costUsd }
4028
+ };
2335
4029
  if (agentResult.timedOut) {
2336
4030
  d.log(
2337
4031
  ` ${ticket.filename}: timed out; keeping the work it committed`
@@ -2346,7 +4040,8 @@ async function runTicketAttempt(opts) {
2346
4040
  reason: "exit",
2347
4041
  branchName: worktree.branchName,
2348
4042
  exitCode: agentResult.exitCode,
2349
- completedItems: agentResult.completedItems
4043
+ completedItems: agentResult.completedItems,
4044
+ agentRun
2350
4045
  };
2351
4046
  }
2352
4047
  if (isDirty(worktree.worktreePath)) {
@@ -2369,7 +4064,8 @@ async function runTicketAttempt(opts) {
2369
4064
  return {
2370
4065
  kind: "no-commits",
2371
4066
  worktreePath: worktree.worktreePath,
2372
- branchName: worktree.branchName
4067
+ branchName: worktree.branchName,
4068
+ agentRun
2373
4069
  };
2374
4070
  }
2375
4071
  const passed = await runTestGate({
@@ -2392,73 +4088,189 @@ async function runTicketAttempt(opts) {
2392
4088
  reason: "tests",
2393
4089
  branchName: worktree.branchName,
2394
4090
  exitCode: 1,
2395
- completedItems: agentResult.completedItems
4091
+ completedItems: agentResult.completedItems,
4092
+ agentRun
2396
4093
  };
2397
4094
  }
2398
4095
  return {
2399
4096
  kind: "ready",
2400
4097
  worktreePath: worktree.worktreePath,
2401
4098
  branchName: worktree.branchName,
2402
- completedItems: agentResult.completedItems
4099
+ completedItems: agentResult.completedItems,
4100
+ agentRun
2403
4101
  };
2404
4102
  }
2405
4103
  __name(runTicketAttempt, "runTicketAttempt");
2406
- async function runTicketAttempts(opts) {
2407
- const { ticket, d } = opts;
2408
- const maxAttempts = opts.retries + 1;
2409
- const providerName = basename2(opts.agentCmd);
2410
- const isKnown = providerName in EFFORT_LADDERS;
2411
- let effort = opts.effort;
2412
- let outcome = {
2413
- kind: "no-commits",
2414
- worktreePath: "",
2415
- branchName: ""
4104
+ function readLogTail(path) {
4105
+ try {
4106
+ return tailLines(readFileSync6(path, "utf8"), LOG_TAIL_LINES);
4107
+ } catch {
4108
+ return "";
4109
+ }
4110
+ }
4111
+ __name(readLogTail, "readLogTail");
4112
+ function attemptEntry(opts) {
4113
+ const { logsDir, ticket, attempt, effort, outcome } = opts;
4114
+ const base = {
4115
+ key: ticketKey(ticket.feature, ticket.num),
4116
+ label: ticketLabel(ticket),
4117
+ attempt,
4118
+ now: /* @__PURE__ */ new Date()
2416
4119
  };
2417
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2418
- if (attempt === 1) {
2419
- d.log(` Starting ${ticket.filename}`);
2420
- } else {
2421
- if (isKnown) {
2422
- effort = escalateEffort(
2423
- providerName,
2424
- effort
2425
- );
4120
+ const logFor = /* @__PURE__ */ __name((ext) => {
4121
+ const path = attemptLogPath(logsDir, ticket, attempt, ext);
4122
+ return { logName: basename5(path), logTail: readLogTail(path) };
4123
+ }, "logFor");
4124
+ switch (outcome.kind) {
4125
+ case "ready":
4126
+ return {
4127
+ ...base,
4128
+ outcome: "done",
4129
+ run: outcome.agentRun,
4130
+ effort
4131
+ };
4132
+ case "failed":
4133
+ switch (outcome.reason) {
4134
+ case "exit":
4135
+ return {
4136
+ ...base,
4137
+ outcome: "exit",
4138
+ run: outcome.agentRun,
4139
+ effort
4140
+ };
4141
+ case "tests":
4142
+ return {
4143
+ ...base,
4144
+ outcome: "tests",
4145
+ run: outcome.agentRun,
4146
+ effort,
4147
+ ...logFor(".test.log")
4148
+ };
4149
+ case "install":
4150
+ return {
4151
+ ...base,
4152
+ outcome: "install",
4153
+ ...logFor(".install.log")
4154
+ };
4155
+ default: {
4156
+ const _exhaustive = outcome;
4157
+ return _exhaustive;
4158
+ }
2426
4159
  }
2427
- const delay = retryDelayFor(opts.retryDelay, attempt);
2428
- d.log(
2429
- ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
2430
- );
2431
- if (delay > 0) await sleep(delay * 1e3);
4160
+ case "no-commits":
4161
+ return {
4162
+ ...base,
4163
+ outcome: "no-commits",
4164
+ run: outcome.agentRun,
4165
+ effort
4166
+ };
4167
+ case "error":
4168
+ case "worktree-error":
4169
+ return {
4170
+ ...base,
4171
+ outcome: "error",
4172
+ error: formatErrorMessage(outcome.error)
4173
+ };
4174
+ default: {
4175
+ const _exhaustive = outcome;
4176
+ return _exhaustive;
2432
4177
  }
4178
+ }
4179
+ }
4180
+ __name(attemptEntry, "attemptEntry");
4181
+ function recordAttempt(opts) {
4182
+ try {
4183
+ const entry = attemptEntry(opts);
4184
+ if (!entry) return;
4185
+ appendEntry(opts.learningsPath, renderEntry(entry));
4186
+ } catch (err) {
4187
+ opts.d.log(
4188
+ ` ${opts.ticket.filename}: could not record learnings: ${formatErrorMessage(err)}`
4189
+ );
4190
+ }
4191
+ }
4192
+ __name(recordAttempt, "recordAttempt");
4193
+ async function runTicketAttempts(opts) {
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");
4199
+ return runWithRetries({
4200
+ maxAttempts: opts.retries + 1,
4201
+ retryDelay: opts.retryDelay,
4202
+ agentCmd: opts.agentCmd,
4203
+ effort: opts.effort,
4204
+ label: ticket.filename,
4205
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
4206
+ }, async (attempt, effort) => {
4207
+ const record = /* @__PURE__ */ __name((o) => {
4208
+ recordAttempt({
4209
+ learningsPath: opts.learningsPath,
4210
+ logsDir: opts.logsDir,
4211
+ ticket,
4212
+ attempt,
4213
+ effort,
4214
+ outcome: o,
4215
+ d
4216
+ });
4217
+ }, "record");
4218
+ let outcome;
2433
4219
  try {
2434
4220
  outcome = await runTicketAttempt({
2435
4221
  ...opts,
2436
4222
  attempt,
2437
- 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")
2438
4229
  });
2439
4230
  } catch (error) {
2440
4231
  d.log(
2441
4232
  ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
2442
4233
  );
2443
- return { kind: "error", error };
4234
+ const errorOutcome = { kind: "error", error };
4235
+ record(errorOutcome);
4236
+ return { result: errorOutcome, stop: true };
2444
4237
  }
4238
+ record(outcome);
2445
4239
  if (outcome.kind === "ready") {
2446
4240
  d.setTicketStatus(
2447
4241
  ticket.num,
2448
4242
  "awaiting-merge",
2449
4243
  ticket.feature
2450
4244
  );
2451
- return outcome;
4245
+ return { result: outcome, stop: true };
2452
4246
  }
2453
- if (outcome.kind === "worktree-error") return outcome;
2454
- }
2455
- return outcome;
4247
+ return {
4248
+ result: outcome,
4249
+ stop: outcome.kind === "worktree-error"
4250
+ };
4251
+ });
2456
4252
  }
2457
4253
  __name(runTicketAttempts, "runTicketAttempts");
4254
+ function failureNote(reason) {
4255
+ if (reason === "tests") return "tests failed";
4256
+ if (reason === "install") return "install failed";
4257
+ return "failed";
4258
+ }
4259
+ __name(failureNote, "failureNote");
4260
+ function isRegularFile(path) {
4261
+ try {
4262
+ return statSync(path).isFile();
4263
+ } catch {
4264
+ return false;
4265
+ }
4266
+ }
4267
+ __name(isRegularFile, "isRegularFile");
2458
4268
  async function saveProgress(repoRoot, progressPath, progress, message, d) {
2459
4269
  writeProgress(progressPath, progress);
4270
+ const learningsPath = learningsPathFor(progressPath);
4271
+ const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
2460
4272
  try {
2461
- await commitProgress(repoRoot, progressPath, message);
4273
+ await commitProgress(repoRoot, paths, message);
2462
4274
  } catch (err) {
2463
4275
  d.log(
2464
4276
  " Failed to commit progress: " + formatErrorMessage(err)
@@ -2471,15 +4283,77 @@ function indentFilenames(tickets) {
2471
4283
  return tickets.map((t) => ` ${t.filename}`).join("\n");
2472
4284
  }
2473
4285
  __name(indentFilenames, "indentFilenames");
2474
- function renderFinishSummary(tickets, totalCompleted, failedKeys) {
4286
+ function explainOpen(tickets, failedKeys, warnings) {
2475
4287
  const remaining = tickets.filter(
2476
4288
  (t) => t.status === "ready-for-agent"
2477
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 = [];
2478
4296
  const human = tickets.filter((t) => t.status === HUMAN_STATUS);
2479
- const humanNums = new Set(human.map((t) => t.num));
2480
- const blockedByHuman = remaining.filter(
2481
- (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")
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"
2482
4355
  );
4356
+ const human = tickets.filter((t) => t.status === HUMAN_STATUS);
2483
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` : "");
2484
4358
  const lines = [counts];
2485
4359
  const features = new Set(
@@ -2508,26 +4382,54 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
2508
4382
  lines.push(detail);
2509
4383
  }
2510
4384
  }
2511
- if (human.length === 0) return lines.join("\n");
2512
- const noun = human.length === 1 ? "ticket is" : "tickets are";
2513
- lines.push(
2514
- "",
2515
- (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:",
2516
- 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])
2517
4401
  );
2518
- if (blockedByHuman.length > 0) {
2519
- 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) {
2520
4416
  lines.push(
2521
- `${n} ready-for-agent ticket${n === 1 ? " is" : "s are"} blocked until they are done:`,
2522
- 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."
2523
4428
  );
2524
4429
  }
2525
- lines.push(
2526
- "Do that work by hand, then set each ticket's **Status:** to done" + (blockedByHuman.length > 0 ? " and run nightralph again." : ".")
2527
- );
2528
4430
  return lines.join("\n");
2529
4431
  }
2530
- __name(renderFinishSummary, "renderFinishSummary");
4432
+ __name(renderNothingReady, "renderNothingReady");
2531
4433
  function sessionTickets(tickets) {
2532
4434
  return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
2533
4435
  }
@@ -2546,7 +4448,10 @@ function reportBlocked(d, tickets, failedKeys) {
2546
4448
  )) {
2547
4449
  const ticket = byKey.get(key);
2548
4450
  if (!ticket) continue;
2549
- 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
+ });
2550
4455
  d.setBlocked(ticket.num, ticket.feature, labels);
2551
4456
  }
2552
4457
  }
@@ -2558,13 +4463,13 @@ function runProgress(tickets) {
2558
4463
  };
2559
4464
  }
2560
4465
  __name(runProgress, "runProgress");
2561
- async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
2562
- mkdirSync2(logsDir, { recursive: true });
4466
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency, warnings = [], preferSubagent) {
4467
+ mkdirSync3(logsDir, { recursive: true });
2563
4468
  const d = display ?? createDisplay();
2564
4469
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
2565
4470
  const delayBase = retryDelay ?? 0;
2566
4471
  const poolSize = concurrency ?? DEFAULT_CONCURRENCY;
2567
- const providerName = basename2(agentCmd);
4472
+ const providerName = basename5(agentCmd);
2568
4473
  const isKnown = providerName in EFFORT_LADDERS;
2569
4474
  let totalWaves = 0;
2570
4475
  for (const _ of simulateWaves(tickets)) totalWaves++;
@@ -2572,6 +4477,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2572
4477
  let waveNum = 0;
2573
4478
  const failedKeys = /* @__PURE__ */ new Set();
2574
4479
  const session = sessionTickets(tickets);
4480
+ let runStats = emptyRunStats();
2575
4481
  while (true) {
2576
4482
  if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
2577
4483
  const ready = findReadyTickets(tickets).filter(
@@ -2602,7 +4508,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2602
4508
  const prompt = renderPrompt(
2603
4509
  specBody,
2604
4510
  t,
2605
- { testCmd }
4511
+ { testCmd, preferSubagent }
2606
4512
  );
2607
4513
  const label = t.filename.replace(
2608
4514
  /\.md$/,
@@ -2614,6 +4520,18 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2614
4520
  t.feature
2615
4521
  );
2616
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");
2617
4535
  let level = effort;
2618
4536
  let result = await spawnAgent({
2619
4537
  prompt,
@@ -2624,8 +4542,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2624
4542
  label,
2625
4543
  maxTurns,
2626
4544
  effort: level,
4545
+ preferSubagent,
4546
+ onStats,
2627
4547
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2628
4548
  });
4549
+ tally(result);
2629
4550
  for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
2630
4551
  if (isKnown) {
2631
4552
  level = escalateEffort(
@@ -2652,8 +4573,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2652
4573
  label,
2653
4574
  maxTurns,
2654
4575
  effort: level,
4576
+ preferSubagent,
4577
+ onStats,
2655
4578
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2656
4579
  });
4580
+ tally(result);
2657
4581
  }
2658
4582
  return { ticket: t, result };
2659
4583
  }
@@ -2712,14 +4636,17 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
2712
4636
  d.log("\n" + renderFinishSummary(
2713
4637
  tickets,
2714
4638
  totalCompleted,
2715
- failedKeys
4639
+ failedKeys,
4640
+ warnings
2716
4641
  ));
4642
+ const statsLine = renderRunStats(runStats);
4643
+ if (statsLine) d.log(statsLine);
2717
4644
  d.cleanup();
2718
4645
  return { allDone: remaining.length === 0 };
2719
4646
  }
2720
4647
  __name(runWaves, "runWaves");
2721
4648
  async function runWavesIsolated(opts) {
2722
- mkdirSync2(opts.logsDir, { recursive: true });
4649
+ mkdirSync3(opts.logsDir, { recursive: true });
2723
4650
  const d = opts.display ?? createDisplay();
2724
4651
  const retries = opts.retries ?? DEFAULT_RETRIES;
2725
4652
  const retryDelay = opts.retryDelay ?? 0;
@@ -2734,12 +4661,13 @@ async function runWavesIsolated(opts) {
2734
4661
  "Working tree is dirty; commit or stash changes before running"
2735
4662
  );
2736
4663
  }
4664
+ const launchCommit = headCommit(repoRoot);
2737
4665
  const features = new Set(
2738
4666
  opts.tickets.map((t) => t.feature).filter(Boolean)
2739
4667
  );
2740
4668
  const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
2741
- const fallbackProgressPath = join4(
2742
- dirname2(opts.logsDir),
4669
+ const fallbackProgressPath = join6(
4670
+ dirname4(opts.logsDir),
2743
4671
  "progress.md"
2744
4672
  );
2745
4673
  let tracker;
@@ -2799,6 +4727,10 @@ async function runWavesIsolated(opts) {
2799
4727
  let waveNum = 0;
2800
4728
  const failedKeys = /* @__PURE__ */ new Set();
2801
4729
  const session = sessionTickets(opts.tickets);
4730
+ let runStats = emptyRunStats();
4731
+ const onAgentRun = /* @__PURE__ */ __name((result) => {
4732
+ runStats = addRun(runStats, result);
4733
+ }, "onAgentRun");
2802
4734
  while (true) {
2803
4735
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
2804
4736
  const ready = findReadyTickets(opts.tickets).filter(
@@ -2837,13 +4769,36 @@ async function runWavesIsolated(opts) {
2837
4769
  timeout: opts.timeout,
2838
4770
  logsDir: opts.logsDir,
2839
4771
  testCmd: opts.testCmd,
4772
+ installCmd: opts.installCmd,
2840
4773
  maxTurns: opts.maxTurns,
2841
4774
  effort: opts.effort,
4775
+ preferSubagent: opts.preferSubagent,
4776
+ learningsPath: learningsPathFor(
4777
+ tracker.pathFor(t.feature)
4778
+ ),
2842
4779
  retries,
2843
4780
  retryDelay,
2844
- d
4781
+ d,
4782
+ onAgentRun
2845
4783
  })
2846
4784
  );
4785
+ const touched = new Set(ready.map((t) => {
4786
+ return learningsPathFor(tracker.pathFor(t.feature));
4787
+ }));
4788
+ for (const path of touched) {
4789
+ if (!isRegularFile(path)) continue;
4790
+ try {
4791
+ await commitProgress(
4792
+ repoRoot,
4793
+ path,
4794
+ "nightralph: record attempt learnings"
4795
+ );
4796
+ } catch (err) {
4797
+ d.log(
4798
+ " Failed to commit learnings: " + formatErrorMessage(err)
4799
+ );
4800
+ }
4801
+ }
2847
4802
  const outcomes = settled.map(
2848
4803
  (r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
2849
4804
  );
@@ -2947,7 +4902,7 @@ async function runWavesIsolated(opts) {
2947
4902
  repoRoot,
2948
4903
  tracker.pathFor(ticket.feature),
2949
4904
  tracker.dataFor(ticket.feature),
2950
- `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
4905
+ `nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
2951
4906
  d
2952
4907
  );
2953
4908
  continue;
@@ -2983,6 +4938,7 @@ async function runWavesIsolated(opts) {
2983
4938
  branches: successfulBranches,
2984
4939
  strategy: opts.conflictStrategy
2985
4940
  });
4941
+ const doneThisWave = [];
2986
4942
  for (const branchResult of mergeResult.merged) {
2987
4943
  const bk = branchToKey.get(
2988
4944
  branchResult.branch
@@ -2991,6 +4947,7 @@ async function runWavesIsolated(opts) {
2991
4947
  if (!ticket) continue;
2992
4948
  if (branchResult.merged) {
2993
4949
  markDone(ticket);
4950
+ doneThisWave.push(ticket);
2994
4951
  waveCompleted++;
2995
4952
  totalCompleted++;
2996
4953
  d.setTicketStatus(
@@ -3040,11 +4997,15 @@ async function runWavesIsolated(opts) {
3040
4997
  timeout: opts.timeout,
3041
4998
  logsDir: opts.logsDir,
3042
4999
  testCmd: opts.testCmd,
5000
+ installCmd: opts.installCmd,
3043
5001
  maxTurns: opts.maxTurns,
3044
- onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
5002
+ preferSubagent: opts.preferSubagent,
5003
+ onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine"),
5004
+ onAgentRun
3045
5005
  }) : false;
3046
5006
  if (recovered) {
3047
5007
  markDone(ticket);
5008
+ doneThisWave.push(ticket);
3048
5009
  waveCompleted++;
3049
5010
  totalCompleted++;
3050
5011
  d.setTicketStatus(
@@ -3100,6 +5061,28 @@ async function runWavesIsolated(opts) {
3100
5061
  exitCode: 0,
3101
5062
  done: false
3102
5063
  });
5064
+ try {
5065
+ appendEntry(
5066
+ learningsPathFor(
5067
+ tracker.pathFor(ticket.feature)
5068
+ ),
5069
+ renderEntry({
5070
+ outcome: "merge",
5071
+ key: ticketKey(
5072
+ ticket.feature,
5073
+ ticket.num
5074
+ ),
5075
+ label: ticketLabel(ticket),
5076
+ attempt: 0,
5077
+ now: /* @__PURE__ */ new Date(),
5078
+ stderr: branchResult.stderr ?? ""
5079
+ })
5080
+ );
5081
+ } catch (err) {
5082
+ d.log(
5083
+ ` ${ticket.filename}: could not record learnings: ` + formatErrorMessage(err)
5084
+ );
5085
+ }
3103
5086
  await saveProgress(
3104
5087
  repoRoot,
3105
5088
  tracker.pathFor(ticket.feature),
@@ -3110,6 +5093,7 @@ async function runWavesIsolated(opts) {
3110
5093
  }
3111
5094
  }
3112
5095
  }
5096
+ await commitTicketFiles(repoRoot, doneThisWave, d);
3113
5097
  if (mergeResult.stoppedAt) {
3114
5098
  d.log(
3115
5099
  `
@@ -3152,31 +5136,109 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
3152
5136
  }
3153
5137
  }
3154
5138
  if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
5139
+ let reviewLine = null;
5140
+ if (opts.review) {
5141
+ try {
5142
+ const outcome = await runReviewPass({
5143
+ repoRoot,
5144
+ repoName,
5145
+ baseBranch,
5146
+ reviewPath: opts.review.reviewPath,
5147
+ forkedFrom: opts.review.forkedFrom,
5148
+ launchCommit,
5149
+ specBody: reviewSpecBody(
5150
+ opts.specBody,
5151
+ opts.specBodies
5152
+ ),
5153
+ agentCmd: opts.agentCmd,
5154
+ model: opts.model,
5155
+ timeout: opts.timeout,
5156
+ logsDir: opts.logsDir,
5157
+ testCmd: opts.testCmd,
5158
+ installCmd: opts.installCmd,
5159
+ maxTurns: opts.maxTurns,
5160
+ effort: opts.effort,
5161
+ preferSubagent: opts.preferSubagent,
5162
+ retries,
5163
+ retryDelay,
5164
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
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
+ });
5173
+ reviewLine = renderReviewSummary(
5174
+ outcome,
5175
+ relative2(repoRoot, opts.review.reviewPath)
5176
+ );
5177
+ } catch (err) {
5178
+ d.log(`
5179
+ Review failed: ${formatErrorMessage(err)}`);
5180
+ }
5181
+ }
3155
5182
  const remaining = opts.tickets.filter(
3156
5183
  (t) => t.status === "ready-for-agent"
3157
5184
  );
3158
5185
  d.log("\n" + renderFinishSummary(
3159
5186
  opts.tickets,
3160
5187
  totalCompleted,
3161
- failedKeys
5188
+ failedKeys,
5189
+ opts.warnings ?? []
3162
5190
  ));
5191
+ const statsLine = renderRunStats(runStats);
5192
+ if (statsLine) d.log(statsLine);
5193
+ if (reviewLine) d.log(reviewLine);
5194
+ d.log(`Work is on ${baseBranch}`);
3163
5195
  d.cleanup();
3164
- return { allDone: remaining.length === 0 };
5196
+ return { allDone: remaining.length === 0, branch: baseBranch };
3165
5197
  }
3166
5198
  __name(runWavesIsolated, "runWavesIsolated");
3167
5199
 
5200
+ // src/preflight.ts
5201
+ import { accessSync, constants, statSync as statSync2 } from "node:fs";
5202
+ import { delimiter, join as join7, resolve as resolve2 } from "node:path";
5203
+ function isExecutableFile(file) {
5204
+ try {
5205
+ if (!statSync2(file).isFile()) return false;
5206
+ accessSync(file, constants.X_OK);
5207
+ return true;
5208
+ } catch {
5209
+ return false;
5210
+ }
5211
+ }
5212
+ __name(isExecutableFile, "isExecutableFile");
5213
+ function resolveExecutable(cmd, opts = {}) {
5214
+ if (cmd === "") return null;
5215
+ const cwd = opts.cwd ?? process.cwd();
5216
+ if (cmd.includes("/")) {
5217
+ const full = resolve2(cwd, cmd);
5218
+ return isExecutableFile(full) ? full : null;
5219
+ }
5220
+ const path = opts.path ?? process.env.PATH ?? "";
5221
+ for (const dir of path.split(delimiter)) {
5222
+ if (dir === "") continue;
5223
+ const candidate = join7(dir, cmd);
5224
+ if (isExecutableFile(candidate)) return candidate;
5225
+ }
5226
+ return null;
5227
+ }
5228
+ __name(resolveExecutable, "resolveExecutable");
5229
+
3168
5230
  // src/resolve.ts
3169
- import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
3170
- import { dirname as dirname3, join as join5 } from "node:path";
5231
+ import { existsSync as existsSync6, readdirSync as readdirSync3 } from "node:fs";
5232
+ import { dirname as dirname5, join as join8 } from "node:path";
3171
5233
  import { fileURLToPath } from "node:url";
3172
5234
  import { checkbox, Separator } from "@inquirer/prompts";
3173
5235
  function getScriptDir(metaUrl) {
3174
- return dirname3(fileURLToPath(metaUrl));
5236
+ return dirname5(fileURLToPath(metaUrl));
3175
5237
  }
3176
5238
  __name(getScriptDir, "getScriptDir");
3177
5239
  function findFeatureDirs(cwd) {
3178
- const scratchDir = join5(cwd, ".scratch");
3179
- if (!existsSync4(scratchDir)) return [];
5240
+ const scratchDir = join8(cwd, ".scratch");
5241
+ if (!existsSync6(scratchDir)) return [];
3180
5242
  const entries = readdirSync3(
3181
5243
  scratchDir,
3182
5244
  { withFileTypes: true }
@@ -3184,18 +5246,18 @@ function findFeatureDirs(cwd) {
3184
5246
  const results = [];
3185
5247
  for (const entry of entries) {
3186
5248
  if (!entry.isDirectory()) continue;
3187
- const issuesDir = join5(
5249
+ const issuesDir = join8(
3188
5250
  scratchDir,
3189
5251
  entry.name,
3190
5252
  "issues"
3191
5253
  );
3192
- const specFile = join5(
5254
+ const specFile = join8(
3193
5255
  scratchDir,
3194
5256
  entry.name,
3195
5257
  "spec.md"
3196
5258
  );
3197
- if (!existsSync4(issuesDir)) continue;
3198
- const progressFile = join5(
5259
+ if (!existsSync6(issuesDir)) continue;
5260
+ const progressFile = join8(
3199
5261
  scratchDir,
3200
5262
  entry.name,
3201
5263
  "progress.md"
@@ -3211,13 +5273,93 @@ function findFeatureDirs(cwd) {
3211
5273
  return results;
3212
5274
  }
3213
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");
3214
5356
  var ALL_VALUE = "__all__";
3215
5357
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
3216
5358
  if (specName) {
3217
- const featureDir = join5(cwd, ".scratch", specName);
5359
+ const featureDir = join8(cwd, ".scratch", specName);
3218
5360
  return [{
3219
- dir: join5(featureDir, "issues"),
3220
- spec: join5(featureDir, "spec.md"),
5361
+ dir: join8(featureDir, "issues"),
5362
+ spec: join8(featureDir, "spec.md"),
3221
5363
  name: specName
3222
5364
  }];
3223
5365
  }
@@ -3233,14 +5375,11 @@ async function resolveIssuesDir(specName, cwd = process.cwd()) {
3233
5375
  console.log(`Using feature: ${f.name}`);
3234
5376
  return [{ dir: f.dir, spec: f.spec, name: f.name }];
3235
5377
  }
3236
- const featureChoices = features.map((f) => {
3237
- const tickets = scanTickets(f.dir);
3238
- const ready = findReadyTickets(tickets).length;
3239
- return {
3240
- name: `${f.name} (${ready}/${tickets.length} ready)`,
3241
- value: f.name
3242
- };
3243
- });
5378
+ const scratchDir = join8(cwd, ".scratch");
5379
+ const featureChoices = features.map((f) => ({
5380
+ name: featureChoiceLabel(scratchDir, f.name),
5381
+ value: f.name
5382
+ }));
3244
5383
  while (true) {
3245
5384
  const selected = await checkbox({
3246
5385
  message: "Which features?",
@@ -3296,8 +5435,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3296
5435
  }
3297
5436
  }
3298
5437
  setup({
3299
- bundledSkillsDir: join6(scriptDir, "skills"),
3300
- docsTemplatesDir: join6(
5438
+ bundledSkillsDir: join9(scriptDir, "skills"),
5439
+ docsTemplatesDir: join9(
3301
5440
  scriptDir,
3302
5441
  "docs-templates"
3303
5442
  ),
@@ -3332,7 +5471,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3332
5471
  default: 3600
3333
5472
  }).option("test-cmd", {
3334
5473
  type: "string",
3335
- describe: 'Command run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to "npm test".'
5474
+ describe: 'Command run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to "npm test" when package.json has a test script; otherwise the run has no test gate. "" disables it.'
5475
+ }).option("install-cmd", {
5476
+ type: "string",
5477
+ describe: `Command run in each fresh worktree before the agent starts. Defaults to the install command for the project's lockfile (npm ci or pnpm install); "" disables it.`
3336
5478
  }).option("X", {
3337
5479
  type: "boolean",
3338
5480
  describe: "Stop on merge conflict instead of re-spawning",
@@ -3344,6 +5486,9 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3344
5486
  type: "string",
3345
5487
  choices: ALL_EFFORT_LEVELS,
3346
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.'
3347
5492
  }).option("retries", {
3348
5493
  type: "number",
3349
5494
  default: DEFAULT_RETRIES,
@@ -3356,6 +5501,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3356
5501
  type: "number",
3357
5502
  default: DEFAULT_CONCURRENCY,
3358
5503
  describe: "Max agents running at once within a wave."
5504
+ }).option("review", {
5505
+ type: "boolean",
5506
+ default: true,
5507
+ describe: "Review the merged work and run one fix pass at the end. --no-review skips it."
3359
5508
  }),
3360
5509
  async (argv) => {
3361
5510
  await runExecute({
@@ -3365,15 +5514,27 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3365
5514
  dryRun: argv.dryRun,
3366
5515
  timeout: argv.timeout,
3367
5516
  testCmd: argv.testCmd,
5517
+ installCmd: argv.installCmd,
3368
5518
  stopOnConflict: argv.X,
3369
5519
  maxTurns: argv.maxTurns ?? argv.turns,
3370
5520
  effort: argv.effort,
5521
+ preferSubagent: argv.preferSubagent,
3371
5522
  retries: argv.retries,
3372
5523
  retryDelay: argv.retryDelay,
3373
- concurrency: argv.concurrency
5524
+ concurrency: argv.concurrency,
5525
+ review: argv.review
3374
5526
  });
3375
5527
  }
3376
- ).strict().demandCommand(1, "Specify a command or provider").help().parse();
5528
+ ).strict().demandCommand(1, "Specify a command or provider").help().fail((msg, err, y) => {
5529
+ if (err && !msg) {
5530
+ console.error(formatErrorMessage(err));
5531
+ process.exit(2);
5532
+ }
5533
+ y.showHelp();
5534
+ console.error(`
5535
+ ${msg}`);
5536
+ process.exit(1);
5537
+ }).parse();
3377
5538
  function isGitRepo() {
3378
5539
  try {
3379
5540
  getRepoRoot();
@@ -3394,80 +5555,215 @@ function extractFeatureName(spec) {
3394
5555
  return parts[parts.length - 2] ?? parts[parts.length - 1] ?? "feature";
3395
5556
  }
3396
5557
  __name(extractFeatureName, "extractFeatureName");
3397
- async function runExecute(argv) {
3398
- const selections = await resolveIssuesDir(
3399
- argv.spec
3400
- );
5558
+ function loadTickets(selections, log) {
3401
5559
  const allTickets = [];
3402
5560
  const specBodies = /* @__PURE__ */ new Map();
3403
5561
  const progressPaths = /* @__PURE__ */ new Map();
5562
+ const issuesDirs = [];
3404
5563
  let firstFeatureName = "";
3405
5564
  let firstLogsDir = "";
5565
+ const missing = [];
5566
+ const valid = [];
5567
+ const specs = /* @__PURE__ */ new Map();
3406
5568
  for (const selection of selections) {
3407
5569
  const { dir, spec, name } = selection;
3408
- if (!existsSync5(dir)) {
3409
- console.error(
3410
- `Directory not found: ${dir}`
3411
- );
3412
- process.exit(2);
5570
+ if (!existsSync7(dir)) {
5571
+ missing.push(`Directory not found: ${dir}`);
5572
+ continue;
3413
5573
  }
3414
- if (!existsSync5(spec)) {
3415
- console.error(
3416
- `Spec file not found: ${spec}`
3417
- );
3418
- process.exit(2);
5574
+ if (!existsSync7(spec)) {
5575
+ missing.push(`Spec file not found: ${spec}`);
5576
+ continue;
3419
5577
  }
3420
- const specBody = stripSpecSection(
3421
- readFileSync5(spec, "utf8"),
5578
+ specs.set(name, stripSpecSection(
5579
+ readFileSync7(spec, "utf8"),
3422
5580
  "User Stories"
3423
- );
3424
- 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);
3425
5590
  if (tickets.length === 0) {
3426
- console.log(
5591
+ log(
3427
5592
  `No ticket files found for ${name}. Skipping.`
3428
5593
  );
3429
5594
  continue;
3430
5595
  }
3431
- console.log(
5596
+ log(
3432
5597
  `[${name}] Found ${tickets.length} tickets`
3433
5598
  );
3434
- const knownNums = new Set(
3435
- tickets.map((t) => t.num)
3436
- );
3437
- for (const t of tickets) {
3438
- const unknown = t.blockers.filter(
3439
- (b) => !knownNums.has(b)
3440
- );
3441
- if (unknown.length > 0) {
3442
- console.warn(
3443
- `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
3444
- );
3445
- }
3446
- t.blockers = t.blockers.filter(
3447
- (b) => knownNums.has(b)
3448
- );
3449
- }
3450
- specBodies.set(name, specBody);
5599
+ specBodies.set(name, specs.get(name));
3451
5600
  progressPaths.set(
3452
5601
  name,
3453
- join6(dirname4(dir), "progress.md")
5602
+ join9(dirname6(dir), "progress.md")
3454
5603
  );
5604
+ issuesDirs.push(dir);
3455
5605
  allTickets.push(...tickets);
3456
5606
  if (!firstFeatureName) {
3457
5607
  firstFeatureName = extractFeatureName(spec);
3458
- firstLogsDir = join6(dirname4(dir), "logs");
5608
+ firstLogsDir = join9(dirname6(dir), "logs");
5609
+ }
5610
+ }
5611
+ return {
5612
+ allTickets,
5613
+ specBodies,
5614
+ progressPaths,
5615
+ issuesDirs,
5616
+ firstFeatureName,
5617
+ firstLogsDir,
5618
+ missing,
5619
+ warnings: loaded.warnings
5620
+ };
5621
+ }
5622
+ __name(loadTickets, "loadTickets");
5623
+ function loadTicketsOrExit(selections) {
5624
+ const loaded = loadTickets(selections, console.log);
5625
+ if (loaded.missing.length > 0) {
5626
+ for (const msg of loaded.missing) console.error(msg);
5627
+ process.exit(2);
5628
+ }
5629
+ return loaded;
5630
+ }
5631
+ __name(loadTicketsOrExit, "loadTicketsOrExit");
5632
+ function checkoutLaunchBranch(plan) {
5633
+ try {
5634
+ execFileSync2(
5635
+ "git",
5636
+ ["checkout", "--quiet", plan.launchBranch],
5637
+ { cwd: getRepoRoot(), stdio: "ignore" }
5638
+ );
5639
+ } catch {
5640
+ }
5641
+ }
5642
+ __name(checkoutLaunchBranch, "checkoutLaunchBranch");
5643
+ function failStaleResume(plan, problems) {
5644
+ checkoutLaunchBranch(plan);
5645
+ const n = plan.behind;
5646
+ console.error(
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}.`
5648
+ );
5649
+ process.exit(2);
5650
+ }
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");
5671
+ function reviewPathFor(issuesDirs) {
5672
+ const featureDir = dirname6(issuesDirs[0]);
5673
+ return issuesDirs.length === 1 ? join9(featureDir, "review.md") : join9(dirname6(featureDir), "review.md");
5674
+ }
5675
+ __name(reviewPathFor, "reviewPathFor");
5676
+ async function runExecute(argv) {
5677
+ const selections = await resolveIssuesDir(
5678
+ argv.spec
5679
+ );
5680
+ let loaded = loadTicketsOrExit(selections);
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
+ }
5690
+ const providerPath = resolveExecutable(argv.provider);
5691
+ const providerIsPath = argv.provider.includes("/");
5692
+ const providerMissing = providerIsPath ? "not found or not executable" : "not found on PATH";
5693
+ if (providerPath === null && !argv.dryRun) {
5694
+ console.error(
5695
+ `Provider '${argv.provider}' ${providerMissing}`
5696
+ );
5697
+ process.exit(2);
5698
+ }
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
+ }
5708
+ const isolated = isGitRepo();
5709
+ const featureNames = [...loaded.specBodies.keys()];
5710
+ let plan = null;
5711
+ if (isolated && !argv.dryRun) {
5712
+ try {
5713
+ plan = prepareIntegrationBranch(
5714
+ getRepoRoot(),
5715
+ featureNames
5716
+ );
5717
+ } catch (err) {
5718
+ console.error(formatErrorMessage(err));
5719
+ process.exit(2);
5720
+ }
5721
+ if (plan.created) {
5722
+ console.log(
5723
+ `Created branch ${plan.branch} from ${plan.launchBranch}`
5724
+ );
5725
+ } else if (plan.resumed) {
5726
+ console.log(
5727
+ `Resumed ${plan.branch}` + (plan.fastForwarded ? ` (fast-forwarded to ${plan.launchBranch})` : "") + (plan.behind > 0 ? ` (${plan.launchBranch} is ${plan.behind} commit${plan.behind === 1 ? "" : "s"} ahead)` : "")
5728
+ );
5729
+ loaded = loadTickets(selections, () => {
5730
+ });
5731
+ if (loaded.missing.length > 0) {
5732
+ failStaleResume(plan, loaded.missing);
5733
+ }
5734
+ if (loaded.allTickets.length === 0) {
5735
+ failStaleResume(plan, ["no tickets"]);
5736
+ }
5737
+ if (nothingReady(loaded.allTickets)) {
5738
+ stopNothingReady(plan, loaded);
5739
+ }
3459
5740
  }
3460
5741
  }
3461
- if (allTickets.length === 0) return;
5742
+ const {
5743
+ allTickets,
5744
+ specBodies,
5745
+ progressPaths,
5746
+ firstFeatureName,
5747
+ firstLogsDir
5748
+ } = loaded;
3462
5749
  const timeout = argv.timeout ?? 900;
3463
- const testCmd = argv.testCmd || detectTestCmd(
3464
- isGitRepo() ? getRepoRoot() : process.cwd()
3465
- ) || "npm test";
5750
+ const testCmd = argv.testCmd ?? detectTestCmd(
5751
+ isolated ? getRepoRoot() : process.cwd()
5752
+ ) ?? "";
5753
+ if (argv.testCmd === void 0 && testCmd === "") {
5754
+ console.log(
5755
+ "No test command detected; running without a test gate. Pass --test-cmd to add one."
5756
+ );
5757
+ }
5758
+ const installCmd = argv.installCmd ?? (isolated ? detectInstallCmd(getRepoRoot()) ?? void 0 : void 0);
3466
5759
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
3467
5760
  const logsDir = firstLogsDir;
3468
5761
  const fallbackSpec = specBodies.values().next().value;
3469
5762
  if (argv.dryRun) {
3470
- if (isGitRepo()) {
5763
+ console.log(
5764
+ `Provider: ${providerPath ?? providerMissing}`
5765
+ );
5766
+ if (isolated) {
3471
5767
  const repoName = getRepoName();
3472
5768
  dryRunIsolated({
3473
5769
  tickets: allTickets,
@@ -3476,13 +5772,28 @@ async function runExecute(argv) {
3476
5772
  conflictStrategy,
3477
5773
  progressPath: progressPaths.values().next().value,
3478
5774
  testCmd,
5775
+ installCmd,
3479
5776
  effort: argv.effort,
3480
5777
  retries: argv.retries,
3481
5778
  retryDelay: argv.retryDelay,
3482
- concurrency: argv.concurrency
5779
+ concurrency: argv.concurrency,
5780
+ preferSubagent
3483
5781
  });
5782
+ console.log(
5783
+ describeIntegrationBranch(
5784
+ getRepoRoot(),
5785
+ featureNames
5786
+ )
5787
+ );
5788
+ const reviewState = argv.review === false ? "off" : "on";
5789
+ console.log(`Review: ${reviewState}`);
3484
5790
  } else {
3485
- dryRun(allTickets, fallbackSpec, testCmd);
5791
+ dryRun(
5792
+ allTickets,
5793
+ fallbackSpec,
5794
+ testCmd,
5795
+ preferSubagent
5796
+ );
3486
5797
  }
3487
5798
  return;
3488
5799
  }
@@ -3496,12 +5807,12 @@ async function runExecute(argv) {
3496
5807
  process.on("SIGINT", () => onSignal(130));
3497
5808
  process.on("SIGTERM", () => onSignal(143));
3498
5809
  let result;
3499
- if (isGitRepo()) {
5810
+ if (isolated) {
3500
5811
  result = await runWavesIsolated({
3501
5812
  tickets: allTickets,
3502
5813
  specBody: fallbackSpec,
3503
5814
  specBodies,
3504
- agentCmd: argv.provider,
5815
+ agentCmd,
3505
5816
  model: argv.model ?? null,
3506
5817
  timeout,
3507
5818
  logsDir,
@@ -3509,18 +5820,25 @@ async function runExecute(argv) {
3509
5820
  conflictStrategy,
3510
5821
  display,
3511
5822
  testCmd,
5823
+ installCmd,
3512
5824
  maxTurns: argv.maxTurns,
3513
5825
  effort: argv.effort,
3514
5826
  retries: argv.retries,
3515
5827
  retryDelay: argv.retryDelay,
3516
5828
  concurrency: argv.concurrency,
3517
- progressPaths
5829
+ preferSubagent,
5830
+ progressPaths,
5831
+ warnings: loaded.warnings,
5832
+ review: argv.review === false ? void 0 : {
5833
+ reviewPath: reviewPathFor(loaded.issuesDirs),
5834
+ forkedFrom: plan?.forkedFrom ?? void 0
5835
+ }
3518
5836
  });
3519
5837
  } else {
3520
5838
  result = await runWaves(
3521
5839
  allTickets,
3522
5840
  fallbackSpec,
3523
- argv.provider,
5841
+ agentCmd,
3524
5842
  argv.model ?? null,
3525
5843
  timeout,
3526
5844
  logsDir,
@@ -3530,7 +5848,9 @@ async function runExecute(argv) {
3530
5848
  argv.effort,
3531
5849
  argv.retries,
3532
5850
  argv.retryDelay,
3533
- argv.concurrency
5851
+ argv.concurrency,
5852
+ loaded.warnings,
5853
+ preferSubagent
3534
5854
  );
3535
5855
  }
3536
5856
  if (!result.allDone) {