nightralph 0.0.52 → 0.0.54

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 (46) hide show
  1. package/README.md +145 -16
  2. package/dist/display.js +80 -62
  3. package/dist/display.js.map +2 -2
  4. package/dist/index.js +2099 -314
  5. package/dist/index.js.map +4 -4
  6. package/dist/learnings.js +356 -0
  7. package/dist/learnings.js.map +7 -0
  8. package/dist/meta.json +234 -14
  9. package/dist/orchestrator.js +528 -142
  10. package/dist/orchestrator.js.map +3 -3
  11. package/dist/preflight.js +34 -0
  12. package/dist/preflight.js.map +7 -0
  13. package/dist/progress.js +5 -4
  14. package/dist/progress.js.map +2 -2
  15. package/dist/retry.js +83 -0
  16. package/dist/retry.js.map +7 -0
  17. package/dist/review.js +662 -0
  18. package/dist/review.js.map +7 -0
  19. package/dist/rules.js +54 -0
  20. package/dist/rules.js.map +7 -0
  21. package/dist/src/display.d.ts +35 -33
  22. package/dist/src/display.d.ts.map +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/learnings.d.ts +68 -0
  25. package/dist/src/learnings.d.ts.map +1 -0
  26. package/dist/src/orchestrator.d.ts +24 -11
  27. package/dist/src/orchestrator.d.ts.map +1 -1
  28. package/dist/src/preflight.d.ts +6 -0
  29. package/dist/src/preflight.d.ts.map +1 -0
  30. package/dist/src/progress.d.ts +1 -1
  31. package/dist/src/progress.d.ts.map +1 -1
  32. package/dist/src/retry.d.ts +26 -0
  33. package/dist/src/retry.d.ts.map +1 -0
  34. package/dist/src/review.d.ts +127 -0
  35. package/dist/src/review.d.ts.map +1 -0
  36. package/dist/src/rules.d.ts +10 -0
  37. package/dist/src/rules.d.ts.map +1 -0
  38. package/dist/src/testcmd.d.ts +1 -0
  39. package/dist/src/testcmd.d.ts.map +1 -1
  40. package/dist/src/worktree.d.ts +30 -1
  41. package/dist/src/worktree.d.ts.map +1 -1
  42. package/dist/testcmd.js +13 -0
  43. package/dist/testcmd.js.map +2 -2
  44. package/dist/worktree.js +236 -32
  45. package/dist/worktree.js.map +2 -2
  46. package/package.json +5 -4
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 { 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;
@@ -837,6 +1032,14 @@ var DEFAULT_COLUMNS = 80;
837
1032
  var MIN_LOG_ROWS = 1;
838
1033
  var MAX_MESSAGE_BUFFER = 200;
839
1034
  var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
1035
+ function rowText(ticket) {
1036
+ const label = ticketLabel(ticket);
1037
+ if (ticket.status !== "blocked" || !ticket.blockedBy?.length) {
1038
+ return label;
1039
+ }
1040
+ return `${label} (blocked by ${ticket.blockedBy.join(", ")})`;
1041
+ }
1042
+ __name(rowText, "rowText");
840
1043
  function fitLine(line, width) {
841
1044
  const visible = line.replace(ANSI_RE, "");
842
1045
  if (visible.length <= width) return line;
@@ -860,13 +1063,16 @@ function renderDot(status) {
860
1063
  case "in-progress":
861
1064
  return color.yellow(FILLED_DOT);
862
1065
  // The agent and its test gate are finished; the branch
863
- // waits for the rest of the wave before it is merged.
1066
+ // waits for the rest of the wave before it is merged. It
1067
+ // stays cyan through the merge and any conflict respawn.
864
1068
  case "awaiting-merge":
865
1069
  return color.cyan(FILLED_DOT);
866
1070
  case "done":
867
1071
  return color.green(FILLED_DOT);
868
1072
  case "failed":
869
1073
  return color.red(FILLED_DOT);
1074
+ case "blocked":
1075
+ return color.dim(FILLED_DOT);
870
1076
  }
871
1077
  }
872
1078
  __name(renderDot, "renderDot");
@@ -875,8 +1081,8 @@ var StatusDisplay = class {
875
1081
  __name(this, "StatusDisplay");
876
1082
  }
877
1083
  wave = null;
878
- progress = null;
879
- remaining = [];
1084
+ tickets = [];
1085
+ hasRun = false;
880
1086
  messages = [];
881
1087
  renderedLines = 0;
882
1088
  config;
@@ -893,20 +1099,28 @@ var StatusDisplay = class {
893
1099
  const available = termHeight - overhead;
894
1100
  return Math.max(1, available);
895
1101
  }
896
- startWave(waveNum, totalWaves, tickets, progress, remaining) {
897
- this.wave = {
898
- waveNum,
899
- totalWaves,
900
- tickets: tickets.map((t) => ({
901
- ...t,
902
- status: "pending"
903
- }))
904
- };
905
- this.remaining = (remaining ?? []).map((t) => ({
1102
+ findTicket(num, feature) {
1103
+ const found = feature ? this.tickets.find(
1104
+ (t) => t.num === num && t.feature === feature
1105
+ ) : this.tickets.find((t) => t.num === num);
1106
+ return found ?? null;
1107
+ }
1108
+ startRun(tickets) {
1109
+ this.tickets = tickets.map((t) => ({
906
1110
  ...t,
907
1111
  status: "pending"
908
1112
  }));
909
- this.progress = progress ?? null;
1113
+ this.hasRun = true;
1114
+ this.viewportStart = 0;
1115
+ }
1116
+ startWave(waveNum, totalWaves, tickets, _progress) {
1117
+ if (!this.hasRun) {
1118
+ this.startRun(tickets);
1119
+ }
1120
+ this.wave = {
1121
+ waveNum,
1122
+ totalWaves
1123
+ };
910
1124
  this.messages = [];
911
1125
  this.viewportStart = 0;
912
1126
  this.config.stream.write(ansi.cursorHide());
@@ -926,28 +1140,31 @@ var StatusDisplay = class {
926
1140
  this.render();
927
1141
  }
928
1142
  setTicketStatus(num, status, feature) {
929
- if (!this.wave) return;
930
- const ticket = feature ? this.wave.tickets.find(
931
- (t) => t.num === num && t.feature === feature
932
- ) : this.wave.tickets.find((t) => t.num === num);
933
- if (ticket) {
934
- ticket.status = status;
935
- if (status === "in-progress") {
936
- const ticketIndex = this.wave.tickets.indexOf(ticket);
937
- const availableRows = this.getAvailableRows();
938
- const totalTickets = this.wave.tickets.length;
939
- if (ticketIndex < this.viewportStart) {
940
- this.viewportStart = ticketIndex;
941
- } else if (ticketIndex >= this.viewportStart + availableRows) {
942
- this.viewportStart = ticketIndex - availableRows + 3;
943
- }
944
- this.viewportStart = Math.max(
945
- 0,
946
- Math.min(this.viewportStart, totalTickets - 1)
947
- );
1143
+ const ticket = this.findTicket(num, feature);
1144
+ if (!ticket) return;
1145
+ ticket.status = status;
1146
+ if (status === "in-progress") {
1147
+ const ticketIndex = this.tickets.indexOf(ticket);
1148
+ const availableRows = this.getAvailableRows();
1149
+ const totalTickets = this.tickets.length;
1150
+ if (ticketIndex < this.viewportStart) {
1151
+ this.viewportStart = ticketIndex;
1152
+ } else if (ticketIndex >= this.viewportStart + availableRows) {
1153
+ this.viewportStart = ticketIndex - availableRows + 3;
948
1154
  }
949
- this.render();
1155
+ this.viewportStart = Math.max(
1156
+ 0,
1157
+ Math.min(this.viewportStart, totalTickets - 1)
1158
+ );
950
1159
  }
1160
+ this.render();
1161
+ }
1162
+ setBlocked(num, feature, blockedBy) {
1163
+ const ticket = this.findTicket(num, feature);
1164
+ if (!ticket) return;
1165
+ ticket.status = "blocked";
1166
+ ticket.blockedBy = [...blockedBy];
1167
+ this.render();
951
1168
  }
952
1169
  log(message) {
953
1170
  if (!this.wave) {
@@ -974,59 +1191,52 @@ var StatusDisplay = class {
974
1191
  if (this.renderedLines > 0) {
975
1192
  output += ansi.eraseLines(this.renderedLines + 1);
976
1193
  }
977
- const doneInWave = this.wave.tickets.filter(
1194
+ const done = this.tickets.filter(
978
1195
  (t) => t.status === "done"
979
1196
  ).length;
980
- const completed = doneInWave + (this.progress?.completed ?? 0);
981
- const total = this.progress?.total ?? this.wave.tickets.length;
982
- const pct = total === 0 ? 0 : Math.round(completed / total * 100);
1197
+ const total = this.tickets.length;
1198
+ const pct = total === 0 ? 0 : Math.round(done / total * 100);
983
1199
  const width = Math.max(
984
1200
  1,
985
1201
  (this.config.stream.columns ?? DEFAULT_COLUMNS) - 1
986
1202
  );
987
- output += `Wave ${this.wave.waveNum}/${this.wave.totalWaves} - ${pct}% complete
988
- `;
1203
+ const header = `Wave ${this.wave.waveNum}/${this.wave.totalWaves} - ${done}/${total} done - ${pct}%`;
1204
+ output += fitLine(header, width) + "\n";
989
1205
  const availableRows = this.getAvailableRows();
990
- const tickets = [
991
- ...this.wave.tickets,
992
- ...this.remaining
993
- ];
994
1206
  let renderedTicketCount = 0;
995
- if (tickets.length <= availableRows) {
996
- for (const ticket of tickets) {
1207
+ if (this.tickets.length <= availableRows) {
1208
+ for (const ticket of this.tickets) {
997
1209
  const dot = renderDot(ticket.status);
998
- const name = fitLine(
999
- ticketLabel(ticket),
1000
- width - 2
1001
- );
1210
+ const text = rowText(ticket);
1211
+ const name = fitLine(text, width - 2);
1002
1212
  output += `${dot} ${name}
1003
1213
  `;
1004
1214
  }
1005
- renderedTicketCount = tickets.length;
1215
+ renderedTicketCount = this.tickets.length;
1006
1216
  } else {
1007
1217
  this.viewportStart = Math.max(
1008
1218
  0,
1009
1219
  Math.min(
1010
1220
  this.viewportStart,
1011
- tickets.length - 1
1221
+ this.tickets.length - 1
1012
1222
  )
1013
1223
  );
1014
1224
  let hasMoreAbove = this.viewportStart > 0;
1015
1225
  let tentativeCount = availableRows - (hasMoreAbove ? 1 : 0);
1016
- let hasMoreBelow = this.viewportStart + tentativeCount < tickets.length;
1226
+ let hasMoreBelow = this.viewportStart + tentativeCount < this.tickets.length;
1017
1227
  let ticketsToShow = Math.max(
1018
1228
  0,
1019
1229
  tentativeCount - (hasMoreBelow ? 1 : 0)
1020
1230
  );
1021
1231
  const maxStart = Math.max(
1022
1232
  0,
1023
- tickets.length - Math.max(1, ticketsToShow)
1233
+ this.tickets.length - Math.max(1, ticketsToShow)
1024
1234
  );
1025
1235
  if (this.viewportStart > maxStart) {
1026
1236
  this.viewportStart = maxStart;
1027
1237
  hasMoreAbove = this.viewportStart > 0;
1028
1238
  tentativeCount = availableRows - (hasMoreAbove ? 1 : 0);
1029
- hasMoreBelow = this.viewportStart + tentativeCount < tickets.length;
1239
+ hasMoreBelow = this.viewportStart + tentativeCount < this.tickets.length;
1030
1240
  ticketsToShow = Math.max(
1031
1241
  0,
1032
1242
  tentativeCount - (hasMoreBelow ? 1 : 0)
@@ -1037,21 +1247,19 @@ var StatusDisplay = class {
1037
1247
  output += ` ... ${moreCount} more above
1038
1248
  `;
1039
1249
  }
1040
- const visibleTickets = tickets.slice(
1250
+ const visibleTickets = this.tickets.slice(
1041
1251
  this.viewportStart,
1042
1252
  this.viewportStart + ticketsToShow
1043
1253
  );
1044
1254
  for (const ticket of visibleTickets) {
1045
1255
  const dot = renderDot(ticket.status);
1046
- const name = fitLine(
1047
- ticketLabel(ticket),
1048
- width - 2
1049
- );
1256
+ const text = rowText(ticket);
1257
+ const name = fitLine(text, width - 2);
1050
1258
  output += `${dot} ${name}
1051
1259
  `;
1052
1260
  }
1053
1261
  if (hasMoreBelow) {
1054
- const moreCount = tickets.length - (this.viewportStart + ticketsToShow);
1262
+ const moreCount = this.tickets.length - (this.viewportStart + ticketsToShow);
1055
1263
  output += ` ... ${moreCount} more below
1056
1264
  `;
1057
1265
  }
@@ -1083,7 +1291,9 @@ var PlainDisplay = class {
1083
1291
  static {
1084
1292
  __name(this, "PlainDisplay");
1085
1293
  }
1086
- startWave(waveNum, _totalWaves, tickets, progress, _remaining) {
1294
+ startRun(_tickets) {
1295
+ }
1296
+ startWave(waveNum, _totalWaves, tickets, progress) {
1087
1297
  const pct = progress && progress.total > 0 ? ` (${progress.completed}/${progress.total} done)` : "";
1088
1298
  console.log(
1089
1299
  `
@@ -1092,6 +1302,8 @@ Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
1092
1302
  }
1093
1303
  setTicketStatus(_num, _status, _feature) {
1094
1304
  }
1305
+ setBlocked(_num, _feature, _blockedBy) {
1306
+ }
1095
1307
  log(message) {
1096
1308
  console.log(message);
1097
1309
  }
@@ -1124,8 +1336,20 @@ function detectTestCmd(projectDir) {
1124
1336
  }
1125
1337
  }
1126
1338
  __name(detectTestCmd, "detectTestCmd");
1339
+ function detectInstallCmd(projectDir) {
1340
+ const has = /* @__PURE__ */ __name((name) => existsSync3(join3(projectDir, name)), "has");
1341
+ if (!has("package.json")) return null;
1342
+ if (has("pnpm-lock.yaml")) {
1343
+ return "pnpm install --prefer-offline --frozen-lockfile";
1344
+ }
1345
+ if (has("package-lock.json")) {
1346
+ return "npm ci --prefer-offline --no-audit --no-fund";
1347
+ }
1348
+ return "npm install --prefer-offline --no-audit --no-fund --no-package-lock";
1349
+ }
1350
+ __name(detectInstallCmd, "detectInstallCmd");
1127
1351
  function runTestCmd(opts) {
1128
- return new Promise((resolve2) => {
1352
+ return new Promise((resolve3) => {
1129
1353
  let settled = false;
1130
1354
  const child = spawn("sh", ["-c", opts.cmd], {
1131
1355
  cwd: opts.cwd,
@@ -1157,7 +1381,7 @@ function runTestCmd(opts) {
1157
1381
  opts.registry.delete(child.pid);
1158
1382
  }
1159
1383
  chunks.push(String(err));
1160
- resolve2({
1384
+ resolve3({
1161
1385
  exitCode: 1,
1162
1386
  output: chunks.join("")
1163
1387
  });
@@ -1169,7 +1393,7 @@ function runTestCmd(opts) {
1169
1393
  if (child.pid !== void 0 && opts.registry) {
1170
1394
  opts.registry.delete(child.pid);
1171
1395
  }
1172
- resolve2({
1396
+ resolve3({
1173
1397
  exitCode: code ?? 1,
1174
1398
  output: chunks.join("")
1175
1399
  });
@@ -1178,6 +1402,1061 @@ function runTestCmd(opts) {
1178
1402
  }
1179
1403
  __name(runTestCmd, "runTestCmd");
1180
1404
 
1405
+ // src/learnings.ts
1406
+ import {
1407
+ appendFileSync,
1408
+ existsSync as existsSync4,
1409
+ mkdirSync as mkdirSync2,
1410
+ readFileSync as readFileSync4,
1411
+ rmSync,
1412
+ writeFileSync as writeFileSync3
1413
+ } from "node:fs";
1414
+ import { basename as basename2, dirname as dirname2, join as join4 } from "node:path";
1415
+ var MAX_OWN_ENTRIES = 4;
1416
+ var MAX_ENTRY_CHARS = 4e3;
1417
+ var LOG_TAIL_LINES = 40;
1418
+ var NOTES_PATH = ".nightralph/notes.md";
1419
+ var SENTINEL_PREFIX = "<!-- nightralph:entry";
1420
+ var TRUNCATED_MARKER = "[truncated]";
1421
+ var LEARNING_OUTCOMES = [
1422
+ "done",
1423
+ "exit",
1424
+ "tests",
1425
+ "install",
1426
+ "no-commits",
1427
+ "error",
1428
+ "merge"
1429
+ ];
1430
+ function encodeKey(key) {
1431
+ return key.replace(/%/g, "%25").replace(/\s/g, (c) => encodeURIComponent(c));
1432
+ }
1433
+ __name(encodeKey, "encodeKey");
1434
+ function fence(content) {
1435
+ const longest = Math.max(
1436
+ 0,
1437
+ ...[...content.matchAll(/`+/g)].map((m) => m[0].length)
1438
+ );
1439
+ const ticks = "`".repeat(Math.max(3, longest + 1));
1440
+ return `${ticks}
1441
+ ${content}
1442
+ ${ticks}`;
1443
+ }
1444
+ __name(fence, "fence");
1445
+ function sanitize(text) {
1446
+ if (!text) return "";
1447
+ return text.replace(
1448
+ new RegExp(SENTINEL_PREFIX, "g"),
1449
+ "<!-- nightralph-entry"
1450
+ );
1451
+ }
1452
+ __name(sanitize, "sanitize");
1453
+ function renderAgentSummary(run, effort) {
1454
+ const effortLine = effort ? `Effort: ${effort}.` : "";
1455
+ const exitLine = `Agent exit: ${run.exitCode}.`;
1456
+ const itemsText = run.completedItems.length > 0 ? run.completedItems.map(
1457
+ (item) => `"${sanitize(item)}"`
1458
+ ).join(", ") + "." : "none.";
1459
+ const itemsLine = `Checked items: ${itemsText}`;
1460
+ return [
1461
+ effortLine,
1462
+ exitLine,
1463
+ itemsLine
1464
+ ].filter(Boolean).join(" ");
1465
+ }
1466
+ __name(renderAgentSummary, "renderAgentSummary");
1467
+ function renderNotesSection(notes) {
1468
+ const notesSanitized = sanitize(notes);
1469
+ return notesSanitized.trim() ? `### Agent notes
1470
+ ${notesSanitized}` : "The agent left no notes.";
1471
+ }
1472
+ __name(renderNotesSection, "renderNotesSection");
1473
+ function formatDate(date) {
1474
+ const year = date.getFullYear();
1475
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1476
+ const day = String(date.getDate()).padStart(2, "0");
1477
+ const hours = String(date.getHours()).padStart(2, "0");
1478
+ const minutes = String(date.getMinutes()).padStart(2, "0");
1479
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
1480
+ }
1481
+ __name(formatDate, "formatDate");
1482
+ function isLearningOutcome(value) {
1483
+ return LEARNING_OUTCOMES.some((o) => o === value);
1484
+ }
1485
+ __name(isLearningOutcome, "isLearningOutcome");
1486
+ function renderEntry(input) {
1487
+ const encodedKey = encodeKey(input.key);
1488
+ const hasRun = "run" in input;
1489
+ const timedOut = hasRun && input.run.timedOut;
1490
+ const timedOutStr = timedOut ? " timedout=true" : "";
1491
+ const sentinel = `<!-- nightralph:entry ticket=${encodedKey} attempt=${input.attempt} outcome=${input.outcome}${timedOutStr} -->`;
1492
+ const timestamp = formatDate(input.now);
1493
+ const attemptStr = input.outcome === "merge" ? "" : ` attempt ${input.attempt}`;
1494
+ let description;
1495
+ switch (input.outcome) {
1496
+ case "done":
1497
+ description = "done";
1498
+ break;
1499
+ case "exit":
1500
+ description = `failed (exit ${input.run.exitCode})`;
1501
+ break;
1502
+ case "tests":
1503
+ description = "failed (tests)";
1504
+ break;
1505
+ case "install":
1506
+ description = "failed (install)";
1507
+ break;
1508
+ case "no-commits":
1509
+ description = "no commits";
1510
+ break;
1511
+ case "error":
1512
+ description = "error";
1513
+ break;
1514
+ case "merge":
1515
+ description = "merge failed";
1516
+ break;
1517
+ default: {
1518
+ const _exhaustive = input;
1519
+ description = _exhaustive;
1520
+ }
1521
+ }
1522
+ const timedOutPrefix = timedOut ? "timed out, then " : "";
1523
+ const heading = `## ${timestamp} -- ${input.label}${attemptStr} -- ${timedOutPrefix}${description}`;
1524
+ let body;
1525
+ switch (input.outcome) {
1526
+ case "done":
1527
+ case "exit":
1528
+ case "no-commits": {
1529
+ const run = input.run;
1530
+ const summary = renderAgentSummary(run, input.effort);
1531
+ const notesSection = renderNotesSection(run.notes);
1532
+ body = summary + "\n\n" + notesSection;
1533
+ break;
1534
+ }
1535
+ case "tests": {
1536
+ const logTail = sanitize(input.logTail);
1537
+ const fenced = fence(logTail);
1538
+ const summary = renderAgentSummary(
1539
+ input.run,
1540
+ input.effort
1541
+ );
1542
+ const notesSection = renderNotesSection(input.run.notes);
1543
+ const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
1544
+ body = summary + `
1545
+
1546
+ Test gate failed.
1547
+ ${lastNLines}
1548
+ ${fenced}
1549
+
1550
+ ` + notesSection;
1551
+ break;
1552
+ }
1553
+ case "install": {
1554
+ const logTail = sanitize(input.logTail);
1555
+ const fenced = fence(logTail);
1556
+ const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
1557
+ body = `Install failed.
1558
+ ${lastNLines}
1559
+ ` + fenced;
1560
+ break;
1561
+ }
1562
+ case "error": {
1563
+ const errorText = sanitize(input.error);
1564
+ const fenced = fence(errorText);
1565
+ body = fenced;
1566
+ break;
1567
+ }
1568
+ case "merge": {
1569
+ const stderrText = sanitize(input.stderr);
1570
+ const fenced = fence(stderrText);
1571
+ body = `Merge failed.
1572
+ ${fenced}`;
1573
+ break;
1574
+ }
1575
+ default: {
1576
+ const unknown = input;
1577
+ throw new Error(`Unknown outcome: ${JSON.stringify(unknown)}`);
1578
+ }
1579
+ }
1580
+ return `${sentinel}
1581
+ ${heading}
1582
+
1583
+ ${body}
1584
+
1585
+ ---
1586
+ `;
1587
+ }
1588
+ __name(renderEntry, "renderEntry");
1589
+ function parseEntries(text) {
1590
+ const lines = text.split("\n").map((line) => {
1591
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1592
+ });
1593
+ const entries = [];
1594
+ let currentSentinel = null;
1595
+ let currentBody = [];
1596
+ const sentinelRegex = /^<!-- nightralph:entry (.*) -->$/;
1597
+ for (const line of lines) {
1598
+ const match = line.match(sentinelRegex);
1599
+ if (match) {
1600
+ if (currentSentinel !== null) {
1601
+ const entry = parseSentinel(currentSentinel, currentBody);
1602
+ if (entry) {
1603
+ entries.push(entry);
1604
+ }
1605
+ }
1606
+ currentSentinel = match[1];
1607
+ currentBody = [];
1608
+ } else if (currentSentinel !== null) {
1609
+ currentBody.push(line);
1610
+ }
1611
+ }
1612
+ if (currentSentinel !== null) {
1613
+ const entry = parseSentinel(currentSentinel, currentBody);
1614
+ if (entry) {
1615
+ entries.push(entry);
1616
+ }
1617
+ }
1618
+ return entries;
1619
+ }
1620
+ __name(parseEntries, "parseEntries");
1621
+ function parseSentinel(sentinel, bodyLines) {
1622
+ const parts = sentinel.split(/\s+/).filter(Boolean);
1623
+ const attrs = {};
1624
+ for (const part of parts) {
1625
+ const idx = part.indexOf("=");
1626
+ if (idx > 0) {
1627
+ const key2 = part.slice(0, idx);
1628
+ const value = part.slice(idx + 1);
1629
+ if (value) {
1630
+ attrs[key2] = value;
1631
+ }
1632
+ }
1633
+ }
1634
+ let key = attrs.ticket;
1635
+ if (!key) return null;
1636
+ try {
1637
+ key = decodeURIComponent(key);
1638
+ } catch (_e) {
1639
+ return null;
1640
+ }
1641
+ if (!key.trim()) return null;
1642
+ const attemptStr = attrs.attempt;
1643
+ if (!attemptStr) return null;
1644
+ if (!/^\d+$/.test(attemptStr)) return null;
1645
+ const attempt = Number(attemptStr);
1646
+ const outcome = attrs.outcome;
1647
+ if (!outcome || !isLearningOutcome(outcome)) return null;
1648
+ const timedOut = attrs.timedout === "true";
1649
+ let body = bodyLines.join("\n").trim();
1650
+ const lines = body.split("\n");
1651
+ if (lines.length > 0 && lines[lines.length - 1] === "---") {
1652
+ body = lines.slice(0, -1).join("\n").trim();
1653
+ }
1654
+ return {
1655
+ key,
1656
+ attempt,
1657
+ outcome,
1658
+ timedOut,
1659
+ body
1660
+ };
1661
+ }
1662
+ __name(parseSentinel, "parseSentinel");
1663
+ function selectHistory(entries, opts) {
1664
+ const own = entries.filter((e) => e.key === opts.key).slice(-MAX_OWN_ENTRIES);
1665
+ const blockers = opts.blockerKeys.flatMap((k) => {
1666
+ const done = entries.filter((e) => {
1667
+ return e.key === k && e.outcome === "done";
1668
+ });
1669
+ const last = done[done.length - 1];
1670
+ return last ? [last] : [];
1671
+ });
1672
+ return { own, blockers };
1673
+ }
1674
+ __name(selectHistory, "selectHistory");
1675
+ function renderHistory(selection) {
1676
+ if (selection.own.length === 0 && selection.blockers.length === 0) {
1677
+ return "";
1678
+ }
1679
+ 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.";
1680
+ const parts = [framing, ""];
1681
+ if (selection.own.length > 0) {
1682
+ parts.push("---- PREVIOUS ATTEMPTS AT THIS TICKET ----");
1683
+ parts.push(selection.own.map((e) => capEntry(e)).join("\n\n"));
1684
+ parts.push("");
1685
+ }
1686
+ if (selection.blockers.length > 0) {
1687
+ parts.push("---- WHAT THE BLOCKING TICKETS DID ----");
1688
+ parts.push(selection.blockers.map((e) => capEntry(e)).join("\n\n"));
1689
+ }
1690
+ return parts.join("\n");
1691
+ }
1692
+ __name(renderHistory, "renderHistory");
1693
+ function capEntry(entry) {
1694
+ if (entry.body.length <= MAX_ENTRY_CHARS) {
1695
+ return entry.body;
1696
+ }
1697
+ return entry.body.slice(0, MAX_ENTRY_CHARS) + "\n" + TRUNCATED_MARKER;
1698
+ }
1699
+ __name(capEntry, "capEntry");
1700
+ function tailLines(text, n) {
1701
+ let lines = text.split("\n");
1702
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
1703
+ lines = lines.slice(0, -1);
1704
+ }
1705
+ if (lines.length > n) {
1706
+ lines = lines.slice(-n);
1707
+ }
1708
+ return lines.join("\n");
1709
+ }
1710
+ __name(tailLines, "tailLines");
1711
+ function learningsPathFor(progressPath) {
1712
+ return join4(dirname2(progressPath), "learnings.md");
1713
+ }
1714
+ __name(learningsPathFor, "learningsPathFor");
1715
+ function readEntries(path) {
1716
+ if (!existsSync4(path)) return [];
1717
+ return parseEntries(readFileSync4(path, "utf8"));
1718
+ }
1719
+ __name(readEntries, "readEntries");
1720
+ function appendEntry(path, text) {
1721
+ mkdirSync2(dirname2(path), { recursive: true });
1722
+ appendFileSync(path, text);
1723
+ }
1724
+ __name(appendEntry, "appendEntry");
1725
+ function collectNotes(worktreePath, file = basename2(NOTES_PATH)) {
1726
+ const dir = join4(worktreePath, dirname2(NOTES_PATH));
1727
+ const path = join4(dir, file);
1728
+ const text = existsSync4(path) ? readFileSync4(path, "utf8").trim() : "";
1729
+ rmSync(dir, { recursive: true, force: true });
1730
+ return text || null;
1731
+ }
1732
+ __name(collectNotes, "collectNotes");
1733
+ function ensureNotesIgnored(worktreePath) {
1734
+ const dir = join4(worktreePath, dirname2(NOTES_PATH));
1735
+ mkdirSync2(dir, { recursive: true });
1736
+ writeFileSync3(join4(dir, ".gitignore"), "*\n");
1737
+ }
1738
+ __name(ensureNotesIgnored, "ensureNotesIgnored");
1739
+
1740
+ // src/rules.ts
1741
+ function readDocsRule(subject) {
1742
+ 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.`;
1743
+ }
1744
+ __name(readDocsRule, "readDocsRule");
1745
+ function installRule(installCmd) {
1746
+ if (!installCmd) return [];
1747
+ return [
1748
+ `- 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.`
1749
+ ];
1750
+ }
1751
+ __name(installRule, "installRule");
1752
+ function testGateRule(testCmd, subject) {
1753
+ if (testCmd === "") {
1754
+ 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}.`;
1755
+ }
1756
+ 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.`;
1757
+ }
1758
+ __name(testGateRule, "testGateRule");
1759
+ var LINT_RULE = "- If the project has a lint script, run it and fix what it reports.";
1760
+ function integrityRule(unit) {
1761
+ 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.`;
1762
+ }
1763
+ __name(integrityRule, "integrityRule");
1764
+ var CONFINEMENT_RULES = [
1765
+ "- Do NOT read or modify files outside your current directory (the worktree).",
1766
+ "- Do NOT run git push."
1767
+ ];
1768
+ function commitRule(unit) {
1769
+ return `- Commit after each ${unit}, and make sure everything is committed when finished, except your notes file.`;
1770
+ }
1771
+ __name(commitRule, "commitRule");
1772
+ function notesRule(unit, subject) {
1773
+ 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.`;
1774
+ }
1775
+ __name(notesRule, "notesRule");
1776
+ function agentsMdRule(subject) {
1777
+ 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.`;
1778
+ }
1779
+ __name(agentsMdRule, "agentsMdRule");
1780
+
1781
+ // src/review.ts
1782
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
1783
+ import { basename as basename4, dirname as dirname3, join as join5, relative } from "node:path";
1784
+
1785
+ // src/retry.ts
1786
+ import { basename as basename3 } from "node:path";
1787
+ var EFFORT_LADDERS = {
1788
+ pi: [
1789
+ "off",
1790
+ "minimal",
1791
+ "low",
1792
+ "medium",
1793
+ "high",
1794
+ "xhigh",
1795
+ "max"
1796
+ ],
1797
+ claude: [
1798
+ "low",
1799
+ "medium",
1800
+ "high",
1801
+ "xhigh",
1802
+ "max"
1803
+ ],
1804
+ codex: ["low", "medium", "high"]
1805
+ };
1806
+ var ALL_EFFORT_LEVELS = [
1807
+ "off",
1808
+ "minimal",
1809
+ "low",
1810
+ "medium",
1811
+ "high",
1812
+ "xhigh",
1813
+ "max"
1814
+ ];
1815
+ function escalateEffort(provider, level) {
1816
+ if (!level) return "high";
1817
+ const ladder = EFFORT_LADDERS[provider];
1818
+ const i = ladder.indexOf(level);
1819
+ if (i === -1) return level;
1820
+ return ladder[Math.min(i + 1, ladder.length - 1)];
1821
+ }
1822
+ __name(escalateEffort, "escalateEffort");
1823
+ function retryDelayFor(base, attempt) {
1824
+ return base * 2 ** (attempt - 2);
1825
+ }
1826
+ __name(retryDelayFor, "retryDelayFor");
1827
+ function sleep(ms) {
1828
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1829
+ }
1830
+ __name(sleep, "sleep");
1831
+ async function runWithRetries(opts, run) {
1832
+ const maxAttempts = Math.max(1, opts.maxAttempts);
1833
+ const providerName = basename3(opts.agentCmd);
1834
+ const isKnown = providerName in EFFORT_LADDERS;
1835
+ const wait = opts.wait ?? sleep;
1836
+ let effort = opts.effort;
1837
+ for (let attempt = 1; ; attempt++) {
1838
+ if (attempt === 1) {
1839
+ opts.log(` Starting ${opts.label}`);
1840
+ } else {
1841
+ if (isKnown) {
1842
+ effort = escalateEffort(
1843
+ providerName,
1844
+ effort
1845
+ );
1846
+ }
1847
+ const delay = retryDelayFor(opts.retryDelay, attempt);
1848
+ opts.log(
1849
+ ` ${opts.label}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1850
+ );
1851
+ if (delay > 0) await wait(delay * 1e3);
1852
+ }
1853
+ const step = await run(attempt, effort);
1854
+ if (step.stop || attempt >= maxAttempts) return step.result;
1855
+ }
1856
+ }
1857
+ __name(runWithRetries, "runWithRetries");
1858
+
1859
+ // src/review.ts
1860
+ var FINDINGS_FILE = "findings.md";
1861
+ var FINDINGS_PATH = `${dirname3(NOTES_PATH)}/${FINDINGS_FILE}`;
1862
+ var NO_FINDINGS = "No findings.";
1863
+ var REVIEW_EXCLUDE = ".scratch";
1864
+ var REVIEW_MARKER = "---- REVIEW INSTRUCTIONS ----";
1865
+ var FIX_MARKER = "---- REVIEW FINDINGS ----";
1866
+ function reviewExcludeFor(repoRoot, reviewPath) {
1867
+ let dir = dirname3(reviewPath);
1868
+ while (dirname3(dir) !== dir) {
1869
+ if (basename4(dir) === ".scratch") {
1870
+ const rel = relative(repoRoot, dir);
1871
+ if (rel && !rel.startsWith("..")) return rel;
1872
+ break;
1873
+ }
1874
+ dir = dirname3(dir);
1875
+ }
1876
+ return REVIEW_EXCLUDE;
1877
+ }
1878
+ __name(reviewExcludeFor, "reviewExcludeFor");
1879
+ function reviewDiffCommand(from, to, exclude = REVIEW_EXCLUDE) {
1880
+ return `git diff ${from}..${to} -- . ':(exclude)${exclude}'`;
1881
+ }
1882
+ __name(reviewDiffCommand, "reviewDiffCommand");
1883
+ function renderReviewPrompt(opts) {
1884
+ return [
1885
+ "---- SPEC CONTEXT ----",
1886
+ opts.specBody,
1887
+ "",
1888
+ REVIEW_MARKER,
1889
+ "You are reviewing work that autonomous agents merged into this branch, one ticket at a time. Review the combined change as a whole.",
1890
+ "",
1891
+ "The change to review:",
1892
+ "",
1893
+ ` ${reviewDiffCommand(opts.from, opts.to, opts.exclude)}`,
1894
+ "",
1895
+ "Follow these rules:",
1896
+ "- Read any AGENTS.md in or above the directories the change touches, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. Treat their contracts and the spec above as the standard the code must meet.",
1897
+ "- Look for correctness bugs, missing or weak tests, and code that breaks a documented contract. Ignore style preferences.",
1898
+ "- You may run the tests or the type checker to confirm a suspicion.",
1899
+ "- Do NOT edit, stage, or commit code. Anything you change here is discarded.",
1900
+ ...CONFINEMENT_RULES,
1901
+ "",
1902
+ `Write your findings to \`${FINDINGS_PATH}\` (create the directory), most severe first, in exactly this form:`,
1903
+ "",
1904
+ "- [ ] <one-line summary of the finding>",
1905
+ " <file:line, and a concrete input or state that goes wrong>",
1906
+ "",
1907
+ "If you find nothing worth fixing, write the single line: " + NO_FINDINGS,
1908
+ "Never git add or commit that file; the orchestrator collects it.",
1909
+ ""
1910
+ ].join("\n");
1911
+ }
1912
+ __name(renderReviewPrompt, "renderReviewPrompt");
1913
+ var FIX_FAILURE_TEXT = {
1914
+ install: "dependency install failed",
1915
+ exit: "the agent exited with an error",
1916
+ "no-commits": "the agent made no commits",
1917
+ tests: "the test command failed after the agent finished",
1918
+ merge: "the fix branch did not merge cleanly"
1919
+ };
1920
+ function findingLines(findings) {
1921
+ return findings.flatMap((f) => [
1922
+ `- [ ] ${f.summary}`,
1923
+ ...f.detail ? f.detail.split("\n").map((l) => ` ${l}`) : []
1924
+ ]);
1925
+ }
1926
+ __name(findingLines, "findingLines");
1927
+ function previousBlock(previous) {
1928
+ if (!previous) return [];
1929
+ return [
1930
+ "---- PREVIOUS FIX ATTEMPT ----",
1931
+ `It failed: ${FIX_FAILURE_TEXT[previous.reason]}.`,
1932
+ previous.notes ?? "It left no notes.",
1933
+ ""
1934
+ ];
1935
+ }
1936
+ __name(previousBlock, "previousBlock");
1937
+ function renderFixPrompt(opts) {
1938
+ return [
1939
+ "---- SPEC CONTEXT ----",
1940
+ opts.specBody,
1941
+ "",
1942
+ FIX_MARKER,
1943
+ ...findingLines(opts.findings),
1944
+ "",
1945
+ ...previousBlock(opts.previous),
1946
+ "---- AGENT INSTRUCTIONS ----",
1947
+ "You are an autonomous coding agent. Fix the review findings above using /tdd.",
1948
+ "",
1949
+ "Follow these rules:",
1950
+ readDocsRule("findings"),
1951
+ "- Fix ONLY the listed findings. Do not fix unrelated problems you notice.",
1952
+ ...installRule(opts.installCmd),
1953
+ "- Use /tdd for every finding: write a failing test that shows the problem, then write only enough code to make it pass, then refactor.",
1954
+ testGateRule(opts.testCmd, "fix"),
1955
+ LINT_RULE,
1956
+ integrityRule("finding"),
1957
+ ...CONFINEMENT_RULES,
1958
+ commitRule("finding"),
1959
+ notesRule("finding", "fix"),
1960
+ "- After fixing each finding, print a line: [x] <finding summary> (matching the summary text exactly).",
1961
+ "- If a finding is wrong or cannot be fixed honestly, leave it unchecked and explain why in your notes file.",
1962
+ agentsMdRule("fix"),
1963
+ ""
1964
+ ].join("\n");
1965
+ }
1966
+ __name(renderFixPrompt, "renderFixPrompt");
1967
+ var SENTINEL_PREFIX2 = "<!-- nightralph:review";
1968
+ var SENTINEL_RE = /^<!-- nightralph:review (.*) -->$/;
1969
+ var SHA_RE = /^[0-9a-f]{4,64}$/;
1970
+ var FIX_HEADING = "### Fix";
1971
+ var FINDING_RE = /^ ?[-*+]\s+\[([ xX])\]\s+(.+)$/;
1972
+ var REVIEW_STATUSES = [
1973
+ "clean",
1974
+ "fixed",
1975
+ "unresolved",
1976
+ "review-failed"
1977
+ ];
1978
+ function isReviewStatus(value) {
1979
+ return REVIEW_STATUSES.includes(value);
1980
+ }
1981
+ __name(isReviewStatus, "isReviewStatus");
1982
+ function collapse(s) {
1983
+ return s.replace(/\s+/g, " ").trim();
1984
+ }
1985
+ __name(collapse, "collapse");
1986
+ function normalize(s) {
1987
+ return collapse(s).toLowerCase();
1988
+ }
1989
+ __name(normalize, "normalize");
1990
+ function sanitize2(text) {
1991
+ return text.replace(
1992
+ new RegExp(SENTINEL_PREFIX2, "g"),
1993
+ "<!-- nightralph-review"
1994
+ );
1995
+ }
1996
+ __name(sanitize2, "sanitize");
1997
+ function splitLines(text) {
1998
+ return text.split("\n").map((line) => {
1999
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
2000
+ });
2001
+ }
2002
+ __name(splitLines, "splitLines");
2003
+ function parseFindings(text) {
2004
+ const findings = [];
2005
+ const details = [];
2006
+ for (const line of splitLines(text)) {
2007
+ const m = line.match(FINDING_RE);
2008
+ if (m) {
2009
+ findings.push({
2010
+ summary: collapse(m[2]),
2011
+ detail: "",
2012
+ checked: m[1] !== " "
2013
+ });
2014
+ details.push([]);
2015
+ } else if (findings.length > 0 && line.trim()) {
2016
+ details[details.length - 1].push(line.trim());
2017
+ }
2018
+ }
2019
+ if (findings.length > 0) {
2020
+ return findings.map((f, i) => {
2021
+ return { ...f, detail: details[i].join("\n") };
2022
+ });
2023
+ }
2024
+ const clean = splitLines(text).some((line) => {
2025
+ return normalize(line) === NO_FINDINGS.toLowerCase();
2026
+ });
2027
+ return clean ? [] : null;
2028
+ }
2029
+ __name(parseFindings, "parseFindings");
2030
+ function markFixed(findings, completedItems) {
2031
+ const done = new Set(completedItems.map(normalize));
2032
+ return findings.map((f) => {
2033
+ return { ...f, checked: done.has(normalize(f.summary)) };
2034
+ });
2035
+ }
2036
+ __name(markFixed, "markFixed");
2037
+ function headline(input) {
2038
+ const n = input.findings.length;
2039
+ const fixed = input.findings.filter((f) => f.checked).length;
2040
+ switch (input.status) {
2041
+ case "clean":
2042
+ return "no findings";
2043
+ case "fixed":
2044
+ return `${fixed} of ${n} fixed`;
2045
+ case "unresolved":
2046
+ return `${n} finding${n === 1 ? "" : "s"}, fix failed`;
2047
+ case "review-failed":
2048
+ return "review failed";
2049
+ default: {
2050
+ const _exhaustive = input.status;
2051
+ return _exhaustive;
2052
+ }
2053
+ }
2054
+ }
2055
+ __name(headline, "headline");
2056
+ function renderFindings(input) {
2057
+ if (input.status === "review-failed") {
2058
+ return ["The reviewer left no usable findings file."];
2059
+ }
2060
+ if (input.findings.length === 0) return [NO_FINDINGS];
2061
+ return input.findings.flatMap((f) => {
2062
+ const box = f.checked ? "[x]" : "[ ]";
2063
+ const detail = f.detail ? f.detail.split("\n").map((l) => ` ${sanitize2(l)}`) : [];
2064
+ return [`- ${box} ${sanitize2(collapse(f.summary))}`, ...detail];
2065
+ });
2066
+ }
2067
+ __name(renderFindings, "renderFindings");
2068
+ function renderFix(fix) {
2069
+ if (!fix) return [];
2070
+ return [
2071
+ "",
2072
+ FIX_HEADING,
2073
+ `- attempts: ${fix.attempts} of ${fix.maxAttempts}`,
2074
+ ...fix.effort ? [`- effort: ${fix.effort}`] : [],
2075
+ `- agent exit code: ${fix.exitCode ?? "none"}`,
2076
+ `- result: ${sanitize2(fix.result)}`,
2077
+ "",
2078
+ fix.notes ? sanitize2(fix.notes) : "The fixer left no notes."
2079
+ ];
2080
+ }
2081
+ __name(renderFix, "renderFix");
2082
+ function renderReviewEntry(input) {
2083
+ return [
2084
+ `${SENTINEL_PREFIX2} from=${input.from} to=${input.to} status=${input.status} -->`,
2085
+ `## ${formatDate(input.now)} -- review ${input.from.slice(0, 7)}..${input.to.slice(0, 7)} -- ${headline(input)}`,
2086
+ ...renderFindings(input),
2087
+ ...renderFix(input.fix),
2088
+ "---",
2089
+ ""
2090
+ ].join("\n");
2091
+ }
2092
+ __name(renderReviewEntry, "renderReviewEntry");
2093
+ function parseEntry(sentinel, bodyLines) {
2094
+ const attrs = {};
2095
+ for (const part of sentinel.split(/\s+/).filter(Boolean)) {
2096
+ const idx = part.indexOf("=");
2097
+ if (idx > 0) attrs[part.slice(0, idx)] = part.slice(idx + 1);
2098
+ }
2099
+ const { from, to, status } = attrs;
2100
+ if (!from || !SHA_RE.test(from)) return null;
2101
+ if (!to || !SHA_RE.test(to)) return null;
2102
+ if (!status || !isReviewStatus(status)) return null;
2103
+ const lines = [...bodyLines];
2104
+ while (lines.length > 0 && !lines[lines.length - 1].trim()) {
2105
+ lines.pop();
2106
+ }
2107
+ if (lines[lines.length - 1] === "---") lines.pop();
2108
+ const fixAt = lines.indexOf(FIX_HEADING);
2109
+ const findingLines2 = fixAt === -1 ? lines : lines.slice(0, fixAt);
2110
+ return {
2111
+ from,
2112
+ to,
2113
+ status,
2114
+ findings: parseFindings(findingLines2.join("\n")) ?? [],
2115
+ body: lines.join("\n").trim()
2116
+ };
2117
+ }
2118
+ __name(parseEntry, "parseEntry");
2119
+ function parseReviewEntries(text) {
2120
+ const entries = [];
2121
+ let sentinel = null;
2122
+ let body = [];
2123
+ const flush = /* @__PURE__ */ __name(() => {
2124
+ if (sentinel === null) return;
2125
+ const entry = parseEntry(sentinel, body);
2126
+ if (entry) entries.push(entry);
2127
+ }, "flush");
2128
+ for (const line of splitLines(text)) {
2129
+ const m = line.match(SENTINEL_RE);
2130
+ if (m) {
2131
+ flush();
2132
+ sentinel = m[1];
2133
+ body = [];
2134
+ } else if (sentinel !== null) {
2135
+ body.push(line);
2136
+ }
2137
+ }
2138
+ flush();
2139
+ return entries;
2140
+ }
2141
+ __name(parseReviewEntries, "parseReviewEntries");
2142
+ function selectReviewBase(entries, opts) {
2143
+ const last = [...entries].reverse().find((e) => {
2144
+ return e.status !== "review-failed";
2145
+ });
2146
+ if (last && opts.isAncestor(last.to)) return last.to;
2147
+ return opts.forkPoint ?? opts.launchCommit;
2148
+ }
2149
+ __name(selectReviewBase, "selectReviewBase");
2150
+ function renderReviewSummary(outcome, reviewPath) {
2151
+ const see = ` -- see ${reviewPath}`;
2152
+ const plural = outcome.findings === 1 ? "" : "s";
2153
+ switch (outcome.status) {
2154
+ case "skipped":
2155
+ return null;
2156
+ case "clean":
2157
+ return `Review: no findings${see}`;
2158
+ case "fixed":
2159
+ return `Review: ${outcome.findings} finding${plural}, ${outcome.fixed} fixed${see}`;
2160
+ case "unresolved":
2161
+ return `Review: ${outcome.findings} finding${plural}, fix failed${see}`;
2162
+ case "review-failed":
2163
+ return `Review: the reviewer failed${see}`;
2164
+ default: {
2165
+ const _exhaustive = outcome.status;
2166
+ return _exhaustive;
2167
+ }
2168
+ }
2169
+ }
2170
+ __name(renderReviewSummary, "renderReviewSummary");
2171
+ function logPath(logsDir, stem, attempt, ext) {
2172
+ const suffix = attempt > 1 ? `.attempt${attempt}` : "";
2173
+ return join5(logsDir, `${stem}${suffix}${ext}`);
2174
+ }
2175
+ __name(logPath, "logPath");
2176
+ async function runStep(opts, deps, cmd, cwd, path) {
2177
+ const result = await runTestCmd({
2178
+ cmd,
2179
+ cwd,
2180
+ timeout: opts.timeout,
2181
+ registry: deps.registry
2182
+ });
2183
+ writeFileSync4(path, result.output);
2184
+ return result.exitCode === 0;
2185
+ }
2186
+ __name(runStep, "runStep");
2187
+ async function discardWorktree(opts, wt) {
2188
+ try {
2189
+ await removeWorktree(wt.worktreePath, { force: true });
2190
+ await deleteBranch(opts.repoRoot, wt.branchName);
2191
+ } catch (err) {
2192
+ opts.log(
2193
+ ` Failed to remove ${wt.worktreePath}: ` + (err instanceof Error ? err.message : String(err))
2194
+ );
2195
+ }
2196
+ }
2197
+ __name(discardWorktree, "discardWorktree");
2198
+ async function runReviewer(opts, deps, range) {
2199
+ return runWithRetries({
2200
+ maxAttempts: opts.retries + 1,
2201
+ retryDelay: opts.retryDelay,
2202
+ agentCmd: opts.agentCmd,
2203
+ effort: opts.effort,
2204
+ label: "review",
2205
+ log: opts.log
2206
+ }, async (attempt, effort) => {
2207
+ const wt = await createNamedWorktree({
2208
+ repoRoot: opts.repoRoot,
2209
+ repoName: opts.repoName,
2210
+ baseBranch: opts.baseBranch,
2211
+ name: "review"
2212
+ });
2213
+ try {
2214
+ if (opts.installCmd && !await runStep(
2215
+ opts,
2216
+ deps,
2217
+ opts.installCmd,
2218
+ wt.worktreePath,
2219
+ logPath(opts.logsDir, "review", attempt, ".install.log")
2220
+ )) {
2221
+ opts.log(" review: install failed");
2222
+ return { result: null, stop: false };
2223
+ }
2224
+ ensureNotesIgnored(wt.worktreePath);
2225
+ await deps.spawnAgent({
2226
+ prompt: renderReviewPrompt({
2227
+ specBody: opts.specBody,
2228
+ ...range,
2229
+ exclude: reviewExcludeFor(
2230
+ opts.repoRoot,
2231
+ opts.reviewPath
2232
+ )
2233
+ }),
2234
+ agentCmd: opts.agentCmd,
2235
+ model: opts.model,
2236
+ timeout: opts.timeout,
2237
+ logPath: logPath(opts.logsDir, "review", attempt, ".log"),
2238
+ cwd: wt.worktreePath,
2239
+ label: "review",
2240
+ maxTurns: opts.maxTurns,
2241
+ effort,
2242
+ onLine: opts.log
2243
+ });
2244
+ const text = collectNotes(wt.worktreePath, FINDINGS_FILE);
2245
+ const findings = text === null ? null : parseFindings(text);
2246
+ if (findings === null) {
2247
+ opts.log(" review: no usable findings file");
2248
+ }
2249
+ return { result: findings, stop: findings !== null };
2250
+ } finally {
2251
+ await discardWorktree(opts, wt);
2252
+ }
2253
+ });
2254
+ }
2255
+ __name(runReviewer, "runReviewer");
2256
+ async function runFixer(opts, deps, findings) {
2257
+ const unchecked = findings.map((f) => ({ ...f, checked: false }));
2258
+ let previous;
2259
+ return runWithRetries({
2260
+ maxAttempts: opts.retries + 1,
2261
+ retryDelay: opts.retryDelay,
2262
+ agentCmd: opts.agentCmd,
2263
+ effort: opts.effort,
2264
+ label: "review fix",
2265
+ log: opts.log
2266
+ }, async (attempt, effort) => {
2267
+ const fail = /* @__PURE__ */ __name((reason, exitCode, notes) => {
2268
+ previous = { reason, notes };
2269
+ opts.log(` review fix: ${FIX_FAILURE_TEXT[reason]}`);
2270
+ return {
2271
+ result: {
2272
+ merged: false,
2273
+ findings: unchecked,
2274
+ attempts: attempt,
2275
+ effort,
2276
+ exitCode,
2277
+ result: FIX_FAILURE_TEXT[reason],
2278
+ notes
2279
+ },
2280
+ stop: false
2281
+ };
2282
+ }, "fail");
2283
+ const wt = await createNamedWorktree({
2284
+ repoRoot: opts.repoRoot,
2285
+ repoName: opts.repoName,
2286
+ baseBranch: opts.baseBranch,
2287
+ name: "review-fix"
2288
+ });
2289
+ try {
2290
+ if (opts.installCmd && !await runStep(
2291
+ opts,
2292
+ deps,
2293
+ opts.installCmd,
2294
+ wt.worktreePath,
2295
+ logPath(
2296
+ opts.logsDir,
2297
+ "review-fix",
2298
+ attempt,
2299
+ ".install.log"
2300
+ )
2301
+ )) {
2302
+ return fail("install", null, null);
2303
+ }
2304
+ ensureNotesIgnored(wt.worktreePath);
2305
+ const agent = await deps.spawnAgent({
2306
+ prompt: renderFixPrompt({
2307
+ specBody: opts.specBody,
2308
+ findings,
2309
+ testCmd: opts.testCmd,
2310
+ installCmd: opts.installCmd,
2311
+ previous
2312
+ }),
2313
+ agentCmd: opts.agentCmd,
2314
+ model: opts.model,
2315
+ timeout: opts.timeout,
2316
+ logPath: logPath(
2317
+ opts.logsDir,
2318
+ "review-fix",
2319
+ attempt,
2320
+ ".log"
2321
+ ),
2322
+ cwd: wt.worktreePath,
2323
+ label: "review-fix",
2324
+ maxTurns: opts.maxTurns,
2325
+ effort,
2326
+ onLine: opts.log
2327
+ });
2328
+ const notes = collectNotes(wt.worktreePath);
2329
+ if (!agent.timedOut && agent.exitCode !== 0) {
2330
+ return fail("exit", agent.exitCode, notes);
2331
+ }
2332
+ if (isDirty(wt.worktreePath)) {
2333
+ await commitDirty(
2334
+ wt.worktreePath,
2335
+ "nightralph: auto-commit review fix remaining changes"
2336
+ );
2337
+ }
2338
+ if (!hasNewCommits(
2339
+ opts.repoRoot,
2340
+ opts.baseBranch,
2341
+ wt.branchName
2342
+ )) {
2343
+ return fail("no-commits", agent.exitCode, notes);
2344
+ }
2345
+ if (opts.testCmd === "") {
2346
+ opts.log(" review fix: no test gate");
2347
+ } else if (!await runStep(
2348
+ opts,
2349
+ deps,
2350
+ opts.testCmd,
2351
+ wt.worktreePath,
2352
+ logPath(
2353
+ opts.logsDir,
2354
+ "review-fix",
2355
+ attempt,
2356
+ ".test.log"
2357
+ )
2358
+ )) {
2359
+ return fail("tests", agent.exitCode, notes);
2360
+ }
2361
+ const merged = await mergeBranch(
2362
+ opts.repoRoot,
2363
+ wt.branchName
2364
+ );
2365
+ if (!merged.success) {
2366
+ return fail(
2367
+ "merge",
2368
+ agent.exitCode,
2369
+ [notes, merged.stderr].filter(Boolean).join("\n\n") || null
2370
+ );
2371
+ }
2372
+ opts.log(" review fix: merged");
2373
+ return {
2374
+ result: {
2375
+ merged: true,
2376
+ findings: markFixed(findings, agent.completedItems),
2377
+ attempts: attempt,
2378
+ effort,
2379
+ exitCode: agent.exitCode,
2380
+ result: "merged",
2381
+ notes
2382
+ },
2383
+ stop: true
2384
+ };
2385
+ } finally {
2386
+ await discardWorktree(opts, wt);
2387
+ }
2388
+ });
2389
+ }
2390
+ __name(runFixer, "runFixer");
2391
+ function readReviewEntries(path) {
2392
+ if (!existsSync5(path)) return [];
2393
+ return parseReviewEntries(readFileSync5(path, "utf8"));
2394
+ }
2395
+ __name(readReviewEntries, "readReviewEntries");
2396
+ async function runReviewPass(opts, deps) {
2397
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
2398
+ const to = headCommit(opts.repoRoot);
2399
+ const forkPoint = opts.forkedFrom ? mergeBase(opts.repoRoot, opts.forkedFrom, to) : null;
2400
+ const from = selectReviewBase(readReviewEntries(opts.reviewPath), {
2401
+ isAncestor: /* @__PURE__ */ __name((sha) => isAncestor(opts.repoRoot, sha, to), "isAncestor"),
2402
+ forkPoint,
2403
+ launchCommit: opts.launchCommit
2404
+ });
2405
+ const exclude = reviewExcludeFor(opts.repoRoot, opts.reviewPath);
2406
+ if (!hasChangesOutside(opts.repoRoot, from, to, exclude)) {
2407
+ opts.log("\nReview: nothing new to review");
2408
+ return { status: "skipped", findings: 0, fixed: 0 };
2409
+ }
2410
+ opts.log(`
2411
+ Reviewing ${from.slice(0, 7)}..${to.slice(0, 7)}`);
2412
+ const findings = await runReviewer(opts, deps, { from, to });
2413
+ let entry;
2414
+ if (findings === null) {
2415
+ entry = {
2416
+ from,
2417
+ to,
2418
+ status: "review-failed",
2419
+ now: now(),
2420
+ findings: []
2421
+ };
2422
+ } else if (findings.length === 0) {
2423
+ entry = { from, to, status: "clean", now: now(), findings: [] };
2424
+ } else {
2425
+ opts.log(
2426
+ `Review: ${findings.length} finding${findings.length === 1 ? "" : "s"}`
2427
+ );
2428
+ const fix = await runFixer(opts, deps, findings);
2429
+ entry = {
2430
+ from,
2431
+ // A merged fix counts as reviewed
2432
+ to: fix.merged ? headCommit(opts.repoRoot) : to,
2433
+ status: fix.merged ? "fixed" : "unresolved",
2434
+ now: now(),
2435
+ findings: fix.findings,
2436
+ fix: {
2437
+ attempts: fix.attempts,
2438
+ maxAttempts: opts.retries + 1,
2439
+ effort: fix.effort,
2440
+ exitCode: fix.exitCode,
2441
+ result: fix.result,
2442
+ notes: fix.notes
2443
+ }
2444
+ };
2445
+ }
2446
+ appendEntry(opts.reviewPath, renderReviewEntry(entry));
2447
+ await commitProgress(
2448
+ opts.repoRoot,
2449
+ opts.reviewPath,
2450
+ "nightralph: record review"
2451
+ );
2452
+ return {
2453
+ status: entry.status,
2454
+ findings: entry.findings.length,
2455
+ fixed: entry.findings.filter((f) => f.checked).length
2456
+ };
2457
+ }
2458
+ __name(runReviewPass, "runReviewPass");
2459
+
1181
2460
  // src/orchestrator.ts
1182
2461
  var execFileAsync4 = promisify4(execFile4);
1183
2462
  function formatErrorMessage(error) {
@@ -1270,8 +2549,8 @@ function scanTickets(dir, feature = "") {
1270
2549
  ));
1271
2550
  return files.map((filename) => {
1272
2551
  const parsed = parseTicketFilename(filename);
1273
- const filepath = join4(dir, filename);
1274
- const body = readFileSync4(filepath, "utf8");
2552
+ const filepath = join6(dir, filename);
2553
+ const body = readFileSync6(filepath, "utf8");
1275
2554
  return {
1276
2555
  num: parsed.num,
1277
2556
  slug: parsed.slug,
@@ -1301,6 +2580,37 @@ function findReadyTickets(tickets) {
1301
2580
  });
1302
2581
  }
1303
2582
  __name(findReadyTickets, "findReadyTickets");
2583
+ function findBlockedTickets(tickets, failedKeys) {
2584
+ const byKey = new Map(
2585
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
2586
+ );
2587
+ function collectDead(ticket, visited, found) {
2588
+ for (const b of ticket.blockers) {
2589
+ const key = ticketKey(ticket.feature, b);
2590
+ if (visited.has(key)) continue;
2591
+ visited.add(key);
2592
+ const blocker = byKey.get(key);
2593
+ if (!blocker || blocker.status === "done") continue;
2594
+ if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
2595
+ found.add(key);
2596
+ continue;
2597
+ }
2598
+ collectDead(blocker, visited, found);
2599
+ }
2600
+ }
2601
+ __name(collectDead, "collectDead");
2602
+ const blocked = /* @__PURE__ */ new Map();
2603
+ for (const t of tickets) {
2604
+ const key = ticketKey(t.feature, t.num);
2605
+ if (t.status !== "ready-for-agent") continue;
2606
+ if (failedKeys.has(key)) continue;
2607
+ const found = /* @__PURE__ */ new Set();
2608
+ collectDead(t, /* @__PURE__ */ new Set([key]), found);
2609
+ if (found.size > 0) blocked.set(key, [...found]);
2610
+ }
2611
+ return blocked;
2612
+ }
2613
+ __name(findBlockedTickets, "findBlockedTickets");
1304
2614
  var COMPLETED_RE = /^\s*[-*+]?\s*\[x\]\s+(.+)$/i;
1305
2615
  var TOOL_USE_RE = /^\[[\w.]+\]/;
1306
2616
  var CHECKBOX_RE = /^(\s*[-*+] )\[( |x|X)\]( .*)$/;
@@ -1351,7 +2661,7 @@ function checkItems(ticket, completedItems) {
1351
2661
  if (completedItems.length === 0) return;
1352
2662
  let body;
1353
2663
  try {
1354
- body = readFileSync4(ticket.filepath, "utf8");
2664
+ body = readFileSync6(ticket.filepath, "utf8");
1355
2665
  } catch {
1356
2666
  body = ticket.body;
1357
2667
  }
@@ -1375,7 +2685,7 @@ function checkItems(ticket, completedItems) {
1375
2685
  }
1376
2686
  if (!changed) return;
1377
2687
  body = lines.join("\n");
1378
- writeFileSync3(ticket.filepath, body);
2688
+ writeFileSync5(ticket.filepath, body);
1379
2689
  ticket.body = body;
1380
2690
  }
1381
2691
  __name(checkItems, "checkItems");
@@ -1399,7 +2709,7 @@ __name(checkAllItems, "checkAllItems");
1399
2709
  function markDone(ticket) {
1400
2710
  let body;
1401
2711
  try {
1402
- body = readFileSync4(ticket.filepath, "utf8");
2712
+ body = readFileSync6(ticket.filepath, "utf8");
1403
2713
  } catch {
1404
2714
  body = ticket.body;
1405
2715
  }
@@ -1408,7 +2718,7 @@ function markDone(ticket) {
1408
2718
  /\*\*Status:\*\*\s*.+/i,
1409
2719
  "**Status:** done"
1410
2720
  );
1411
- writeFileSync3(ticket.filepath, updated);
2721
+ writeFileSync5(ticket.filepath, updated);
1412
2722
  ticket.status = "done";
1413
2723
  ticket.body = updated;
1414
2724
  }
@@ -1441,11 +2751,23 @@ function stripSpecSection(specBody, heading) {
1441
2751
  return out.join("\n");
1442
2752
  }
1443
2753
  __name(stripSpecSection, "stripSpecSection");
2754
+ function reviewSpecBody(specBody, specBodies) {
2755
+ if (!specBodies || specBodies.size <= 1) return specBody;
2756
+ return [...specBodies].map(([name, body]) => `# Feature: ${name}
2757
+
2758
+ ${body}`).join("\n\n");
2759
+ }
2760
+ __name(reviewSpecBody, "reviewSpecBody");
1444
2761
  function lookupSpec(specBodies, fallback, feature) {
1445
2762
  if (!feature || !specBodies) return fallback;
1446
2763
  return specBodies.get(feature) ?? fallback;
1447
2764
  }
1448
2765
  __name(lookupSpec, "lookupSpec");
2766
+ function historyBlock(history) {
2767
+ if (!history) return [];
2768
+ return [history, ""];
2769
+ }
2770
+ __name(historyBlock, "historyBlock");
1449
2771
  function renderPrompt(specBody, ticket, opts) {
1450
2772
  return [
1451
2773
  "---- SPEC CONTEXT ----",
@@ -1455,27 +2777,29 @@ function renderPrompt(specBody, ticket, opts) {
1455
2777
  ticket.filename,
1456
2778
  ticket.body,
1457
2779
  "",
2780
+ ...historyBlock(opts.history),
1458
2781
  "---- AGENT INSTRUCTIONS ----",
1459
2782
  "You are an autonomous coding agent. Implement the ticket using /tdd.",
1460
2783
  "",
1461
2784
  "Follow these rules:",
1462
- "- 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.",
2785
+ readDocsRule("ticket"),
1463
2786
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
1464
2787
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
1465
- "- 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.",
1466
- `- 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.`,
1467
- "- If the project has a lint script, run it and fix what it reports.",
1468
- "- 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.",
1469
- "- Do NOT read or modify files outside your current directory (the worktree).",
1470
- "- Do NOT run git push.",
1471
- "- Commit after each checklist item, and make sure everything is committed when finished.",
2788
+ ...installRule(opts.installCmd),
2789
+ "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor. For a checklist item that is pure scaffolding, config, or docs (where a test adds no value), skip test-first and record that decision in your notes file.",
2790
+ testGateRule(opts.testCmd, "ticket"),
2791
+ LINT_RULE,
2792
+ integrityRule("checklist item"),
2793
+ ...CONFINEMENT_RULES,
2794
+ commitRule("checklist item"),
2795
+ notesRule("checklist item", "ticket"),
1472
2796
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
1473
- "- 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.",
2797
+ agentsMdRule("ticket"),
1474
2798
  ""
1475
2799
  ].join("\n");
1476
2800
  }
1477
2801
  __name(renderPrompt, "renderPrompt");
1478
- function renderMergePrompt(specBody, ticket) {
2802
+ function renderMergePrompt(specBody, ticket, opts = {}) {
1479
2803
  return [
1480
2804
  "---- SPEC CONTEXT ----",
1481
2805
  specBody,
@@ -1496,6 +2820,7 @@ function renderMergePrompt(specBody, ticket) {
1496
2820
  "4. Commit the resolved files.",
1497
2821
  "",
1498
2822
  "Do NOT re-implement the ticket from scratch.",
2823
+ ...installRule(opts.installCmd),
1499
2824
  "Do NOT read or modify files outside your current directory (the worktree).",
1500
2825
  "Do NOT run git push."
1501
2826
  ].join("\n");
@@ -1603,48 +2928,12 @@ var PROVIDER_ARGS = {
1603
2928
  "--mode",
1604
2929
  "json"
1605
2930
  ]
1606
- };
1607
- function getProviderArgs(cmd) {
1608
- const name = basename2(cmd);
1609
- return PROVIDER_ARGS[name] ?? [];
1610
- }
1611
- __name(getProviderArgs, "getProviderArgs");
1612
- var EFFORT_LADDERS = {
1613
- pi: [
1614
- "off",
1615
- "minimal",
1616
- "low",
1617
- "medium",
1618
- "high",
1619
- "xhigh",
1620
- "max"
1621
- ],
1622
- claude: [
1623
- "low",
1624
- "medium",
1625
- "high",
1626
- "xhigh",
1627
- "max"
1628
- ],
1629
- codex: ["low", "medium", "high"]
1630
- };
1631
- var ALL_EFFORT_LEVELS = [
1632
- "off",
1633
- "minimal",
1634
- "low",
1635
- "medium",
1636
- "high",
1637
- "xhigh",
1638
- "max"
1639
- ];
1640
- function escalateEffort(provider, level) {
1641
- if (!level) return "high";
1642
- const ladder = EFFORT_LADDERS[provider];
1643
- const i = ladder.indexOf(level);
1644
- if (i === -1) return level;
1645
- return ladder[Math.min(i + 1, ladder.length - 1)];
2931
+ };
2932
+ function getProviderArgs(cmd) {
2933
+ const name = basename5(cmd);
2934
+ return PROVIDER_ARGS[name] ?? [];
1646
2935
  }
1647
- __name(escalateEffort, "escalateEffort");
2936
+ __name(getProviderArgs, "getProviderArgs");
1648
2937
  function formatStreamLine(line) {
1649
2938
  let obj;
1650
2939
  try {
@@ -1745,8 +3034,8 @@ function createPiStreamFormatter() {
1745
3034
  __name(createPiStreamFormatter, "createPiStreamFormatter");
1746
3035
  function writePromptFile(prompt) {
1747
3036
  const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
1748
- const filePath = join4(tmpdir(), name);
1749
- writeFileSync3(filePath, prompt);
3037
+ const filePath = join6(tmpdir(), name);
3038
+ writeFileSync5(filePath, prompt);
1750
3039
  return filePath;
1751
3040
  }
1752
3041
  __name(writePromptFile, "writePromptFile");
@@ -1787,7 +3076,7 @@ function spawnAgent(opts) {
1787
3076
  if (opts.model) {
1788
3077
  args.push("--model", opts.model);
1789
3078
  }
1790
- const providerName = basename2(opts.agentCmd);
3079
+ const providerName = basename5(opts.agentCmd);
1791
3080
  const isClaude = providerName === "claude";
1792
3081
  const isPi = providerName === "pi";
1793
3082
  if (opts.maxTurns) {
@@ -1837,7 +3126,7 @@ function spawnAgent(opts) {
1837
3126
  detached: true
1838
3127
  });
1839
3128
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
1840
- proc.stdout.pipe(logStream);
3129
+ proc.stdout.pipe(logStream, { end: false });
1841
3130
  const piFormatter = isPi ? createPiStreamFormatter() : null;
1842
3131
  const completedItems = [];
1843
3132
  const seenItems = /* @__PURE__ */ new Set();
@@ -1886,7 +3175,7 @@ function spawnAgent(opts) {
1886
3175
  }
1887
3176
  }
1888
3177
  });
1889
- proc.stderr.pipe(logStream);
3178
+ proc.stderr.pipe(logStream, { end: false });
1890
3179
  const rlErr = createInterface({ input: proc.stderr });
1891
3180
  rlErr.on("line", (line) => {
1892
3181
  if (isClaude && !TOOL_USE_RE.test(line)) return;
@@ -1921,7 +3210,7 @@ function spawnAgent(opts) {
1921
3210
  proc.stdin.write(opts.prompt);
1922
3211
  }
1923
3212
  proc.stdin.end();
1924
- return new Promise((resolve2) => {
3213
+ return new Promise((resolve3) => {
1925
3214
  let settled = false;
1926
3215
  let graceTimer;
1927
3216
  function finish(exitCode) {
@@ -1947,7 +3236,7 @@ function spawnAgent(opts) {
1947
3236
  rlErr.close();
1948
3237
  logStream.end();
1949
3238
  if (promptFile) cleanupPromptFile(promptFile);
1950
- resolve2({ exitCode, completedItems, timedOut });
3239
+ resolve3({ exitCode, completedItems, timedOut });
1951
3240
  }
1952
3241
  __name(finish, "finish");
1953
3242
  proc.on("exit", (code) => {
@@ -1971,16 +3260,21 @@ function spawnAgent(opts) {
1971
3260
  }
1972
3261
  rl.close();
1973
3262
  rlErr.close();
1974
- logStream.end();
3263
+ const msg = "agent process error: " + formatErrorMessage(err);
3264
+ logStream.write(`nightralph: ${msg}
3265
+ `);
1975
3266
  if (promptFile) cleanupPromptFile(promptFile);
1976
- console.error(
1977
- "Agent process error:",
1978
- formatErrorMessage(err)
1979
- );
1980
- resolve2({
1981
- exitCode: 1,
1982
- completedItems,
1983
- timedOut
3267
+ if (opts.onLine) {
3268
+ opts.onLine(`${prefix}${msg}`);
3269
+ } else {
3270
+ console.error(`${prefix}${msg}`);
3271
+ }
3272
+ logStream.end(() => {
3273
+ resolve3({
3274
+ exitCode: 1,
3275
+ completedItems,
3276
+ timedOut
3277
+ });
1984
3278
  });
1985
3279
  });
1986
3280
  });
@@ -2000,6 +3294,8 @@ function dryRun(tickets, specBody, testCmd) {
2000
3294
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
2001
3295
  );
2002
3296
  }
3297
+ console.log(`
3298
+ Test command: ${testCmd || "(none)"}`);
2003
3299
  const firstReady = findReadyTickets(tickets);
2004
3300
  if (firstReady.length > 0) {
2005
3301
  console.log(
@@ -2055,14 +3351,20 @@ Prompt for first ready ticket (${firstReady[0].filename}):
2055
3351
  renderPrompt(
2056
3352
  opts.specBody,
2057
3353
  firstReady[0],
2058
- { testCmd: opts.testCmd }
3354
+ {
3355
+ testCmd: opts.testCmd,
3356
+ installCmd: opts.installCmd
3357
+ }
2059
3358
  )
2060
3359
  );
2061
3360
  }
2062
3361
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
2063
3362
  console.log(
2064
3363
  `
2065
- Test command: ${opts.testCmd}`
3364
+ Test command: ${opts.testCmd || "(none)"}`
3365
+ );
3366
+ console.log(
3367
+ `Install: ${opts.installCmd || "(none)"}`
2066
3368
  );
2067
3369
  console.log(
2068
3370
  `Effort: ${opts.effort ?? "(provider default)"}`
@@ -2083,7 +3385,34 @@ Merge strategy: ${strategyDesc}`
2083
3385
  console.log(`Progress file: ${opts.progressPath}`);
2084
3386
  }
2085
3387
  __name(dryRunIsolated, "dryRunIsolated");
3388
+ async function runInstall(opts) {
3389
+ const result = await runTestCmd({
3390
+ cmd: opts.installCmd,
3391
+ cwd: opts.worktreePath,
3392
+ timeout: opts.timeout,
3393
+ registry: opts.registry
3394
+ });
3395
+ const installLogPath = attemptLogPath(
3396
+ opts.logsDir,
3397
+ opts.ticket,
3398
+ opts.attempt ?? 1,
3399
+ ".install.log"
3400
+ );
3401
+ writeFileSync5(installLogPath, result.output);
3402
+ if (result.exitCode !== 0) {
3403
+ opts.log(
3404
+ ` ${opts.ticket.filename}: install failed (exit ${result.exitCode}); see ${installLogPath}`
3405
+ );
3406
+ return false;
3407
+ }
3408
+ return true;
3409
+ }
3410
+ __name(runInstall, "runInstall");
2086
3411
  async function runTestGate(opts) {
3412
+ if (opts.testCmd === "") {
3413
+ opts.log(` ${opts.ticket.filename}: no test gate`);
3414
+ return true;
3415
+ }
2087
3416
  const result = await runTestCmd({
2088
3417
  cmd: opts.testCmd,
2089
3418
  cwd: opts.worktreePath,
@@ -2096,7 +3425,7 @@ async function runTestGate(opts) {
2096
3425
  opts.attempt ?? 1,
2097
3426
  ".test.log"
2098
3427
  );
2099
- writeFileSync3(testLogPath, result.output);
3428
+ writeFileSync5(testLogPath, result.output);
2100
3429
  if (result.exitCode !== 0) {
2101
3430
  opts.log(
2102
3431
  ` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
@@ -2125,11 +3454,24 @@ async function respawnAndRetryMerge(opts) {
2125
3454
  } else {
2126
3455
  console.log(msg1);
2127
3456
  }
3457
+ if (opts.installCmd) {
3458
+ const installed = await runInstall({
3459
+ installCmd: opts.installCmd,
3460
+ ticket: opts.ticket,
3461
+ worktreePath: opts.worktreePath,
3462
+ timeout: opts.timeout,
3463
+ logsDir: opts.logsDir,
3464
+ log,
3465
+ registry: runningAgents
3466
+ });
3467
+ if (!installed) return false;
3468
+ }
2128
3469
  const prompt = renderMergePrompt(
2129
3470
  opts.specBody,
2130
- opts.ticket
3471
+ opts.ticket,
3472
+ { installCmd: opts.installCmd }
2131
3473
  );
2132
- const logPath = join4(
3474
+ const logPath2 = join6(
2133
3475
  opts.logsDir,
2134
3476
  opts.ticket.filename.replace(/\.md$/, ".respawn.log")
2135
3477
  );
@@ -2142,7 +3484,7 @@ async function respawnAndRetryMerge(opts) {
2142
3484
  agentCmd: opts.agentCmd,
2143
3485
  model: opts.model,
2144
3486
  timeout: opts.timeout,
2145
- logPath,
3487
+ logPath: logPath2,
2146
3488
  cwd: opts.worktreePath,
2147
3489
  label,
2148
3490
  maxTurns: opts.maxTurns,
@@ -2226,20 +3568,31 @@ async function runPool(items, limit, fn) {
2226
3568
  return results;
2227
3569
  }
2228
3570
  __name(runPool, "runPool");
2229
- function retryDelayFor(base, attempt) {
2230
- return base * 2 ** (attempt - 2);
2231
- }
2232
- __name(retryDelayFor, "retryDelayFor");
2233
- function sleep(ms) {
2234
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2235
- }
2236
- __name(sleep, "sleep");
2237
3571
  function attemptLogPath(logsDir, ticket, attempt, ext) {
2238
3572
  const stem = ticket.filename.replace(/\.md$/, "");
2239
3573
  const suffix = attempt > 1 ? `.attempt${attempt}` : "";
2240
- return join4(logsDir, `${stem}${suffix}${ext}`);
3574
+ return join6(logsDir, `${stem}${suffix}${ext}`);
2241
3575
  }
2242
3576
  __name(attemptLogPath, "attemptLogPath");
3577
+ function buildHistory(learningsPath, ticket, d) {
3578
+ try {
3579
+ return renderHistory(selectHistory(
3580
+ readEntries(learningsPath),
3581
+ {
3582
+ key: ticketKey(ticket.feature, ticket.num),
3583
+ blockerKeys: ticket.blockers.map((b) => {
3584
+ return ticketKey(ticket.feature, b);
3585
+ })
3586
+ }
3587
+ ));
3588
+ } catch (err) {
3589
+ d.log(
3590
+ ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
3591
+ );
3592
+ return "";
3593
+ }
3594
+ }
3595
+ __name(buildHistory, "buildHistory");
2243
3596
  async function runTicketAttempt(opts) {
2244
3597
  const { ticket, d } = opts;
2245
3598
  let worktree;
@@ -2258,6 +3611,38 @@ async function runTicketAttempt(opts) {
2258
3611
  "in-progress",
2259
3612
  ticket.feature
2260
3613
  );
3614
+ if (opts.installCmd) {
3615
+ const installed = await runInstall({
3616
+ installCmd: opts.installCmd,
3617
+ ticket,
3618
+ worktreePath: worktree.worktreePath,
3619
+ timeout: opts.timeout,
3620
+ logsDir: opts.logsDir,
3621
+ attempt: opts.attempt,
3622
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log"),
3623
+ registry: runningAgents
3624
+ });
3625
+ if (!installed) {
3626
+ await removeWorktreeQuietly(
3627
+ worktree.worktreePath,
3628
+ d
3629
+ );
3630
+ return {
3631
+ kind: "failed",
3632
+ reason: "install",
3633
+ branchName: worktree.branchName,
3634
+ exitCode: 1,
3635
+ completedItems: []
3636
+ };
3637
+ }
3638
+ }
3639
+ try {
3640
+ ensureNotesIgnored(worktree.worktreePath);
3641
+ } catch (err) {
3642
+ d.log(
3643
+ ` ${ticket.filename}: could not set up the notes directory: ${formatErrorMessage(err)}`
3644
+ );
3645
+ }
2261
3646
  const ticketSpec = lookupSpec(
2262
3647
  opts.specBodies,
2263
3648
  opts.specBody,
@@ -2267,7 +3652,17 @@ async function runTicketAttempt(opts) {
2267
3652
  prompt: renderPrompt(
2268
3653
  ticketSpec,
2269
3654
  ticket,
2270
- { testCmd: opts.testCmd }
3655
+ {
3656
+ testCmd: opts.testCmd,
3657
+ installCmd: opts.installCmd,
3658
+ // Rebuilt every attempt, so a retry sees the
3659
+ // entry its previous attempt just wrote
3660
+ history: buildHistory(
3661
+ opts.learningsPath,
3662
+ ticket,
3663
+ d
3664
+ )
3665
+ }
2271
3666
  ),
2272
3667
  agentCmd: opts.agentCmd,
2273
3668
  model: opts.model,
@@ -2284,6 +3679,20 @@ async function runTicketAttempt(opts) {
2284
3679
  effort: opts.effort,
2285
3680
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2286
3681
  });
3682
+ let notes = null;
3683
+ try {
3684
+ notes = collectNotes(worktree.worktreePath);
3685
+ } catch (err) {
3686
+ d.log(
3687
+ ` ${ticket.filename}: could not collect notes: ${formatErrorMessage(err)}`
3688
+ );
3689
+ }
3690
+ const agentRun = {
3691
+ exitCode: agentResult.exitCode,
3692
+ completedItems: agentResult.completedItems,
3693
+ timedOut: agentResult.timedOut,
3694
+ notes
3695
+ };
2287
3696
  if (agentResult.timedOut) {
2288
3697
  d.log(
2289
3698
  ` ${ticket.filename}: timed out; keeping the work it committed`
@@ -2298,7 +3707,8 @@ async function runTicketAttempt(opts) {
2298
3707
  reason: "exit",
2299
3708
  branchName: worktree.branchName,
2300
3709
  exitCode: agentResult.exitCode,
2301
- completedItems: agentResult.completedItems
3710
+ completedItems: agentResult.completedItems,
3711
+ agentRun
2302
3712
  };
2303
3713
  }
2304
3714
  if (isDirty(worktree.worktreePath)) {
@@ -2321,7 +3731,8 @@ async function runTicketAttempt(opts) {
2321
3731
  return {
2322
3732
  kind: "no-commits",
2323
3733
  worktreePath: worktree.worktreePath,
2324
- branchName: worktree.branchName
3734
+ branchName: worktree.branchName,
3735
+ agentRun
2325
3736
  };
2326
3737
  }
2327
3738
  const passed = await runTestGate({
@@ -2344,44 +3755,130 @@ async function runTicketAttempt(opts) {
2344
3755
  reason: "tests",
2345
3756
  branchName: worktree.branchName,
2346
3757
  exitCode: 1,
2347
- completedItems: agentResult.completedItems
3758
+ completedItems: agentResult.completedItems,
3759
+ agentRun
2348
3760
  };
2349
3761
  }
2350
3762
  return {
2351
3763
  kind: "ready",
2352
3764
  worktreePath: worktree.worktreePath,
2353
3765
  branchName: worktree.branchName,
2354
- completedItems: agentResult.completedItems
3766
+ completedItems: agentResult.completedItems,
3767
+ agentRun
2355
3768
  };
2356
3769
  }
2357
3770
  __name(runTicketAttempt, "runTicketAttempt");
2358
- async function runTicketAttempts(opts) {
2359
- const { ticket, d } = opts;
2360
- const maxAttempts = opts.retries + 1;
2361
- const providerName = basename2(opts.agentCmd);
2362
- const isKnown = providerName in EFFORT_LADDERS;
2363
- let effort = opts.effort;
2364
- let outcome = {
2365
- kind: "no-commits",
2366
- worktreePath: "",
2367
- branchName: ""
3771
+ function readLogTail(path) {
3772
+ try {
3773
+ return tailLines(readFileSync6(path, "utf8"), LOG_TAIL_LINES);
3774
+ } catch {
3775
+ return "";
3776
+ }
3777
+ }
3778
+ __name(readLogTail, "readLogTail");
3779
+ function attemptEntry(opts) {
3780
+ const { logsDir, ticket, attempt, effort, outcome } = opts;
3781
+ const base = {
3782
+ key: ticketKey(ticket.feature, ticket.num),
3783
+ label: ticketLabel(ticket),
3784
+ attempt,
3785
+ now: /* @__PURE__ */ new Date()
2368
3786
  };
2369
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2370
- if (attempt === 1) {
2371
- d.log(` Starting ${ticket.filename}`);
2372
- } else {
2373
- if (isKnown) {
2374
- effort = escalateEffort(
2375
- providerName,
2376
- effort
2377
- );
3787
+ const logFor = /* @__PURE__ */ __name((ext) => {
3788
+ const path = attemptLogPath(logsDir, ticket, attempt, ext);
3789
+ return { logName: basename5(path), logTail: readLogTail(path) };
3790
+ }, "logFor");
3791
+ switch (outcome.kind) {
3792
+ case "ready":
3793
+ return {
3794
+ ...base,
3795
+ outcome: "done",
3796
+ run: outcome.agentRun,
3797
+ effort
3798
+ };
3799
+ case "failed":
3800
+ switch (outcome.reason) {
3801
+ case "exit":
3802
+ return {
3803
+ ...base,
3804
+ outcome: "exit",
3805
+ run: outcome.agentRun,
3806
+ effort
3807
+ };
3808
+ case "tests":
3809
+ return {
3810
+ ...base,
3811
+ outcome: "tests",
3812
+ run: outcome.agentRun,
3813
+ effort,
3814
+ ...logFor(".test.log")
3815
+ };
3816
+ case "install":
3817
+ return {
3818
+ ...base,
3819
+ outcome: "install",
3820
+ ...logFor(".install.log")
3821
+ };
3822
+ default: {
3823
+ const _exhaustive = outcome;
3824
+ return _exhaustive;
3825
+ }
2378
3826
  }
2379
- const delay = retryDelayFor(opts.retryDelay, attempt);
2380
- d.log(
2381
- ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
2382
- );
2383
- if (delay > 0) await sleep(delay * 1e3);
3827
+ case "no-commits":
3828
+ return {
3829
+ ...base,
3830
+ outcome: "no-commits",
3831
+ run: outcome.agentRun,
3832
+ effort
3833
+ };
3834
+ case "error":
3835
+ case "worktree-error":
3836
+ return {
3837
+ ...base,
3838
+ outcome: "error",
3839
+ error: formatErrorMessage(outcome.error)
3840
+ };
3841
+ default: {
3842
+ const _exhaustive = outcome;
3843
+ return _exhaustive;
2384
3844
  }
3845
+ }
3846
+ }
3847
+ __name(attemptEntry, "attemptEntry");
3848
+ function recordAttempt(opts) {
3849
+ try {
3850
+ const entry = attemptEntry(opts);
3851
+ if (!entry) return;
3852
+ appendEntry(opts.learningsPath, renderEntry(entry));
3853
+ } catch (err) {
3854
+ opts.d.log(
3855
+ ` ${opts.ticket.filename}: could not record learnings: ${formatErrorMessage(err)}`
3856
+ );
3857
+ }
3858
+ }
3859
+ __name(recordAttempt, "recordAttempt");
3860
+ async function runTicketAttempts(opts) {
3861
+ const { ticket, d } = opts;
3862
+ return runWithRetries({
3863
+ maxAttempts: opts.retries + 1,
3864
+ retryDelay: opts.retryDelay,
3865
+ agentCmd: opts.agentCmd,
3866
+ effort: opts.effort,
3867
+ label: ticket.filename,
3868
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
3869
+ }, async (attempt, effort) => {
3870
+ const record = /* @__PURE__ */ __name((o) => {
3871
+ recordAttempt({
3872
+ learningsPath: opts.learningsPath,
3873
+ logsDir: opts.logsDir,
3874
+ ticket,
3875
+ attempt,
3876
+ effort,
3877
+ outcome: o,
3878
+ d
3879
+ });
3880
+ }, "record");
3881
+ let outcome;
2385
3882
  try {
2386
3883
  outcome = await runTicketAttempt({
2387
3884
  ...opts,
@@ -2392,25 +3889,46 @@ async function runTicketAttempts(opts) {
2392
3889
  d.log(
2393
3890
  ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
2394
3891
  );
2395
- return { kind: "error", error };
3892
+ const errorOutcome = { kind: "error", error };
3893
+ record(errorOutcome);
3894
+ return { result: errorOutcome, stop: true };
2396
3895
  }
3896
+ record(outcome);
2397
3897
  if (outcome.kind === "ready") {
2398
3898
  d.setTicketStatus(
2399
3899
  ticket.num,
2400
3900
  "awaiting-merge",
2401
3901
  ticket.feature
2402
3902
  );
2403
- return outcome;
3903
+ return { result: outcome, stop: true };
2404
3904
  }
2405
- if (outcome.kind === "worktree-error") return outcome;
2406
- }
2407
- return outcome;
3905
+ return {
3906
+ result: outcome,
3907
+ stop: outcome.kind === "worktree-error"
3908
+ };
3909
+ });
2408
3910
  }
2409
3911
  __name(runTicketAttempts, "runTicketAttempts");
3912
+ function failureNote(reason) {
3913
+ if (reason === "tests") return "tests failed";
3914
+ if (reason === "install") return "install failed";
3915
+ return "failed";
3916
+ }
3917
+ __name(failureNote, "failureNote");
3918
+ function isRegularFile(path) {
3919
+ try {
3920
+ return statSync(path).isFile();
3921
+ } catch {
3922
+ return false;
3923
+ }
3924
+ }
3925
+ __name(isRegularFile, "isRegularFile");
2410
3926
  async function saveProgress(repoRoot, progressPath, progress, message, d) {
2411
3927
  writeProgress(progressPath, progress);
3928
+ const learningsPath = learningsPathFor(progressPath);
3929
+ const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
2412
3930
  try {
2413
- await commitProgress(repoRoot, progressPath, message);
3931
+ await commitProgress(repoRoot, paths, message);
2414
3932
  } catch (err) {
2415
3933
  d.log(
2416
3934
  " Failed to commit progress: " + formatErrorMessage(err)
@@ -2480,6 +3998,29 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
2480
3998
  return lines.join("\n");
2481
3999
  }
2482
4000
  __name(renderFinishSummary, "renderFinishSummary");
4001
+ function sessionTickets(tickets) {
4002
+ return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
4003
+ }
4004
+ __name(sessionTickets, "sessionTickets");
4005
+ function toRef(t) {
4006
+ return { num: t.num, filename: t.filename, feature: t.feature };
4007
+ }
4008
+ __name(toRef, "toRef");
4009
+ function reportBlocked(d, tickets, failedKeys) {
4010
+ const byKey = new Map(
4011
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
4012
+ );
4013
+ for (const [key, dead] of findBlockedTickets(
4014
+ tickets,
4015
+ failedKeys
4016
+ )) {
4017
+ const ticket = byKey.get(key);
4018
+ if (!ticket) continue;
4019
+ const labels = dead.map((k) => byKey.get(k)).filter((t) => t !== void 0).map((t) => ticketLabel(toRef(t)));
4020
+ d.setBlocked(ticket.num, ticket.feature, labels);
4021
+ }
4022
+ }
4023
+ __name(reportBlocked, "reportBlocked");
2483
4024
  function runProgress(tickets) {
2484
4025
  return {
2485
4026
  completed: tickets.filter((t) => t.status === "done").length,
@@ -2488,19 +4029,21 @@ function runProgress(tickets) {
2488
4029
  }
2489
4030
  __name(runProgress, "runProgress");
2490
4031
  async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
2491
- mkdirSync2(logsDir, { recursive: true });
4032
+ mkdirSync3(logsDir, { recursive: true });
2492
4033
  const d = display ?? createDisplay();
2493
4034
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
2494
4035
  const delayBase = retryDelay ?? 0;
2495
4036
  const poolSize = concurrency ?? DEFAULT_CONCURRENCY;
2496
- const providerName = basename2(agentCmd);
4037
+ const providerName = basename5(agentCmd);
2497
4038
  const isKnown = providerName in EFFORT_LADDERS;
2498
4039
  let totalWaves = 0;
2499
4040
  for (const _ of simulateWaves(tickets)) totalWaves++;
2500
4041
  let totalCompleted = 0;
2501
4042
  let waveNum = 0;
2502
4043
  const failedKeys = /* @__PURE__ */ new Set();
4044
+ const session = sessionTickets(tickets);
2503
4045
  while (true) {
4046
+ if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
2504
4047
  const ready = findReadyTickets(tickets).filter(
2505
4048
  (t) => !failedKeys.has(
2506
4049
  ticketKey(t.feature, t.num)
@@ -2508,12 +4051,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2508
4051
  );
2509
4052
  if (ready.length === 0) break;
2510
4053
  waveNum++;
2511
- const readyKeys = new Set(
2512
- ready.map((t) => ticketKey(t.feature, t.num))
2513
- );
2514
- const remaining2 = tickets.filter(
2515
- (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
2516
- );
4054
+ if (waveNum === 1) {
4055
+ d.startRun(session.map(toRef));
4056
+ reportBlocked(d, tickets, failedKeys);
4057
+ }
2517
4058
  d.startWave(
2518
4059
  waveNum,
2519
4060
  totalWaves,
@@ -2522,12 +4063,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2522
4063
  filename: t.filename,
2523
4064
  feature: t.feature
2524
4065
  })),
2525
- runProgress(tickets),
2526
- remaining2.map((t) => ({
2527
- num: t.num,
2528
- filename: t.filename,
2529
- feature: t.feature
2530
- }))
4066
+ runProgress(session)
2531
4067
  );
2532
4068
  const results = await runPool(
2533
4069
  ready,
@@ -2624,10 +4160,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2624
4160
  );
2625
4161
  }
2626
4162
  if (waveCompleted === 0) {
2627
- const remaining3 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
4163
+ const remaining2 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
2628
4164
  ticketKey(t.feature, t.num)
2629
4165
  ));
2630
- if (remaining3.length === 0) {
4166
+ if (remaining2.length === 0) {
2631
4167
  d.log(
2632
4168
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
2633
4169
  );
@@ -2635,10 +4171,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
2635
4171
  }
2636
4172
  d.log(
2637
4173
  `
2638
- No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.length === 1 ? "" : "s"} remain; continuing.`
4174
+ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
2639
4175
  );
2640
4176
  }
2641
4177
  }
4178
+ if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
2642
4179
  const remaining = tickets.filter(
2643
4180
  (t) => t.status === "ready-for-agent"
2644
4181
  );
@@ -2652,7 +4189,7 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
2652
4189
  }
2653
4190
  __name(runWaves, "runWaves");
2654
4191
  async function runWavesIsolated(opts) {
2655
- mkdirSync2(opts.logsDir, { recursive: true });
4192
+ mkdirSync3(opts.logsDir, { recursive: true });
2656
4193
  const d = opts.display ?? createDisplay();
2657
4194
  const retries = opts.retries ?? DEFAULT_RETRIES;
2658
4195
  const retryDelay = opts.retryDelay ?? 0;
@@ -2667,12 +4204,13 @@ async function runWavesIsolated(opts) {
2667
4204
  "Working tree is dirty; commit or stash changes before running"
2668
4205
  );
2669
4206
  }
4207
+ const launchCommit = headCommit(repoRoot);
2670
4208
  const features = new Set(
2671
4209
  opts.tickets.map((t) => t.feature).filter(Boolean)
2672
4210
  );
2673
4211
  const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
2674
- const fallbackProgressPath = join4(
2675
- dirname2(opts.logsDir),
4212
+ const fallbackProgressPath = join6(
4213
+ dirname4(opts.logsDir),
2676
4214
  "progress.md"
2677
4215
  );
2678
4216
  let tracker;
@@ -2731,7 +4269,9 @@ async function runWavesIsolated(opts) {
2731
4269
  let totalCompleted = 0;
2732
4270
  let waveNum = 0;
2733
4271
  const failedKeys = /* @__PURE__ */ new Set();
4272
+ const session = sessionTickets(opts.tickets);
2734
4273
  while (true) {
4274
+ if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
2735
4275
  const ready = findReadyTickets(opts.tickets).filter(
2736
4276
  (t) => !failedKeys.has(
2737
4277
  ticketKey(t.feature, t.num)
@@ -2739,12 +4279,10 @@ async function runWavesIsolated(opts) {
2739
4279
  );
2740
4280
  if (ready.length === 0) break;
2741
4281
  waveNum++;
2742
- const readyKeys = new Set(
2743
- ready.map((t) => ticketKey(t.feature, t.num))
2744
- );
2745
- const remaining2 = opts.tickets.filter(
2746
- (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
2747
- );
4282
+ if (waveNum === 1) {
4283
+ d.startRun(session.map(toRef));
4284
+ reportBlocked(d, opts.tickets, failedKeys);
4285
+ }
2748
4286
  d.startWave(
2749
4287
  waveNum,
2750
4288
  totalWaves,
@@ -2753,12 +4291,7 @@ async function runWavesIsolated(opts) {
2753
4291
  filename: t.filename,
2754
4292
  feature: t.feature
2755
4293
  })),
2756
- runProgress(opts.tickets),
2757
- remaining2.map((t) => ({
2758
- num: t.num,
2759
- filename: t.filename,
2760
- feature: t.feature
2761
- }))
4294
+ runProgress(session)
2762
4295
  );
2763
4296
  const settled = await runPool(
2764
4297
  ready,
@@ -2775,13 +4308,34 @@ async function runWavesIsolated(opts) {
2775
4308
  timeout: opts.timeout,
2776
4309
  logsDir: opts.logsDir,
2777
4310
  testCmd: opts.testCmd,
4311
+ installCmd: opts.installCmd,
2778
4312
  maxTurns: opts.maxTurns,
2779
4313
  effort: opts.effort,
4314
+ learningsPath: learningsPathFor(
4315
+ tracker.pathFor(t.feature)
4316
+ ),
2780
4317
  retries,
2781
4318
  retryDelay,
2782
4319
  d
2783
4320
  })
2784
4321
  );
4322
+ const touched = new Set(ready.map((t) => {
4323
+ return learningsPathFor(tracker.pathFor(t.feature));
4324
+ }));
4325
+ for (const path of touched) {
4326
+ if (!isRegularFile(path)) continue;
4327
+ try {
4328
+ await commitProgress(
4329
+ repoRoot,
4330
+ path,
4331
+ "nightralph: record attempt learnings"
4332
+ );
4333
+ } catch (err) {
4334
+ d.log(
4335
+ " Failed to commit learnings: " + formatErrorMessage(err)
4336
+ );
4337
+ }
4338
+ }
2785
4339
  const outcomes = settled.map(
2786
4340
  (r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
2787
4341
  );
@@ -2885,7 +4439,7 @@ async function runWavesIsolated(opts) {
2885
4439
  repoRoot,
2886
4440
  tracker.pathFor(ticket.feature),
2887
4441
  tracker.dataFor(ticket.feature),
2888
- `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
4442
+ `nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
2889
4443
  d
2890
4444
  );
2891
4445
  continue;
@@ -2921,6 +4475,7 @@ async function runWavesIsolated(opts) {
2921
4475
  branches: successfulBranches,
2922
4476
  strategy: opts.conflictStrategy
2923
4477
  });
4478
+ const doneThisWave = [];
2924
4479
  for (const branchResult of mergeResult.merged) {
2925
4480
  const bk = branchToKey.get(
2926
4481
  branchResult.branch
@@ -2929,6 +4484,7 @@ async function runWavesIsolated(opts) {
2929
4484
  if (!ticket) continue;
2930
4485
  if (branchResult.merged) {
2931
4486
  markDone(ticket);
4487
+ doneThisWave.push(ticket);
2932
4488
  waveCompleted++;
2933
4489
  totalCompleted++;
2934
4490
  d.setTicketStatus(
@@ -2978,11 +4534,13 @@ async function runWavesIsolated(opts) {
2978
4534
  timeout: opts.timeout,
2979
4535
  logsDir: opts.logsDir,
2980
4536
  testCmd: opts.testCmd,
4537
+ installCmd: opts.installCmd,
2981
4538
  maxTurns: opts.maxTurns,
2982
4539
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
2983
4540
  }) : false;
2984
4541
  if (recovered) {
2985
4542
  markDone(ticket);
4543
+ doneThisWave.push(ticket);
2986
4544
  waveCompleted++;
2987
4545
  totalCompleted++;
2988
4546
  d.setTicketStatus(
@@ -3020,6 +4578,11 @@ async function runWavesIsolated(opts) {
3020
4578
  failedKeys.add(
3021
4579
  ticketKey(ticket.feature, ticket.num)
3022
4580
  );
4581
+ d.setTicketStatus(
4582
+ ticket.num,
4583
+ "failed",
4584
+ ticket.feature
4585
+ );
3023
4586
  d.log(
3024
4587
  ` ${ticket.filename}: merge failed, staying ready-for-agent`
3025
4588
  );
@@ -3033,6 +4596,28 @@ async function runWavesIsolated(opts) {
3033
4596
  exitCode: 0,
3034
4597
  done: false
3035
4598
  });
4599
+ try {
4600
+ appendEntry(
4601
+ learningsPathFor(
4602
+ tracker.pathFor(ticket.feature)
4603
+ ),
4604
+ renderEntry({
4605
+ outcome: "merge",
4606
+ key: ticketKey(
4607
+ ticket.feature,
4608
+ ticket.num
4609
+ ),
4610
+ label: ticketLabel(ticket),
4611
+ attempt: 0,
4612
+ now: /* @__PURE__ */ new Date(),
4613
+ stderr: branchResult.stderr ?? ""
4614
+ })
4615
+ );
4616
+ } catch (err) {
4617
+ d.log(
4618
+ ` ${ticket.filename}: could not record learnings: ` + formatErrorMessage(err)
4619
+ );
4620
+ }
3036
4621
  await saveProgress(
3037
4622
  repoRoot,
3038
4623
  tracker.pathFor(ticket.feature),
@@ -3043,6 +4628,7 @@ async function runWavesIsolated(opts) {
3043
4628
  }
3044
4629
  }
3045
4630
  }
4631
+ await commitTicketFiles(repoRoot, doneThisWave, d);
3046
4632
  if (mergeResult.stoppedAt) {
3047
4633
  d.log(
3048
4634
  `
@@ -3067,12 +4653,12 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
3067
4653
  }
3068
4654
  }
3069
4655
  if (waveCompleted === 0) {
3070
- const remaining3 = findReadyTickets(
4656
+ const remaining2 = findReadyTickets(
3071
4657
  opts.tickets
3072
4658
  ).filter((t) => !failedKeys.has(
3073
4659
  ticketKey(t.feature, t.num)
3074
4660
  ));
3075
- if (remaining3.length === 0) {
4661
+ if (remaining2.length === 0) {
3076
4662
  d.log(
3077
4663
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
3078
4664
  );
@@ -3080,8 +4666,44 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
3080
4666
  }
3081
4667
  d.log(
3082
4668
  `
3083
- No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.length === 1 ? "" : "s"} remain; continuing.`
4669
+ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
4670
+ );
4671
+ }
4672
+ }
4673
+ if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
4674
+ let reviewLine = null;
4675
+ if (opts.review) {
4676
+ try {
4677
+ const outcome = await runReviewPass({
4678
+ repoRoot,
4679
+ repoName,
4680
+ baseBranch,
4681
+ reviewPath: opts.review.reviewPath,
4682
+ forkedFrom: opts.review.forkedFrom,
4683
+ launchCommit,
4684
+ specBody: reviewSpecBody(
4685
+ opts.specBody,
4686
+ opts.specBodies
4687
+ ),
4688
+ agentCmd: opts.agentCmd,
4689
+ model: opts.model,
4690
+ timeout: opts.timeout,
4691
+ logsDir: opts.logsDir,
4692
+ testCmd: opts.testCmd,
4693
+ installCmd: opts.installCmd,
4694
+ maxTurns: opts.maxTurns,
4695
+ effort: opts.effort,
4696
+ retries,
4697
+ retryDelay,
4698
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
4699
+ }, { spawnAgent, registry: runningAgents });
4700
+ reviewLine = renderReviewSummary(
4701
+ outcome,
4702
+ relative2(repoRoot, opts.review.reviewPath)
3084
4703
  );
4704
+ } catch (err) {
4705
+ d.log(`
4706
+ Review failed: ${formatErrorMessage(err)}`);
3085
4707
  }
3086
4708
  }
3087
4709
  const remaining = opts.tickets.filter(
@@ -3092,23 +4714,55 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
3092
4714
  totalCompleted,
3093
4715
  failedKeys
3094
4716
  ));
4717
+ if (reviewLine) d.log(reviewLine);
4718
+ d.log(`Work is on ${baseBranch}`);
3095
4719
  d.cleanup();
3096
- return { allDone: remaining.length === 0 };
4720
+ return { allDone: remaining.length === 0, branch: baseBranch };
3097
4721
  }
3098
4722
  __name(runWavesIsolated, "runWavesIsolated");
3099
4723
 
4724
+ // src/preflight.ts
4725
+ import { accessSync, constants, statSync as statSync2 } from "node:fs";
4726
+ import { delimiter, join as join7, resolve as resolve2 } from "node:path";
4727
+ function isExecutableFile(file) {
4728
+ try {
4729
+ if (!statSync2(file).isFile()) return false;
4730
+ accessSync(file, constants.X_OK);
4731
+ return true;
4732
+ } catch {
4733
+ return false;
4734
+ }
4735
+ }
4736
+ __name(isExecutableFile, "isExecutableFile");
4737
+ function resolveExecutable(cmd, opts = {}) {
4738
+ if (cmd === "") return null;
4739
+ const cwd = opts.cwd ?? process.cwd();
4740
+ if (cmd.includes("/")) {
4741
+ const full = resolve2(cwd, cmd);
4742
+ return isExecutableFile(full) ? full : null;
4743
+ }
4744
+ const path = opts.path ?? process.env.PATH ?? "";
4745
+ for (const dir of path.split(delimiter)) {
4746
+ if (dir === "") continue;
4747
+ const candidate = join7(dir, cmd);
4748
+ if (isExecutableFile(candidate)) return candidate;
4749
+ }
4750
+ return null;
4751
+ }
4752
+ __name(resolveExecutable, "resolveExecutable");
4753
+
3100
4754
  // src/resolve.ts
3101
- import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
3102
- import { dirname as dirname3, join as join5 } from "node:path";
4755
+ import { existsSync as existsSync6, readdirSync as readdirSync3 } from "node:fs";
4756
+ import { dirname as dirname5, join as join8 } from "node:path";
3103
4757
  import { fileURLToPath } from "node:url";
3104
4758
  import { checkbox, Separator } from "@inquirer/prompts";
3105
4759
  function getScriptDir(metaUrl) {
3106
- return dirname3(fileURLToPath(metaUrl));
4760
+ return dirname5(fileURLToPath(metaUrl));
3107
4761
  }
3108
4762
  __name(getScriptDir, "getScriptDir");
3109
4763
  function findFeatureDirs(cwd) {
3110
- const scratchDir = join5(cwd, ".scratch");
3111
- if (!existsSync4(scratchDir)) return [];
4764
+ const scratchDir = join8(cwd, ".scratch");
4765
+ if (!existsSync6(scratchDir)) return [];
3112
4766
  const entries = readdirSync3(
3113
4767
  scratchDir,
3114
4768
  { withFileTypes: true }
@@ -3116,18 +4770,18 @@ function findFeatureDirs(cwd) {
3116
4770
  const results = [];
3117
4771
  for (const entry of entries) {
3118
4772
  if (!entry.isDirectory()) continue;
3119
- const issuesDir = join5(
4773
+ const issuesDir = join8(
3120
4774
  scratchDir,
3121
4775
  entry.name,
3122
4776
  "issues"
3123
4777
  );
3124
- const specFile = join5(
4778
+ const specFile = join8(
3125
4779
  scratchDir,
3126
4780
  entry.name,
3127
4781
  "spec.md"
3128
4782
  );
3129
- if (!existsSync4(issuesDir)) continue;
3130
- const progressFile = join5(
4783
+ if (!existsSync6(issuesDir)) continue;
4784
+ const progressFile = join8(
3131
4785
  scratchDir,
3132
4786
  entry.name,
3133
4787
  "progress.md"
@@ -3146,10 +4800,10 @@ __name(findFeatureDirs, "findFeatureDirs");
3146
4800
  var ALL_VALUE = "__all__";
3147
4801
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
3148
4802
  if (specName) {
3149
- const featureDir = join5(cwd, ".scratch", specName);
4803
+ const featureDir = join8(cwd, ".scratch", specName);
3150
4804
  return [{
3151
- dir: join5(featureDir, "issues"),
3152
- spec: join5(featureDir, "spec.md"),
4805
+ dir: join8(featureDir, "issues"),
4806
+ spec: join8(featureDir, "spec.md"),
3153
4807
  name: specName
3154
4808
  }];
3155
4809
  }
@@ -3228,8 +4882,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3228
4882
  }
3229
4883
  }
3230
4884
  setup({
3231
- bundledSkillsDir: join6(scriptDir, "skills"),
3232
- docsTemplatesDir: join6(
4885
+ bundledSkillsDir: join9(scriptDir, "skills"),
4886
+ docsTemplatesDir: join9(
3233
4887
  scriptDir,
3234
4888
  "docs-templates"
3235
4889
  ),
@@ -3264,7 +4918,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3264
4918
  default: 3600
3265
4919
  }).option("test-cmd", {
3266
4920
  type: "string",
3267
- describe: 'Command run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to "npm test".'
4921
+ 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.'
4922
+ }).option("install-cmd", {
4923
+ type: "string",
4924
+ 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.`
3268
4925
  }).option("X", {
3269
4926
  type: "boolean",
3270
4927
  describe: "Stop on merge conflict instead of re-spawning",
@@ -3288,6 +4945,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3288
4945
  type: "number",
3289
4946
  default: DEFAULT_CONCURRENCY,
3290
4947
  describe: "Max agents running at once within a wave."
4948
+ }).option("review", {
4949
+ type: "boolean",
4950
+ default: true,
4951
+ describe: "Review the merged work and run one fix pass at the end. --no-review skips it."
3291
4952
  }),
3292
4953
  async (argv) => {
3293
4954
  await runExecute({
@@ -3297,15 +4958,26 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3297
4958
  dryRun: argv.dryRun,
3298
4959
  timeout: argv.timeout,
3299
4960
  testCmd: argv.testCmd,
4961
+ installCmd: argv.installCmd,
3300
4962
  stopOnConflict: argv.X,
3301
4963
  maxTurns: argv.maxTurns ?? argv.turns,
3302
4964
  effort: argv.effort,
3303
4965
  retries: argv.retries,
3304
4966
  retryDelay: argv.retryDelay,
3305
- concurrency: argv.concurrency
4967
+ concurrency: argv.concurrency,
4968
+ review: argv.review
3306
4969
  });
3307
4970
  }
3308
- ).strict().demandCommand(1, "Specify a command or provider").help().parse();
4971
+ ).strict().demandCommand(1, "Specify a command or provider").help().fail((msg, err, y) => {
4972
+ if (err && !msg) {
4973
+ console.error(formatErrorMessage(err));
4974
+ process.exit(2);
4975
+ }
4976
+ y.showHelp();
4977
+ console.error(`
4978
+ ${msg}`);
4979
+ process.exit(1);
4980
+ }).parse();
3309
4981
  function isGitRepo() {
3310
4982
  try {
3311
4983
  getRepoRoot();
@@ -3326,41 +4998,36 @@ function extractFeatureName(spec) {
3326
4998
  return parts[parts.length - 2] ?? parts[parts.length - 1] ?? "feature";
3327
4999
  }
3328
5000
  __name(extractFeatureName, "extractFeatureName");
3329
- async function runExecute(argv) {
3330
- const selections = await resolveIssuesDir(
3331
- argv.spec
3332
- );
5001
+ function loadTickets(selections, log) {
3333
5002
  const allTickets = [];
3334
5003
  const specBodies = /* @__PURE__ */ new Map();
3335
5004
  const progressPaths = /* @__PURE__ */ new Map();
5005
+ const issuesDirs = [];
3336
5006
  let firstFeatureName = "";
3337
5007
  let firstLogsDir = "";
5008
+ const missing = [];
3338
5009
  for (const selection of selections) {
3339
5010
  const { dir, spec, name } = selection;
3340
- if (!existsSync5(dir)) {
3341
- console.error(
3342
- `Directory not found: ${dir}`
3343
- );
3344
- process.exit(2);
5011
+ if (!existsSync7(dir)) {
5012
+ missing.push(`Directory not found: ${dir}`);
5013
+ continue;
3345
5014
  }
3346
- if (!existsSync5(spec)) {
3347
- console.error(
3348
- `Spec file not found: ${spec}`
3349
- );
3350
- process.exit(2);
5015
+ if (!existsSync7(spec)) {
5016
+ missing.push(`Spec file not found: ${spec}`);
5017
+ continue;
3351
5018
  }
3352
5019
  const specBody = stripSpecSection(
3353
- readFileSync5(spec, "utf8"),
5020
+ readFileSync7(spec, "utf8"),
3354
5021
  "User Stories"
3355
5022
  );
3356
5023
  const tickets = scanTickets(dir, name);
3357
5024
  if (tickets.length === 0) {
3358
- console.log(
5025
+ log(
3359
5026
  `No ticket files found for ${name}. Skipping.`
3360
5027
  );
3361
5028
  continue;
3362
5029
  }
3363
- console.log(
5030
+ log(
3364
5031
  `[${name}] Found ${tickets.length} tickets`
3365
5032
  );
3366
5033
  const knownNums = new Set(
@@ -3371,7 +5038,7 @@ async function runExecute(argv) {
3371
5038
  (b) => !knownNums.has(b)
3372
5039
  );
3373
5040
  if (unknown.length > 0) {
3374
- console.warn(
5041
+ log(
3375
5042
  `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
3376
5043
  );
3377
5044
  }
@@ -3382,24 +5049,128 @@ async function runExecute(argv) {
3382
5049
  specBodies.set(name, specBody);
3383
5050
  progressPaths.set(
3384
5051
  name,
3385
- join6(dirname4(dir), "progress.md")
5052
+ join9(dirname6(dir), "progress.md")
3386
5053
  );
5054
+ issuesDirs.push(dir);
3387
5055
  allTickets.push(...tickets);
3388
5056
  if (!firstFeatureName) {
3389
5057
  firstFeatureName = extractFeatureName(spec);
3390
- firstLogsDir = join6(dirname4(dir), "logs");
5058
+ firstLogsDir = join9(dirname6(dir), "logs");
5059
+ }
5060
+ }
5061
+ return {
5062
+ allTickets,
5063
+ specBodies,
5064
+ progressPaths,
5065
+ issuesDirs,
5066
+ firstFeatureName,
5067
+ firstLogsDir,
5068
+ missing
5069
+ };
5070
+ }
5071
+ __name(loadTickets, "loadTickets");
5072
+ function loadTicketsOrExit(selections) {
5073
+ const loaded = loadTickets(selections, console.log);
5074
+ if (loaded.missing.length > 0) {
5075
+ for (const msg of loaded.missing) console.error(msg);
5076
+ process.exit(2);
5077
+ }
5078
+ return loaded;
5079
+ }
5080
+ __name(loadTicketsOrExit, "loadTicketsOrExit");
5081
+ function failStaleResume(plan, problems) {
5082
+ try {
5083
+ execFileSync2(
5084
+ "git",
5085
+ ["checkout", "--quiet", plan.launchBranch],
5086
+ { cwd: getRepoRoot(), stdio: "ignore" }
5087
+ );
5088
+ } catch {
5089
+ }
5090
+ const n = plan.behind;
5091
+ console.error(
5092
+ `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}.`
5093
+ );
5094
+ process.exit(2);
5095
+ }
5096
+ __name(failStaleResume, "failStaleResume");
5097
+ function reviewPathFor(issuesDirs) {
5098
+ const featureDir = dirname6(issuesDirs[0]);
5099
+ return issuesDirs.length === 1 ? join9(featureDir, "review.md") : join9(dirname6(featureDir), "review.md");
5100
+ }
5101
+ __name(reviewPathFor, "reviewPathFor");
5102
+ async function runExecute(argv) {
5103
+ const selections = await resolveIssuesDir(
5104
+ argv.spec
5105
+ );
5106
+ let loaded = loadTicketsOrExit(selections);
5107
+ if (loaded.allTickets.length === 0) return;
5108
+ const providerPath = resolveExecutable(argv.provider);
5109
+ const providerIsPath = argv.provider.includes("/");
5110
+ const providerMissing = providerIsPath ? "not found or not executable" : "not found on PATH";
5111
+ if (providerPath === null && !argv.dryRun) {
5112
+ console.error(
5113
+ `Provider '${argv.provider}' ${providerMissing}`
5114
+ );
5115
+ process.exit(2);
5116
+ }
5117
+ const agentCmd = providerIsPath ? providerPath ?? argv.provider : argv.provider;
5118
+ const isolated = isGitRepo();
5119
+ const featureNames = [...loaded.specBodies.keys()];
5120
+ let plan = null;
5121
+ if (isolated && !argv.dryRun) {
5122
+ try {
5123
+ plan = prepareIntegrationBranch(
5124
+ getRepoRoot(),
5125
+ featureNames
5126
+ );
5127
+ } catch (err) {
5128
+ console.error(formatErrorMessage(err));
5129
+ process.exit(2);
5130
+ }
5131
+ if (plan.created) {
5132
+ console.log(
5133
+ `Created branch ${plan.branch} from ${plan.launchBranch}`
5134
+ );
5135
+ } else if (plan.resumed) {
5136
+ console.log(
5137
+ `Resumed ${plan.branch}` + (plan.fastForwarded ? ` (fast-forwarded to ${plan.launchBranch})` : "") + (plan.behind > 0 ? ` (${plan.launchBranch} is ${plan.behind} commit${plan.behind === 1 ? "" : "s"} ahead)` : "")
5138
+ );
5139
+ loaded = loadTickets(selections, () => {
5140
+ });
5141
+ if (loaded.missing.length > 0) {
5142
+ failStaleResume(plan, loaded.missing);
5143
+ }
5144
+ if (loaded.allTickets.length === 0) {
5145
+ failStaleResume(plan, ["no tickets"]);
5146
+ }
3391
5147
  }
3392
5148
  }
3393
- if (allTickets.length === 0) return;
5149
+ const {
5150
+ allTickets,
5151
+ specBodies,
5152
+ progressPaths,
5153
+ firstFeatureName,
5154
+ firstLogsDir
5155
+ } = loaded;
3394
5156
  const timeout = argv.timeout ?? 900;
3395
- const testCmd = argv.testCmd || detectTestCmd(
3396
- isGitRepo() ? getRepoRoot() : process.cwd()
3397
- ) || "npm test";
5157
+ const testCmd = argv.testCmd ?? detectTestCmd(
5158
+ isolated ? getRepoRoot() : process.cwd()
5159
+ ) ?? "";
5160
+ if (argv.testCmd === void 0 && testCmd === "") {
5161
+ console.log(
5162
+ "No test command detected; running without a test gate. Pass --test-cmd to add one."
5163
+ );
5164
+ }
5165
+ const installCmd = argv.installCmd ?? (isolated ? detectInstallCmd(getRepoRoot()) ?? void 0 : void 0);
3398
5166
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
3399
5167
  const logsDir = firstLogsDir;
3400
5168
  const fallbackSpec = specBodies.values().next().value;
3401
5169
  if (argv.dryRun) {
3402
- if (isGitRepo()) {
5170
+ console.log(
5171
+ `Provider: ${providerPath ?? providerMissing}`
5172
+ );
5173
+ if (isolated) {
3403
5174
  const repoName = getRepoName();
3404
5175
  dryRunIsolated({
3405
5176
  tickets: allTickets,
@@ -3408,11 +5179,20 @@ async function runExecute(argv) {
3408
5179
  conflictStrategy,
3409
5180
  progressPath: progressPaths.values().next().value,
3410
5181
  testCmd,
5182
+ installCmd,
3411
5183
  effort: argv.effort,
3412
5184
  retries: argv.retries,
3413
5185
  retryDelay: argv.retryDelay,
3414
5186
  concurrency: argv.concurrency
3415
5187
  });
5188
+ console.log(
5189
+ describeIntegrationBranch(
5190
+ getRepoRoot(),
5191
+ featureNames
5192
+ )
5193
+ );
5194
+ const reviewState = argv.review === false ? "off" : "on";
5195
+ console.log(`Review: ${reviewState}`);
3416
5196
  } else {
3417
5197
  dryRun(allTickets, fallbackSpec, testCmd);
3418
5198
  }
@@ -3428,12 +5208,12 @@ async function runExecute(argv) {
3428
5208
  process.on("SIGINT", () => onSignal(130));
3429
5209
  process.on("SIGTERM", () => onSignal(143));
3430
5210
  let result;
3431
- if (isGitRepo()) {
5211
+ if (isolated) {
3432
5212
  result = await runWavesIsolated({
3433
5213
  tickets: allTickets,
3434
5214
  specBody: fallbackSpec,
3435
5215
  specBodies,
3436
- agentCmd: argv.provider,
5216
+ agentCmd,
3437
5217
  model: argv.model ?? null,
3438
5218
  timeout,
3439
5219
  logsDir,
@@ -3441,18 +5221,23 @@ async function runExecute(argv) {
3441
5221
  conflictStrategy,
3442
5222
  display,
3443
5223
  testCmd,
5224
+ installCmd,
3444
5225
  maxTurns: argv.maxTurns,
3445
5226
  effort: argv.effort,
3446
5227
  retries: argv.retries,
3447
5228
  retryDelay: argv.retryDelay,
3448
5229
  concurrency: argv.concurrency,
3449
- progressPaths
5230
+ progressPaths,
5231
+ review: argv.review === false ? void 0 : {
5232
+ reviewPath: reviewPathFor(loaded.issuesDirs),
5233
+ forkedFrom: plan?.forkedFrom ?? void 0
5234
+ }
3450
5235
  });
3451
5236
  } else {
3452
5237
  result = await runWaves(
3453
5238
  allTickets,
3454
5239
  fallbackSpec,
3455
- argv.provider,
5240
+ agentCmd,
3456
5241
  argv.model ?? null,
3457
5242
  timeout,
3458
5243
  logsDir,