nightralph 0.0.53 → 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 (42) hide show
  1. package/README.md +82 -9
  2. package/dist/index.js +1947 -230
  3. package/dist/index.js.map +4 -4
  4. package/dist/learnings.js +356 -0
  5. package/dist/learnings.js.map +7 -0
  6. package/dist/meta.json +232 -12
  7. package/dist/orchestrator.js +442 -109
  8. package/dist/orchestrator.js.map +3 -3
  9. package/dist/preflight.js +34 -0
  10. package/dist/preflight.js.map +7 -0
  11. package/dist/progress.js +5 -4
  12. package/dist/progress.js.map +2 -2
  13. package/dist/retry.js +83 -0
  14. package/dist/retry.js.map +7 -0
  15. package/dist/review.js +662 -0
  16. package/dist/review.js.map +7 -0
  17. package/dist/rules.js +54 -0
  18. package/dist/rules.js.map +7 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/learnings.d.ts +68 -0
  21. package/dist/src/learnings.d.ts.map +1 -0
  22. package/dist/src/orchestrator.d.ts +14 -11
  23. package/dist/src/orchestrator.d.ts.map +1 -1
  24. package/dist/src/preflight.d.ts +6 -0
  25. package/dist/src/preflight.d.ts.map +1 -0
  26. package/dist/src/progress.d.ts +1 -1
  27. package/dist/src/progress.d.ts.map +1 -1
  28. package/dist/src/retry.d.ts +26 -0
  29. package/dist/src/retry.d.ts.map +1 -0
  30. package/dist/src/review.d.ts +127 -0
  31. package/dist/src/review.d.ts.map +1 -0
  32. package/dist/src/rules.d.ts +10 -0
  33. package/dist/src/rules.d.ts.map +1 -0
  34. package/dist/src/testcmd.d.ts +1 -0
  35. package/dist/src/testcmd.d.ts.map +1 -1
  36. package/dist/src/worktree.d.ts +30 -1
  37. package/dist/src/worktree.d.ts.map +1 -1
  38. package/dist/testcmd.js +13 -0
  39. package/dist/testcmd.js.map +2 -2
  40. package/dist/worktree.js +236 -32
  41. package/dist/worktree.js.map +2 -2
  42. package/package.json +4 -3
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;
@@ -1141,8 +1336,20 @@ function detectTestCmd(projectDir) {
1141
1336
  }
1142
1337
  }
1143
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");
1144
1351
  function runTestCmd(opts) {
1145
- return new Promise((resolve2) => {
1352
+ return new Promise((resolve3) => {
1146
1353
  let settled = false;
1147
1354
  const child = spawn("sh", ["-c", opts.cmd], {
1148
1355
  cwd: opts.cwd,
@@ -1174,7 +1381,7 @@ function runTestCmd(opts) {
1174
1381
  opts.registry.delete(child.pid);
1175
1382
  }
1176
1383
  chunks.push(String(err));
1177
- resolve2({
1384
+ resolve3({
1178
1385
  exitCode: 1,
1179
1386
  output: chunks.join("")
1180
1387
  });
@@ -1186,7 +1393,7 @@ function runTestCmd(opts) {
1186
1393
  if (child.pid !== void 0 && opts.registry) {
1187
1394
  opts.registry.delete(child.pid);
1188
1395
  }
1189
- resolve2({
1396
+ resolve3({
1190
1397
  exitCode: code ?? 1,
1191
1398
  output: chunks.join("")
1192
1399
  });
@@ -1195,6 +1402,1061 @@ function runTestCmd(opts) {
1195
1402
  }
1196
1403
  __name(runTestCmd, "runTestCmd");
1197
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
+
1198
2460
  // src/orchestrator.ts
1199
2461
  var execFileAsync4 = promisify4(execFile4);
1200
2462
  function formatErrorMessage(error) {
@@ -1287,8 +2549,8 @@ function scanTickets(dir, feature = "") {
1287
2549
  ));
1288
2550
  return files.map((filename) => {
1289
2551
  const parsed = parseTicketFilename(filename);
1290
- const filepath = join4(dir, filename);
1291
- const body = readFileSync4(filepath, "utf8");
2552
+ const filepath = join6(dir, filename);
2553
+ const body = readFileSync6(filepath, "utf8");
1292
2554
  return {
1293
2555
  num: parsed.num,
1294
2556
  slug: parsed.slug,
@@ -1399,7 +2661,7 @@ function checkItems(ticket, completedItems) {
1399
2661
  if (completedItems.length === 0) return;
1400
2662
  let body;
1401
2663
  try {
1402
- body = readFileSync4(ticket.filepath, "utf8");
2664
+ body = readFileSync6(ticket.filepath, "utf8");
1403
2665
  } catch {
1404
2666
  body = ticket.body;
1405
2667
  }
@@ -1423,7 +2685,7 @@ function checkItems(ticket, completedItems) {
1423
2685
  }
1424
2686
  if (!changed) return;
1425
2687
  body = lines.join("\n");
1426
- writeFileSync3(ticket.filepath, body);
2688
+ writeFileSync5(ticket.filepath, body);
1427
2689
  ticket.body = body;
1428
2690
  }
1429
2691
  __name(checkItems, "checkItems");
@@ -1447,7 +2709,7 @@ __name(checkAllItems, "checkAllItems");
1447
2709
  function markDone(ticket) {
1448
2710
  let body;
1449
2711
  try {
1450
- body = readFileSync4(ticket.filepath, "utf8");
2712
+ body = readFileSync6(ticket.filepath, "utf8");
1451
2713
  } catch {
1452
2714
  body = ticket.body;
1453
2715
  }
@@ -1456,7 +2718,7 @@ function markDone(ticket) {
1456
2718
  /\*\*Status:\*\*\s*.+/i,
1457
2719
  "**Status:** done"
1458
2720
  );
1459
- writeFileSync3(ticket.filepath, updated);
2721
+ writeFileSync5(ticket.filepath, updated);
1460
2722
  ticket.status = "done";
1461
2723
  ticket.body = updated;
1462
2724
  }
@@ -1489,11 +2751,23 @@ function stripSpecSection(specBody, heading) {
1489
2751
  return out.join("\n");
1490
2752
  }
1491
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");
1492
2761
  function lookupSpec(specBodies, fallback, feature) {
1493
2762
  if (!feature || !specBodies) return fallback;
1494
2763
  return specBodies.get(feature) ?? fallback;
1495
2764
  }
1496
2765
  __name(lookupSpec, "lookupSpec");
2766
+ function historyBlock(history) {
2767
+ if (!history) return [];
2768
+ return [history, ""];
2769
+ }
2770
+ __name(historyBlock, "historyBlock");
1497
2771
  function renderPrompt(specBody, ticket, opts) {
1498
2772
  return [
1499
2773
  "---- SPEC CONTEXT ----",
@@ -1503,27 +2777,29 @@ function renderPrompt(specBody, ticket, opts) {
1503
2777
  ticket.filename,
1504
2778
  ticket.body,
1505
2779
  "",
2780
+ ...historyBlock(opts.history),
1506
2781
  "---- AGENT INSTRUCTIONS ----",
1507
2782
  "You are an autonomous coding agent. Implement the ticket using /tdd.",
1508
2783
  "",
1509
2784
  "Follow these rules:",
1510
- "- Read the files directly relevant to the ticket before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.",
2785
+ readDocsRule("ticket"),
1511
2786
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
1512
2787
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
1513
- "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor.",
1514
- `- While iterating, run only the tests relevant to what you changed. Run \`${opts.testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`,
1515
- "- If the project has a lint script, run it and fix what it reports.",
1516
- "- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a checklist item done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and say what blocked you.",
1517
- "- Do NOT read or modify files outside your current directory (the worktree).",
1518
- "- Do NOT run git push.",
1519
- "- Commit after each checklist item, and make sure everything is committed when finished.",
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"),
1520
2796
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
1521
- "- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ticket-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory.",
2797
+ agentsMdRule("ticket"),
1522
2798
  ""
1523
2799
  ].join("\n");
1524
2800
  }
1525
2801
  __name(renderPrompt, "renderPrompt");
1526
- function renderMergePrompt(specBody, ticket) {
2802
+ function renderMergePrompt(specBody, ticket, opts = {}) {
1527
2803
  return [
1528
2804
  "---- SPEC CONTEXT ----",
1529
2805
  specBody,
@@ -1544,6 +2820,7 @@ function renderMergePrompt(specBody, ticket) {
1544
2820
  "4. Commit the resolved files.",
1545
2821
  "",
1546
2822
  "Do NOT re-implement the ticket from scratch.",
2823
+ ...installRule(opts.installCmd),
1547
2824
  "Do NOT read or modify files outside your current directory (the worktree).",
1548
2825
  "Do NOT run git push."
1549
2826
  ].join("\n");
@@ -1640,59 +2917,23 @@ var PROVIDER_ARGS = {
1640
2917
  ],
1641
2918
  // codex removed --full-auto; this is the counterpart
1642
2919
  // of claude's --dangerously-skip-permissions, and the
1643
- // workspace-write sandbox would block commits because
1644
- // a worktree's .git link points outside the tree
1645
- codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
1646
- pi: [
1647
- "-p",
1648
- "--verbose",
1649
- "--approve",
1650
- "--no-session",
1651
- "--mode",
1652
- "json"
1653
- ]
1654
- };
1655
- function getProviderArgs(cmd) {
1656
- const name = basename2(cmd);
1657
- return PROVIDER_ARGS[name] ?? [];
1658
- }
1659
- __name(getProviderArgs, "getProviderArgs");
1660
- var EFFORT_LADDERS = {
1661
- pi: [
1662
- "off",
1663
- "minimal",
1664
- "low",
1665
- "medium",
1666
- "high",
1667
- "xhigh",
1668
- "max"
1669
- ],
1670
- claude: [
1671
- "low",
1672
- "medium",
1673
- "high",
1674
- "xhigh",
1675
- "max"
1676
- ],
1677
- codex: ["low", "medium", "high"]
2920
+ // workspace-write sandbox would block commits because
2921
+ // a worktree's .git link points outside the tree
2922
+ codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
2923
+ pi: [
2924
+ "-p",
2925
+ "--verbose",
2926
+ "--approve",
2927
+ "--no-session",
2928
+ "--mode",
2929
+ "json"
2930
+ ]
1678
2931
  };
1679
- var ALL_EFFORT_LEVELS = [
1680
- "off",
1681
- "minimal",
1682
- "low",
1683
- "medium",
1684
- "high",
1685
- "xhigh",
1686
- "max"
1687
- ];
1688
- function escalateEffort(provider, level) {
1689
- if (!level) return "high";
1690
- const ladder = EFFORT_LADDERS[provider];
1691
- const i = ladder.indexOf(level);
1692
- if (i === -1) return level;
1693
- return ladder[Math.min(i + 1, ladder.length - 1)];
2932
+ function getProviderArgs(cmd) {
2933
+ const name = basename5(cmd);
2934
+ return PROVIDER_ARGS[name] ?? [];
1694
2935
  }
1695
- __name(escalateEffort, "escalateEffort");
2936
+ __name(getProviderArgs, "getProviderArgs");
1696
2937
  function formatStreamLine(line) {
1697
2938
  let obj;
1698
2939
  try {
@@ -1793,8 +3034,8 @@ function createPiStreamFormatter() {
1793
3034
  __name(createPiStreamFormatter, "createPiStreamFormatter");
1794
3035
  function writePromptFile(prompt) {
1795
3036
  const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
1796
- const filePath = join4(tmpdir(), name);
1797
- writeFileSync3(filePath, prompt);
3037
+ const filePath = join6(tmpdir(), name);
3038
+ writeFileSync5(filePath, prompt);
1798
3039
  return filePath;
1799
3040
  }
1800
3041
  __name(writePromptFile, "writePromptFile");
@@ -1835,7 +3076,7 @@ function spawnAgent(opts) {
1835
3076
  if (opts.model) {
1836
3077
  args.push("--model", opts.model);
1837
3078
  }
1838
- const providerName = basename2(opts.agentCmd);
3079
+ const providerName = basename5(opts.agentCmd);
1839
3080
  const isClaude = providerName === "claude";
1840
3081
  const isPi = providerName === "pi";
1841
3082
  if (opts.maxTurns) {
@@ -1969,7 +3210,7 @@ function spawnAgent(opts) {
1969
3210
  proc.stdin.write(opts.prompt);
1970
3211
  }
1971
3212
  proc.stdin.end();
1972
- return new Promise((resolve2) => {
3213
+ return new Promise((resolve3) => {
1973
3214
  let settled = false;
1974
3215
  let graceTimer;
1975
3216
  function finish(exitCode) {
@@ -1995,7 +3236,7 @@ function spawnAgent(opts) {
1995
3236
  rlErr.close();
1996
3237
  logStream.end();
1997
3238
  if (promptFile) cleanupPromptFile(promptFile);
1998
- resolve2({ exitCode, completedItems, timedOut });
3239
+ resolve3({ exitCode, completedItems, timedOut });
1999
3240
  }
2000
3241
  __name(finish, "finish");
2001
3242
  proc.on("exit", (code) => {
@@ -2019,16 +3260,21 @@ function spawnAgent(opts) {
2019
3260
  }
2020
3261
  rl.close();
2021
3262
  rlErr.close();
2022
- logStream.end();
3263
+ const msg = "agent process error: " + formatErrorMessage(err);
3264
+ logStream.write(`nightralph: ${msg}
3265
+ `);
2023
3266
  if (promptFile) cleanupPromptFile(promptFile);
2024
- console.error(
2025
- "Agent process error:",
2026
- formatErrorMessage(err)
2027
- );
2028
- resolve2({
2029
- exitCode: 1,
2030
- completedItems,
2031
- 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
+ });
2032
3278
  });
2033
3279
  });
2034
3280
  });
@@ -2048,6 +3294,8 @@ function dryRun(tickets, specBody, testCmd) {
2048
3294
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
2049
3295
  );
2050
3296
  }
3297
+ console.log(`
3298
+ Test command: ${testCmd || "(none)"}`);
2051
3299
  const firstReady = findReadyTickets(tickets);
2052
3300
  if (firstReady.length > 0) {
2053
3301
  console.log(
@@ -2103,14 +3351,20 @@ Prompt for first ready ticket (${firstReady[0].filename}):
2103
3351
  renderPrompt(
2104
3352
  opts.specBody,
2105
3353
  firstReady[0],
2106
- { testCmd: opts.testCmd }
3354
+ {
3355
+ testCmd: opts.testCmd,
3356
+ installCmd: opts.installCmd
3357
+ }
2107
3358
  )
2108
3359
  );
2109
3360
  }
2110
3361
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
2111
3362
  console.log(
2112
3363
  `
2113
- Test command: ${opts.testCmd}`
3364
+ Test command: ${opts.testCmd || "(none)"}`
3365
+ );
3366
+ console.log(
3367
+ `Install: ${opts.installCmd || "(none)"}`
2114
3368
  );
2115
3369
  console.log(
2116
3370
  `Effort: ${opts.effort ?? "(provider default)"}`
@@ -2131,7 +3385,34 @@ Merge strategy: ${strategyDesc}`
2131
3385
  console.log(`Progress file: ${opts.progressPath}`);
2132
3386
  }
2133
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");
2134
3411
  async function runTestGate(opts) {
3412
+ if (opts.testCmd === "") {
3413
+ opts.log(` ${opts.ticket.filename}: no test gate`);
3414
+ return true;
3415
+ }
2135
3416
  const result = await runTestCmd({
2136
3417
  cmd: opts.testCmd,
2137
3418
  cwd: opts.worktreePath,
@@ -2144,7 +3425,7 @@ async function runTestGate(opts) {
2144
3425
  opts.attempt ?? 1,
2145
3426
  ".test.log"
2146
3427
  );
2147
- writeFileSync3(testLogPath, result.output);
3428
+ writeFileSync5(testLogPath, result.output);
2148
3429
  if (result.exitCode !== 0) {
2149
3430
  opts.log(
2150
3431
  ` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
@@ -2173,11 +3454,24 @@ async function respawnAndRetryMerge(opts) {
2173
3454
  } else {
2174
3455
  console.log(msg1);
2175
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
+ }
2176
3469
  const prompt = renderMergePrompt(
2177
3470
  opts.specBody,
2178
- opts.ticket
3471
+ opts.ticket,
3472
+ { installCmd: opts.installCmd }
2179
3473
  );
2180
- const logPath = join4(
3474
+ const logPath2 = join6(
2181
3475
  opts.logsDir,
2182
3476
  opts.ticket.filename.replace(/\.md$/, ".respawn.log")
2183
3477
  );
@@ -2190,7 +3484,7 @@ async function respawnAndRetryMerge(opts) {
2190
3484
  agentCmd: opts.agentCmd,
2191
3485
  model: opts.model,
2192
3486
  timeout: opts.timeout,
2193
- logPath,
3487
+ logPath: logPath2,
2194
3488
  cwd: opts.worktreePath,
2195
3489
  label,
2196
3490
  maxTurns: opts.maxTurns,
@@ -2274,20 +3568,31 @@ async function runPool(items, limit, fn) {
2274
3568
  return results;
2275
3569
  }
2276
3570
  __name(runPool, "runPool");
2277
- function retryDelayFor(base, attempt) {
2278
- return base * 2 ** (attempt - 2);
2279
- }
2280
- __name(retryDelayFor, "retryDelayFor");
2281
- function sleep(ms) {
2282
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2283
- }
2284
- __name(sleep, "sleep");
2285
3571
  function attemptLogPath(logsDir, ticket, attempt, ext) {
2286
3572
  const stem = ticket.filename.replace(/\.md$/, "");
2287
3573
  const suffix = attempt > 1 ? `.attempt${attempt}` : "";
2288
- return join4(logsDir, `${stem}${suffix}${ext}`);
3574
+ return join6(logsDir, `${stem}${suffix}${ext}`);
2289
3575
  }
2290
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");
2291
3596
  async function runTicketAttempt(opts) {
2292
3597
  const { ticket, d } = opts;
2293
3598
  let worktree;
@@ -2306,6 +3611,38 @@ async function runTicketAttempt(opts) {
2306
3611
  "in-progress",
2307
3612
  ticket.feature
2308
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
+ }
2309
3646
  const ticketSpec = lookupSpec(
2310
3647
  opts.specBodies,
2311
3648
  opts.specBody,
@@ -2315,7 +3652,17 @@ async function runTicketAttempt(opts) {
2315
3652
  prompt: renderPrompt(
2316
3653
  ticketSpec,
2317
3654
  ticket,
2318
- { 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
+ }
2319
3666
  ),
2320
3667
  agentCmd: opts.agentCmd,
2321
3668
  model: opts.model,
@@ -2332,6 +3679,20 @@ async function runTicketAttempt(opts) {
2332
3679
  effort: opts.effort,
2333
3680
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2334
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
+ };
2335
3696
  if (agentResult.timedOut) {
2336
3697
  d.log(
2337
3698
  ` ${ticket.filename}: timed out; keeping the work it committed`
@@ -2346,7 +3707,8 @@ async function runTicketAttempt(opts) {
2346
3707
  reason: "exit",
2347
3708
  branchName: worktree.branchName,
2348
3709
  exitCode: agentResult.exitCode,
2349
- completedItems: agentResult.completedItems
3710
+ completedItems: agentResult.completedItems,
3711
+ agentRun
2350
3712
  };
2351
3713
  }
2352
3714
  if (isDirty(worktree.worktreePath)) {
@@ -2369,7 +3731,8 @@ async function runTicketAttempt(opts) {
2369
3731
  return {
2370
3732
  kind: "no-commits",
2371
3733
  worktreePath: worktree.worktreePath,
2372
- branchName: worktree.branchName
3734
+ branchName: worktree.branchName,
3735
+ agentRun
2373
3736
  };
2374
3737
  }
2375
3738
  const passed = await runTestGate({
@@ -2392,44 +3755,130 @@ async function runTicketAttempt(opts) {
2392
3755
  reason: "tests",
2393
3756
  branchName: worktree.branchName,
2394
3757
  exitCode: 1,
2395
- completedItems: agentResult.completedItems
3758
+ completedItems: agentResult.completedItems,
3759
+ agentRun
2396
3760
  };
2397
3761
  }
2398
3762
  return {
2399
3763
  kind: "ready",
2400
3764
  worktreePath: worktree.worktreePath,
2401
3765
  branchName: worktree.branchName,
2402
- completedItems: agentResult.completedItems
3766
+ completedItems: agentResult.completedItems,
3767
+ agentRun
2403
3768
  };
2404
3769
  }
2405
3770
  __name(runTicketAttempt, "runTicketAttempt");
2406
- async function runTicketAttempts(opts) {
2407
- const { ticket, d } = opts;
2408
- const maxAttempts = opts.retries + 1;
2409
- const providerName = basename2(opts.agentCmd);
2410
- const isKnown = providerName in EFFORT_LADDERS;
2411
- let effort = opts.effort;
2412
- let outcome = {
2413
- kind: "no-commits",
2414
- worktreePath: "",
2415
- branchName: ""
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()
2416
3786
  };
2417
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2418
- if (attempt === 1) {
2419
- d.log(` Starting ${ticket.filename}`);
2420
- } else {
2421
- if (isKnown) {
2422
- effort = escalateEffort(
2423
- providerName,
2424
- effort
2425
- );
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
+ }
2426
3826
  }
2427
- const delay = retryDelayFor(opts.retryDelay, attempt);
2428
- d.log(
2429
- ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
2430
- );
2431
- if (delay > 0) await sleep(delay * 1e3);
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;
2432
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;
2433
3882
  try {
2434
3883
  outcome = await runTicketAttempt({
2435
3884
  ...opts,
@@ -2440,25 +3889,46 @@ async function runTicketAttempts(opts) {
2440
3889
  d.log(
2441
3890
  ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
2442
3891
  );
2443
- return { kind: "error", error };
3892
+ const errorOutcome = { kind: "error", error };
3893
+ record(errorOutcome);
3894
+ return { result: errorOutcome, stop: true };
2444
3895
  }
3896
+ record(outcome);
2445
3897
  if (outcome.kind === "ready") {
2446
3898
  d.setTicketStatus(
2447
3899
  ticket.num,
2448
3900
  "awaiting-merge",
2449
3901
  ticket.feature
2450
3902
  );
2451
- return outcome;
3903
+ return { result: outcome, stop: true };
2452
3904
  }
2453
- if (outcome.kind === "worktree-error") return outcome;
2454
- }
2455
- return outcome;
3905
+ return {
3906
+ result: outcome,
3907
+ stop: outcome.kind === "worktree-error"
3908
+ };
3909
+ });
2456
3910
  }
2457
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");
2458
3926
  async function saveProgress(repoRoot, progressPath, progress, message, d) {
2459
3927
  writeProgress(progressPath, progress);
3928
+ const learningsPath = learningsPathFor(progressPath);
3929
+ const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
2460
3930
  try {
2461
- await commitProgress(repoRoot, progressPath, message);
3931
+ await commitProgress(repoRoot, paths, message);
2462
3932
  } catch (err) {
2463
3933
  d.log(
2464
3934
  " Failed to commit progress: " + formatErrorMessage(err)
@@ -2559,12 +4029,12 @@ function runProgress(tickets) {
2559
4029
  }
2560
4030
  __name(runProgress, "runProgress");
2561
4031
  async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
2562
- mkdirSync2(logsDir, { recursive: true });
4032
+ mkdirSync3(logsDir, { recursive: true });
2563
4033
  const d = display ?? createDisplay();
2564
4034
  const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
2565
4035
  const delayBase = retryDelay ?? 0;
2566
4036
  const poolSize = concurrency ?? DEFAULT_CONCURRENCY;
2567
- const providerName = basename2(agentCmd);
4037
+ const providerName = basename5(agentCmd);
2568
4038
  const isKnown = providerName in EFFORT_LADDERS;
2569
4039
  let totalWaves = 0;
2570
4040
  for (const _ of simulateWaves(tickets)) totalWaves++;
@@ -2719,7 +4189,7 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
2719
4189
  }
2720
4190
  __name(runWaves, "runWaves");
2721
4191
  async function runWavesIsolated(opts) {
2722
- mkdirSync2(opts.logsDir, { recursive: true });
4192
+ mkdirSync3(opts.logsDir, { recursive: true });
2723
4193
  const d = opts.display ?? createDisplay();
2724
4194
  const retries = opts.retries ?? DEFAULT_RETRIES;
2725
4195
  const retryDelay = opts.retryDelay ?? 0;
@@ -2734,12 +4204,13 @@ async function runWavesIsolated(opts) {
2734
4204
  "Working tree is dirty; commit or stash changes before running"
2735
4205
  );
2736
4206
  }
4207
+ const launchCommit = headCommit(repoRoot);
2737
4208
  const features = new Set(
2738
4209
  opts.tickets.map((t) => t.feature).filter(Boolean)
2739
4210
  );
2740
4211
  const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
2741
- const fallbackProgressPath = join4(
2742
- dirname2(opts.logsDir),
4212
+ const fallbackProgressPath = join6(
4213
+ dirname4(opts.logsDir),
2743
4214
  "progress.md"
2744
4215
  );
2745
4216
  let tracker;
@@ -2837,13 +4308,34 @@ async function runWavesIsolated(opts) {
2837
4308
  timeout: opts.timeout,
2838
4309
  logsDir: opts.logsDir,
2839
4310
  testCmd: opts.testCmd,
4311
+ installCmd: opts.installCmd,
2840
4312
  maxTurns: opts.maxTurns,
2841
4313
  effort: opts.effort,
4314
+ learningsPath: learningsPathFor(
4315
+ tracker.pathFor(t.feature)
4316
+ ),
2842
4317
  retries,
2843
4318
  retryDelay,
2844
4319
  d
2845
4320
  })
2846
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
+ }
2847
4339
  const outcomes = settled.map(
2848
4340
  (r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
2849
4341
  );
@@ -2947,7 +4439,7 @@ async function runWavesIsolated(opts) {
2947
4439
  repoRoot,
2948
4440
  tracker.pathFor(ticket.feature),
2949
4441
  tracker.dataFor(ticket.feature),
2950
- `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
4442
+ `nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
2951
4443
  d
2952
4444
  );
2953
4445
  continue;
@@ -2983,6 +4475,7 @@ async function runWavesIsolated(opts) {
2983
4475
  branches: successfulBranches,
2984
4476
  strategy: opts.conflictStrategy
2985
4477
  });
4478
+ const doneThisWave = [];
2986
4479
  for (const branchResult of mergeResult.merged) {
2987
4480
  const bk = branchToKey.get(
2988
4481
  branchResult.branch
@@ -2991,6 +4484,7 @@ async function runWavesIsolated(opts) {
2991
4484
  if (!ticket) continue;
2992
4485
  if (branchResult.merged) {
2993
4486
  markDone(ticket);
4487
+ doneThisWave.push(ticket);
2994
4488
  waveCompleted++;
2995
4489
  totalCompleted++;
2996
4490
  d.setTicketStatus(
@@ -3040,11 +4534,13 @@ async function runWavesIsolated(opts) {
3040
4534
  timeout: opts.timeout,
3041
4535
  logsDir: opts.logsDir,
3042
4536
  testCmd: opts.testCmd,
4537
+ installCmd: opts.installCmd,
3043
4538
  maxTurns: opts.maxTurns,
3044
4539
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
3045
4540
  }) : false;
3046
4541
  if (recovered) {
3047
4542
  markDone(ticket);
4543
+ doneThisWave.push(ticket);
3048
4544
  waveCompleted++;
3049
4545
  totalCompleted++;
3050
4546
  d.setTicketStatus(
@@ -3100,6 +4596,28 @@ async function runWavesIsolated(opts) {
3100
4596
  exitCode: 0,
3101
4597
  done: false
3102
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
+ }
3103
4621
  await saveProgress(
3104
4622
  repoRoot,
3105
4623
  tracker.pathFor(ticket.feature),
@@ -3110,6 +4628,7 @@ async function runWavesIsolated(opts) {
3110
4628
  }
3111
4629
  }
3112
4630
  }
4631
+ await commitTicketFiles(repoRoot, doneThisWave, d);
3113
4632
  if (mergeResult.stoppedAt) {
3114
4633
  d.log(
3115
4634
  `
@@ -3152,6 +4671,41 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
3152
4671
  }
3153
4672
  }
3154
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)
4703
+ );
4704
+ } catch (err) {
4705
+ d.log(`
4706
+ Review failed: ${formatErrorMessage(err)}`);
4707
+ }
4708
+ }
3155
4709
  const remaining = opts.tickets.filter(
3156
4710
  (t) => t.status === "ready-for-agent"
3157
4711
  );
@@ -3160,23 +4714,55 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
3160
4714
  totalCompleted,
3161
4715
  failedKeys
3162
4716
  ));
4717
+ if (reviewLine) d.log(reviewLine);
4718
+ d.log(`Work is on ${baseBranch}`);
3163
4719
  d.cleanup();
3164
- return { allDone: remaining.length === 0 };
4720
+ return { allDone: remaining.length === 0, branch: baseBranch };
3165
4721
  }
3166
4722
  __name(runWavesIsolated, "runWavesIsolated");
3167
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
+
3168
4754
  // src/resolve.ts
3169
- import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
3170
- import { dirname as dirname3, join as join5 } from "node:path";
4755
+ import { existsSync as existsSync6, readdirSync as readdirSync3 } from "node:fs";
4756
+ import { dirname as dirname5, join as join8 } from "node:path";
3171
4757
  import { fileURLToPath } from "node:url";
3172
4758
  import { checkbox, Separator } from "@inquirer/prompts";
3173
4759
  function getScriptDir(metaUrl) {
3174
- return dirname3(fileURLToPath(metaUrl));
4760
+ return dirname5(fileURLToPath(metaUrl));
3175
4761
  }
3176
4762
  __name(getScriptDir, "getScriptDir");
3177
4763
  function findFeatureDirs(cwd) {
3178
- const scratchDir = join5(cwd, ".scratch");
3179
- if (!existsSync4(scratchDir)) return [];
4764
+ const scratchDir = join8(cwd, ".scratch");
4765
+ if (!existsSync6(scratchDir)) return [];
3180
4766
  const entries = readdirSync3(
3181
4767
  scratchDir,
3182
4768
  { withFileTypes: true }
@@ -3184,18 +4770,18 @@ function findFeatureDirs(cwd) {
3184
4770
  const results = [];
3185
4771
  for (const entry of entries) {
3186
4772
  if (!entry.isDirectory()) continue;
3187
- const issuesDir = join5(
4773
+ const issuesDir = join8(
3188
4774
  scratchDir,
3189
4775
  entry.name,
3190
4776
  "issues"
3191
4777
  );
3192
- const specFile = join5(
4778
+ const specFile = join8(
3193
4779
  scratchDir,
3194
4780
  entry.name,
3195
4781
  "spec.md"
3196
4782
  );
3197
- if (!existsSync4(issuesDir)) continue;
3198
- const progressFile = join5(
4783
+ if (!existsSync6(issuesDir)) continue;
4784
+ const progressFile = join8(
3199
4785
  scratchDir,
3200
4786
  entry.name,
3201
4787
  "progress.md"
@@ -3214,10 +4800,10 @@ __name(findFeatureDirs, "findFeatureDirs");
3214
4800
  var ALL_VALUE = "__all__";
3215
4801
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
3216
4802
  if (specName) {
3217
- const featureDir = join5(cwd, ".scratch", specName);
4803
+ const featureDir = join8(cwd, ".scratch", specName);
3218
4804
  return [{
3219
- dir: join5(featureDir, "issues"),
3220
- spec: join5(featureDir, "spec.md"),
4805
+ dir: join8(featureDir, "issues"),
4806
+ spec: join8(featureDir, "spec.md"),
3221
4807
  name: specName
3222
4808
  }];
3223
4809
  }
@@ -3296,8 +4882,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3296
4882
  }
3297
4883
  }
3298
4884
  setup({
3299
- bundledSkillsDir: join6(scriptDir, "skills"),
3300
- docsTemplatesDir: join6(
4885
+ bundledSkillsDir: join9(scriptDir, "skills"),
4886
+ docsTemplatesDir: join9(
3301
4887
  scriptDir,
3302
4888
  "docs-templates"
3303
4889
  ),
@@ -3332,7 +4918,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3332
4918
  default: 3600
3333
4919
  }).option("test-cmd", {
3334
4920
  type: "string",
3335
- 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.`
3336
4925
  }).option("X", {
3337
4926
  type: "boolean",
3338
4927
  describe: "Stop on merge conflict instead of re-spawning",
@@ -3356,6 +4945,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3356
4945
  type: "number",
3357
4946
  default: DEFAULT_CONCURRENCY,
3358
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."
3359
4952
  }),
3360
4953
  async (argv) => {
3361
4954
  await runExecute({
@@ -3365,15 +4958,26 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
3365
4958
  dryRun: argv.dryRun,
3366
4959
  timeout: argv.timeout,
3367
4960
  testCmd: argv.testCmd,
4961
+ installCmd: argv.installCmd,
3368
4962
  stopOnConflict: argv.X,
3369
4963
  maxTurns: argv.maxTurns ?? argv.turns,
3370
4964
  effort: argv.effort,
3371
4965
  retries: argv.retries,
3372
4966
  retryDelay: argv.retryDelay,
3373
- concurrency: argv.concurrency
4967
+ concurrency: argv.concurrency,
4968
+ review: argv.review
3374
4969
  });
3375
4970
  }
3376
- ).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();
3377
4981
  function isGitRepo() {
3378
4982
  try {
3379
4983
  getRepoRoot();
@@ -3394,41 +4998,36 @@ function extractFeatureName(spec) {
3394
4998
  return parts[parts.length - 2] ?? parts[parts.length - 1] ?? "feature";
3395
4999
  }
3396
5000
  __name(extractFeatureName, "extractFeatureName");
3397
- async function runExecute(argv) {
3398
- const selections = await resolveIssuesDir(
3399
- argv.spec
3400
- );
5001
+ function loadTickets(selections, log) {
3401
5002
  const allTickets = [];
3402
5003
  const specBodies = /* @__PURE__ */ new Map();
3403
5004
  const progressPaths = /* @__PURE__ */ new Map();
5005
+ const issuesDirs = [];
3404
5006
  let firstFeatureName = "";
3405
5007
  let firstLogsDir = "";
5008
+ const missing = [];
3406
5009
  for (const selection of selections) {
3407
5010
  const { dir, spec, name } = selection;
3408
- if (!existsSync5(dir)) {
3409
- console.error(
3410
- `Directory not found: ${dir}`
3411
- );
3412
- process.exit(2);
5011
+ if (!existsSync7(dir)) {
5012
+ missing.push(`Directory not found: ${dir}`);
5013
+ continue;
3413
5014
  }
3414
- if (!existsSync5(spec)) {
3415
- console.error(
3416
- `Spec file not found: ${spec}`
3417
- );
3418
- process.exit(2);
5015
+ if (!existsSync7(spec)) {
5016
+ missing.push(`Spec file not found: ${spec}`);
5017
+ continue;
3419
5018
  }
3420
5019
  const specBody = stripSpecSection(
3421
- readFileSync5(spec, "utf8"),
5020
+ readFileSync7(spec, "utf8"),
3422
5021
  "User Stories"
3423
5022
  );
3424
5023
  const tickets = scanTickets(dir, name);
3425
5024
  if (tickets.length === 0) {
3426
- console.log(
5025
+ log(
3427
5026
  `No ticket files found for ${name}. Skipping.`
3428
5027
  );
3429
5028
  continue;
3430
5029
  }
3431
- console.log(
5030
+ log(
3432
5031
  `[${name}] Found ${tickets.length} tickets`
3433
5032
  );
3434
5033
  const knownNums = new Set(
@@ -3439,7 +5038,7 @@ async function runExecute(argv) {
3439
5038
  (b) => !knownNums.has(b)
3440
5039
  );
3441
5040
  if (unknown.length > 0) {
3442
- console.warn(
5041
+ log(
3443
5042
  `${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
3444
5043
  );
3445
5044
  }
@@ -3450,24 +5049,128 @@ async function runExecute(argv) {
3450
5049
  specBodies.set(name, specBody);
3451
5050
  progressPaths.set(
3452
5051
  name,
3453
- join6(dirname4(dir), "progress.md")
5052
+ join9(dirname6(dir), "progress.md")
3454
5053
  );
5054
+ issuesDirs.push(dir);
3455
5055
  allTickets.push(...tickets);
3456
5056
  if (!firstFeatureName) {
3457
5057
  firstFeatureName = extractFeatureName(spec);
3458
- 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
+ }
3459
5147
  }
3460
5148
  }
3461
- if (allTickets.length === 0) return;
5149
+ const {
5150
+ allTickets,
5151
+ specBodies,
5152
+ progressPaths,
5153
+ firstFeatureName,
5154
+ firstLogsDir
5155
+ } = loaded;
3462
5156
  const timeout = argv.timeout ?? 900;
3463
- const testCmd = argv.testCmd || detectTestCmd(
3464
- isGitRepo() ? getRepoRoot() : process.cwd()
3465
- ) || "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);
3466
5166
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
3467
5167
  const logsDir = firstLogsDir;
3468
5168
  const fallbackSpec = specBodies.values().next().value;
3469
5169
  if (argv.dryRun) {
3470
- if (isGitRepo()) {
5170
+ console.log(
5171
+ `Provider: ${providerPath ?? providerMissing}`
5172
+ );
5173
+ if (isolated) {
3471
5174
  const repoName = getRepoName();
3472
5175
  dryRunIsolated({
3473
5176
  tickets: allTickets,
@@ -3476,11 +5179,20 @@ async function runExecute(argv) {
3476
5179
  conflictStrategy,
3477
5180
  progressPath: progressPaths.values().next().value,
3478
5181
  testCmd,
5182
+ installCmd,
3479
5183
  effort: argv.effort,
3480
5184
  retries: argv.retries,
3481
5185
  retryDelay: argv.retryDelay,
3482
5186
  concurrency: argv.concurrency
3483
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}`);
3484
5196
  } else {
3485
5197
  dryRun(allTickets, fallbackSpec, testCmd);
3486
5198
  }
@@ -3496,12 +5208,12 @@ async function runExecute(argv) {
3496
5208
  process.on("SIGINT", () => onSignal(130));
3497
5209
  process.on("SIGTERM", () => onSignal(143));
3498
5210
  let result;
3499
- if (isGitRepo()) {
5211
+ if (isolated) {
3500
5212
  result = await runWavesIsolated({
3501
5213
  tickets: allTickets,
3502
5214
  specBody: fallbackSpec,
3503
5215
  specBodies,
3504
- agentCmd: argv.provider,
5216
+ agentCmd,
3505
5217
  model: argv.model ?? null,
3506
5218
  timeout,
3507
5219
  logsDir,
@@ -3509,18 +5221,23 @@ async function runExecute(argv) {
3509
5221
  conflictStrategy,
3510
5222
  display,
3511
5223
  testCmd,
5224
+ installCmd,
3512
5225
  maxTurns: argv.maxTurns,
3513
5226
  effort: argv.effort,
3514
5227
  retries: argv.retries,
3515
5228
  retryDelay: argv.retryDelay,
3516
5229
  concurrency: argv.concurrency,
3517
- progressPaths
5230
+ progressPaths,
5231
+ review: argv.review === false ? void 0 : {
5232
+ reviewPath: reviewPathFor(loaded.issuesDirs),
5233
+ forkedFrom: plan?.forkedFrom ?? void 0
5234
+ }
3518
5235
  });
3519
5236
  } else {
3520
5237
  result = await runWaves(
3521
5238
  allTickets,
3522
5239
  fallbackSpec,
3523
- argv.provider,
5240
+ agentCmd,
3524
5241
  argv.model ?? null,
3525
5242
  timeout,
3526
5243
  logsDir,