nightralph 0.0.19 → 0.0.22

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 (38) hide show
  1. package/README.md +74 -10
  2. package/dist/display.js +0 -12
  3. package/dist/display.js.map +2 -2
  4. package/dist/docs-templates/issue-tracker-github.md +15 -85
  5. package/dist/docs-templates/issue-tracker.md +16 -10
  6. package/dist/index.js +681 -191
  7. package/dist/index.js.map +4 -4
  8. package/dist/meta.json +68 -14
  9. package/dist/orchestrator.js +517 -142
  10. package/dist/orchestrator.js.map +3 -3
  11. package/dist/progress.js +10 -0
  12. package/dist/progress.js.map +2 -2
  13. package/dist/setup.js +2 -1
  14. package/dist/setup.js.map +2 -2
  15. package/dist/skills/codebase-design/DEEPENING.md +37 -0
  16. package/dist/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  17. package/dist/skills/codebase-design/SKILL.md +114 -0
  18. package/dist/skills/codebase-design/agents/openai.yaml +3 -0
  19. package/dist/skills/to-spec/SKILL.md +1 -1
  20. package/dist/skills/to-tickets/SKILL.md +2 -2
  21. package/dist/src/display.d.ts +0 -1
  22. package/dist/src/display.d.ts.map +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/orchestrator.d.ts +25 -2
  25. package/dist/src/orchestrator.d.ts.map +1 -1
  26. package/dist/src/progress.d.ts +1 -1
  27. package/dist/src/progress.d.ts.map +1 -1
  28. package/dist/src/setup.d.ts +1 -1
  29. package/dist/src/setup.d.ts.map +1 -1
  30. package/dist/src/testcmd.d.ts +11 -0
  31. package/dist/src/testcmd.d.ts.map +1 -0
  32. package/dist/src/worktree.d.ts +3 -1
  33. package/dist/src/worktree.d.ts.map +1 -1
  34. package/dist/testcmd.js +67 -0
  35. package/dist/testcmd.js.map +7 -0
  36. package/dist/worktree.js +19 -6
  37. package/dist/worktree.js.map +2 -2
  38. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ 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 existsSync4, readFileSync as readFileSync4 } from "node:fs";
7
- import { dirname as dirname3, join as join5 } from "node:path";
6
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
7
+ import { dirname as dirname3, join as join6 } from "node:path";
8
8
  import yargs from "yargs";
9
9
  import { hideBin } from "yargs/helpers";
10
10
  import { select as select2 } from "@inquirer/prompts";
@@ -27,7 +27,8 @@ var SKILL_DIRS = [
27
27
  "domain-modeling",
28
28
  "to-spec",
29
29
  "to-tickets",
30
- "tdd"
30
+ "tdd",
31
+ "codebase-design"
31
32
  ];
32
33
  var DOC_TEMPLATES = [
33
34
  "triage-labels.md",
@@ -181,17 +182,17 @@ Source: ${upstream.repo} @ ${upstream.commit}`
181
182
  __name(setup, "setup");
182
183
 
183
184
  // src/orchestrator.ts
184
- import { spawn } from "node:child_process";
185
+ import { spawn as spawn2 } from "node:child_process";
185
186
  import {
186
187
  createWriteStream,
187
188
  mkdirSync as mkdirSync2,
188
- readFileSync as readFileSync3,
189
+ readFileSync as readFileSync4,
189
190
  writeFileSync as writeFileSync3,
190
191
  unlinkSync,
191
192
  readdirSync as readdirSync2
192
193
  } from "node:fs";
193
194
  import { tmpdir } from "node:os";
194
- import { join as join3, dirname, basename as basename2 } from "node:path";
195
+ import { join as join4, dirname, basename as basename2 } from "node:path";
195
196
  import { createInterface } from "node:readline";
196
197
 
197
198
  // src/worktree.ts
@@ -235,7 +236,7 @@ async function branchExists(repoRoot, branchName) {
235
236
  }
236
237
  }
237
238
  __name(branchExists, "branchExists");
238
- async function createWorktree(opts) {
239
+ async function createWorktreeNow(opts) {
239
240
  const { repoRoot, repoName, baseBranch, ticket } = opts;
240
241
  const branchName = `${repoName}/${String(
241
242
  ticket.num
@@ -286,12 +287,25 @@ async function createWorktree(opts) {
286
287
  );
287
288
  return { worktreePath, branchName };
288
289
  }
290
+ __name(createWorktreeNow, "createWorktreeNow");
291
+ var worktreeQueue = Promise.resolve();
292
+ function createWorktree(opts) {
293
+ const result = worktreeQueue.then(() => createWorktreeNow(opts));
294
+ worktreeQueue = result.catch(() => {
295
+ });
296
+ return result;
297
+ }
289
298
  __name(createWorktree, "createWorktree");
290
- async function removeWorktree(worktreePath) {
291
- await execFileAsync(
292
- "git",
293
- ["-C", worktreePath, "worktree", "remove", worktreePath]
294
- );
299
+ async function removeWorktree(worktreePath, opts) {
300
+ const args = [
301
+ "-C",
302
+ worktreePath,
303
+ "worktree",
304
+ "remove"
305
+ ];
306
+ if (opts?.force) args.push("--force");
307
+ args.push(worktreePath);
308
+ await execFileAsync("git", args);
295
309
  }
296
310
  __name(removeWorktree, "removeWorktree");
297
311
  function hasNewCommits(repoRoot, base, branch) {
@@ -482,11 +496,21 @@ async function commitProgress(repoRoot, progressPath, message) {
482
496
  ["add", "--force", progressPath],
483
497
  { cwd: repoRoot }
484
498
  );
499
+ try {
500
+ await execFileAsync2(
501
+ "git",
502
+ ["diff", "--cached", "--quiet", "--", progressPath],
503
+ { cwd: repoRoot }
504
+ );
505
+ return false;
506
+ } catch {
507
+ }
485
508
  await execFileAsync2(
486
509
  "git",
487
510
  ["commit", "-m", message, "--", progressPath],
488
511
  { cwd: repoRoot }
489
512
  );
513
+ return true;
490
514
  }
491
515
  __name(commitProgress, "commitProgress");
492
516
 
@@ -768,7 +792,6 @@ var StatusDisplay = class {
768
792
  config;
769
793
  viewportStart = 0;
770
794
  resizeListener = null;
771
- signalHandler = null;
772
795
  constructor(config) {
773
796
  this.config = {
774
797
  stream: config?.stream ?? process.stdout
@@ -810,13 +833,6 @@ var StatusDisplay = class {
810
833
  this.resizeListener
811
834
  );
812
835
  }
813
- if (this.signalHandler) {
814
- process.removeListener("SIGINT", this.signalHandler);
815
- process.removeListener("SIGTERM", this.signalHandler);
816
- }
817
- this.signalHandler = () => this.cleanup();
818
- process.on("SIGINT", this.signalHandler);
819
- process.on("SIGTERM", this.signalHandler);
820
836
  this.render();
821
837
  }
822
838
  setTicketStatus(num, status) {
@@ -852,10 +868,6 @@ var StatusDisplay = class {
852
868
  if (this.resizeListener && this.config.stream.isTTY) {
853
869
  this.config.stream.removeListener("resize", this.resizeListener);
854
870
  }
855
- if (this.signalHandler) {
856
- process.removeListener("SIGINT", this.signalHandler);
857
- process.removeListener("SIGTERM", this.signalHandler);
858
- }
859
871
  this.config.stream.write(ansi.cursorShow());
860
872
  }
861
873
  render() {
@@ -988,11 +1000,83 @@ function createDisplay(stream) {
988
1000
  }
989
1001
  __name(createDisplay, "createDisplay");
990
1002
 
1003
+ // src/testcmd.ts
1004
+ import { spawn } from "node:child_process";
1005
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1006
+ import { join as join3 } from "node:path";
1007
+ function detectTestCmd(projectDir) {
1008
+ const pkgPath = join3(projectDir, "package.json");
1009
+ if (!existsSync3(pkgPath)) return null;
1010
+ try {
1011
+ const pkg = JSON.parse(
1012
+ readFileSync3(pkgPath, "utf8")
1013
+ );
1014
+ return pkg.scripts?.test ? "npm test" : null;
1015
+ } catch {
1016
+ return null;
1017
+ }
1018
+ }
1019
+ __name(detectTestCmd, "detectTestCmd");
1020
+ function runTestCmd(opts) {
1021
+ return new Promise((resolve) => {
1022
+ let settled = false;
1023
+ const child = spawn("sh", ["-c", opts.cmd], {
1024
+ cwd: opts.cwd,
1025
+ stdio: ["ignore", "pipe", "pipe"],
1026
+ detached: true
1027
+ });
1028
+ const chunks = [];
1029
+ child.stdout.on("data", (d) => chunks.push(String(d)));
1030
+ child.stderr.on("data", (d) => chunks.push(String(d)));
1031
+ const timer = setTimeout(() => {
1032
+ if (child.pid) {
1033
+ try {
1034
+ process.kill(-child.pid, "SIGKILL");
1035
+ } catch {
1036
+ child.kill("SIGKILL");
1037
+ }
1038
+ } else {
1039
+ child.kill("SIGKILL");
1040
+ }
1041
+ }, opts.timeout * 1e3);
1042
+ child.on("error", (err) => {
1043
+ if (settled) return;
1044
+ settled = true;
1045
+ clearTimeout(timer);
1046
+ chunks.push(String(err));
1047
+ resolve({
1048
+ exitCode: 1,
1049
+ output: chunks.join("")
1050
+ });
1051
+ });
1052
+ child.on("close", (code) => {
1053
+ if (settled) return;
1054
+ settled = true;
1055
+ clearTimeout(timer);
1056
+ resolve({
1057
+ exitCode: code ?? 1,
1058
+ output: chunks.join("")
1059
+ });
1060
+ });
1061
+ });
1062
+ }
1063
+ __name(runTestCmd, "runTestCmd");
1064
+
991
1065
  // src/orchestrator.ts
992
1066
  function formatErrorMessage(error) {
993
1067
  return error instanceof Error ? error.message : String(error);
994
1068
  }
995
1069
  __name(formatErrorMessage, "formatErrorMessage");
1070
+ async function removeWorktreeQuietly(worktreePath, d) {
1071
+ try {
1072
+ await removeWorktree(worktreePath, { force: true });
1073
+ } catch (err) {
1074
+ d.log(
1075
+ ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
1076
+ );
1077
+ }
1078
+ }
1079
+ __name(removeWorktreeQuietly, "removeWorktreeQuietly");
996
1080
  var TICKET_RE = /^(\d+)-(.+)\.md$/;
997
1081
  function parseTicketFilename(filename) {
998
1082
  const m = filename.match(TICKET_RE);
@@ -1027,8 +1111,8 @@ function scanTickets(dir) {
1027
1111
  ));
1028
1112
  return files.map((filename) => {
1029
1113
  const parsed = parseTicketFilename(filename);
1030
- const filepath = join3(dir, filename);
1031
- const body = readFileSync3(filepath, "utf8");
1114
+ const filepath = join4(dir, filename);
1115
+ const body = readFileSync4(filepath, "utf8");
1032
1116
  return {
1033
1117
  num: parsed.num,
1034
1118
  slug: parsed.slug,
@@ -1083,7 +1167,39 @@ function markDone(ticket) {
1083
1167
  ticket.body = updated;
1084
1168
  }
1085
1169
  __name(markDone, "markDone");
1086
- function renderPrompt(specBody, ticket) {
1170
+ function stripSpecSection(specBody, heading) {
1171
+ const out = [];
1172
+ let skipping = false;
1173
+ let skipLevel = 0;
1174
+ let inFence = false;
1175
+ for (const line of specBody.split("\n")) {
1176
+ if (/^\s*```/.test(line)) {
1177
+ inFence = !inFence;
1178
+ }
1179
+ if (!inFence) {
1180
+ const m = /^(#{1,6})\s+(.*?)\s*$/.exec(line);
1181
+ if (m) {
1182
+ const level = m[1].length;
1183
+ if (skipping && level <= skipLevel) {
1184
+ skipping = false;
1185
+ }
1186
+ if (!skipping && m[2] === heading) {
1187
+ skipping = true;
1188
+ skipLevel = level;
1189
+ continue;
1190
+ }
1191
+ }
1192
+ }
1193
+ if (!skipping) out.push(line);
1194
+ }
1195
+ return out.join("\n");
1196
+ }
1197
+ __name(stripSpecSection, "stripSpecSection");
1198
+ function renderPrompt(specBody, ticket, opts = {}) {
1199
+ const testRules = opts.testCmd ? [
1200
+ "- Work test-first. For each checklist 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.",
1201
+ `- Run \`${opts.testCmd}\` before committing. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`
1202
+ ] : [];
1087
1203
  return [
1088
1204
  "---- SPEC CONTEXT ----",
1089
1205
  specBody,
@@ -1095,10 +1211,12 @@ function renderPrompt(specBody, ticket) {
1095
1211
  "---- AGENT INSTRUCTIONS ----",
1096
1212
  "You are an autonomous coding agent. Implement the ticket.",
1097
1213
  "",
1098
- "Time is limited. Follow these rules:",
1099
- "- Spend at most 2 minutes reading existing code. Read only the files directly relevant to the ticket.",
1214
+ "Follow these rules:",
1215
+ "- Read the files directly relevant to the ticket before writing code.",
1100
1216
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
1101
- "- Start writing code as soon as you understand the immediate context. You can read more files as needed while implementing.",
1217
+ ...testRules,
1218
+ "- Do NOT read or modify files outside your current directory (the worktree).",
1219
+ "- Do NOT run git push.",
1102
1220
  "- Commit your work when finished.",
1103
1221
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
1104
1222
  ""
@@ -1124,7 +1242,8 @@ function renderMergePrompt(specBody, ticket) {
1124
1242
  "2. Make sure the code compiles and tests pass.",
1125
1243
  "3. Commit the resolved files.",
1126
1244
  "",
1127
- "Do NOT re-implement the ticket from scratch."
1245
+ "Do NOT re-implement the ticket from scratch.",
1246
+ "Do NOT run git push."
1128
1247
  ].join("\n");
1129
1248
  }
1130
1249
  __name(renderMergePrompt, "renderMergePrompt");
@@ -1203,7 +1322,11 @@ var PROVIDER_ARGS = {
1203
1322
  "stream-json",
1204
1323
  "--dangerously-skip-permissions"
1205
1324
  ],
1206
- codex: ["exec", "--full-auto"],
1325
+ // codex removed --full-auto; this is the counterpart
1326
+ // of claude's --dangerously-skip-permissions, and the
1327
+ // workspace-write sandbox would block commits because
1328
+ // a worktree's .git link points outside the tree
1329
+ codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
1207
1330
  pi: [
1208
1331
  "-p",
1209
1332
  "--verbose",
@@ -1218,6 +1341,21 @@ function getProviderArgs(cmd) {
1218
1341
  return PROVIDER_ARGS[name] ?? [];
1219
1342
  }
1220
1343
  __name(getProviderArgs, "getProviderArgs");
1344
+ var THINKING_LEVELS = [
1345
+ "off",
1346
+ "minimal",
1347
+ "low",
1348
+ "medium",
1349
+ "high",
1350
+ "xhigh",
1351
+ "max"
1352
+ ];
1353
+ function escalateThinking(level) {
1354
+ if (!level) return "high";
1355
+ const i = THINKING_LEVELS.indexOf(level);
1356
+ return THINKING_LEVELS[Math.min(i + 1, THINKING_LEVELS.length - 1)];
1357
+ }
1358
+ __name(escalateThinking, "escalateThinking");
1221
1359
  function formatStreamLine(line) {
1222
1360
  let obj;
1223
1361
  try {
@@ -1318,7 +1456,7 @@ function createPiStreamFormatter() {
1318
1456
  __name(createPiStreamFormatter, "createPiStreamFormatter");
1319
1457
  function writePromptFile(prompt) {
1320
1458
  const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
1321
- const filePath = join3(tmpdir(), name);
1459
+ const filePath = join4(tmpdir(), name);
1322
1460
  writeFileSync3(filePath, prompt);
1323
1461
  return filePath;
1324
1462
  }
@@ -1330,6 +1468,22 @@ function cleanupPromptFile(filePath) {
1330
1468
  }
1331
1469
  }
1332
1470
  __name(cleanupPromptFile, "cleanupPromptFile");
1471
+ var CLOSE_GRACE_MS = 2e3;
1472
+ function killProcessGroup(pid) {
1473
+ if (pid === void 0) return;
1474
+ try {
1475
+ process.kill(-pid, "SIGKILL");
1476
+ } catch {
1477
+ }
1478
+ }
1479
+ __name(killProcessGroup, "killProcessGroup");
1480
+ var runningAgents = /* @__PURE__ */ new Set();
1481
+ function killRunningAgents() {
1482
+ for (const pid of runningAgents) {
1483
+ killProcessGroup(pid);
1484
+ }
1485
+ }
1486
+ __name(killRunningAgents, "killRunningAgents");
1333
1487
  function spawnAgent(opts) {
1334
1488
  const args = [
1335
1489
  ...getProviderArgs(opts.agentCmd)
@@ -1337,9 +1491,15 @@ function spawnAgent(opts) {
1337
1491
  if (opts.model) {
1338
1492
  args.push("--model", opts.model);
1339
1493
  }
1494
+ if (opts.maxTurns) {
1495
+ args.push("--max-turns", String(opts.maxTurns));
1496
+ }
1340
1497
  const providerName = basename2(opts.agentCmd);
1341
1498
  const isClaude = providerName === "claude";
1342
1499
  const isPi = providerName === "pi";
1500
+ if (isPi && opts.thinking) {
1501
+ args.push("--thinking", opts.thinking);
1502
+ }
1343
1503
  const prefix = opts.label ? ` [${opts.label}] ` : " ";
1344
1504
  let promptFile = null;
1345
1505
  if (isPi) {
@@ -1350,10 +1510,12 @@ function spawnAgent(opts) {
1350
1510
  opts.logPath,
1351
1511
  { flags: "w" }
1352
1512
  );
1353
- const proc = spawn(opts.agentCmd, args, {
1513
+ const proc = spawn2(opts.agentCmd, args, {
1354
1514
  stdio: ["pipe", "pipe", "pipe"],
1355
- cwd: opts.cwd
1515
+ cwd: opts.cwd,
1516
+ detached: true
1356
1517
  });
1518
+ if (proc.pid !== void 0) runningAgents.add(proc.pid);
1357
1519
  proc.stdout.pipe(logStream);
1358
1520
  const piFormatter = isPi ? createPiStreamFormatter() : null;
1359
1521
  const completedItems = [];
@@ -1407,21 +1569,41 @@ function spawnAgent(opts) {
1407
1569
  console.error(output);
1408
1570
  }
1409
1571
  });
1572
+ let timedOut = false;
1410
1573
  const timeout = setTimeout(() => {
1411
- if (!proc.killed) {
1412
- console.warn(
1413
- `Agent did not exit in ${opts.timeout}s; killing.`
1414
- );
1415
- proc.kill("SIGKILL");
1574
+ if (proc.killed) return;
1575
+ timedOut = true;
1576
+ const msg = `agent timed out after ${opts.timeout}s; killing it`;
1577
+ logStream.write(`nightralph: ${msg}
1578
+ `);
1579
+ if (opts.onLine) {
1580
+ opts.onLine(`${prefix}${msg}`);
1581
+ } else {
1582
+ console.warn(`${prefix}${msg}`);
1416
1583
  }
1584
+ killProcessGroup(proc.pid);
1417
1585
  }, opts.timeout * 1e3);
1586
+ proc.stdin.on("error", (err) => {
1587
+ logStream.write(
1588
+ `nightralph: stdin write failed: ${formatErrorMessage(err)}
1589
+ `
1590
+ );
1591
+ });
1418
1592
  if (!promptFile) {
1419
1593
  proc.stdin.write(opts.prompt);
1420
1594
  }
1421
1595
  proc.stdin.end();
1422
1596
  return new Promise((resolve) => {
1423
- proc.on("close", (code) => {
1597
+ let settled = false;
1598
+ let graceTimer;
1599
+ function finish(exitCode) {
1600
+ if (settled) return;
1601
+ settled = true;
1424
1602
  clearTimeout(timeout);
1603
+ if (graceTimer) clearTimeout(graceTimer);
1604
+ if (proc.pid !== void 0) {
1605
+ runningAgents.delete(proc.pid);
1606
+ }
1425
1607
  if (piFormatter) {
1426
1608
  for (const line of piFormatter.flush()) {
1427
1609
  collectCompleted(line);
@@ -1437,13 +1619,27 @@ function spawnAgent(opts) {
1437
1619
  rlErr.close();
1438
1620
  logStream.end();
1439
1621
  if (promptFile) cleanupPromptFile(promptFile);
1440
- resolve({
1441
- exitCode: code ?? 1,
1442
- completedItems
1443
- });
1622
+ resolve({ exitCode, completedItems, timedOut });
1623
+ }
1624
+ __name(finish, "finish");
1625
+ proc.on("exit", (code) => {
1626
+ graceTimer = setTimeout(() => {
1627
+ proc.stdout.destroy();
1628
+ proc.stderr.destroy();
1629
+ finish(code ?? 1);
1630
+ }, CLOSE_GRACE_MS);
1631
+ });
1632
+ proc.on("close", (code) => {
1633
+ finish(code ?? 1);
1444
1634
  });
1445
1635
  proc.on("error", (err) => {
1636
+ if (settled) return;
1637
+ settled = true;
1638
+ if (graceTimer) clearTimeout(graceTimer);
1446
1639
  clearTimeout(timeout);
1640
+ if (proc.pid !== void 0) {
1641
+ runningAgents.delete(proc.pid);
1642
+ }
1447
1643
  rl.close();
1448
1644
  rlErr.close();
1449
1645
  logStream.end();
@@ -1452,7 +1648,11 @@ function spawnAgent(opts) {
1452
1648
  "Agent process error:",
1453
1649
  formatErrorMessage(err)
1454
1650
  );
1455
- resolve({ exitCode: 1, completedItems });
1651
+ resolve({
1652
+ exitCode: 1,
1653
+ completedItems,
1654
+ timedOut
1655
+ });
1456
1656
  });
1457
1657
  });
1458
1658
  }
@@ -1515,18 +1715,65 @@ Prompt for first ready ticket (${firstReady[0].filename}):
1515
1715
  `
1516
1716
  );
1517
1717
  console.log(
1518
- renderPrompt(opts.specBody, firstReady[0])
1718
+ renderPrompt(
1719
+ opts.specBody,
1720
+ firstReady[0],
1721
+ { testCmd: opts.testCmd }
1722
+ )
1519
1723
  );
1520
1724
  }
1521
1725
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
1522
1726
  console.log(
1523
1727
  `
1728
+ Test command: ${opts.testCmd ?? "(none)"}`
1729
+ );
1730
+ console.log(
1731
+ `Thinking: ${opts.thinking ?? "(provider default)"}`
1732
+ );
1733
+ console.log(
1734
+ `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
1735
+ );
1736
+ console.log(
1737
+ `Retry delay: ${opts.retryDelay ?? DEFAULT_RETRY_DELAY}s`
1738
+ );
1739
+ console.log(
1740
+ `
1524
1741
  Merge strategy: ${strategyDesc}`
1525
1742
  );
1526
1743
  console.log(`Progress file: ${opts.progressPath}`);
1527
1744
  }
1528
1745
  __name(dryRunIsolated, "dryRunIsolated");
1746
+ async function runTestGate(opts) {
1747
+ const result = await runTestCmd({
1748
+ cmd: opts.testCmd,
1749
+ cwd: opts.worktreePath,
1750
+ timeout: opts.timeout
1751
+ });
1752
+ const testLogPath = attemptLogPath(
1753
+ opts.logsDir,
1754
+ opts.ticket,
1755
+ opts.attempt ?? 1,
1756
+ ".test.log"
1757
+ );
1758
+ writeFileSync3(testLogPath, result.output);
1759
+ if (result.exitCode !== 0) {
1760
+ opts.log(
1761
+ ` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
1762
+ );
1763
+ return false;
1764
+ }
1765
+ opts.log(` ${opts.ticket.filename}: tests passed`);
1766
+ return true;
1767
+ }
1768
+ __name(runTestGate, "runTestGate");
1529
1769
  async function respawnAndRetryMerge(opts) {
1770
+ const log = /* @__PURE__ */ __name((msg) => {
1771
+ if (opts.onLine) {
1772
+ opts.onLine(msg);
1773
+ } else {
1774
+ console.log(msg);
1775
+ }
1776
+ }, "log");
1530
1777
  await updateWorktreeFromBase(
1531
1778
  opts.worktreePath,
1532
1779
  opts.baseBranch
@@ -1541,7 +1788,7 @@ async function respawnAndRetryMerge(opts) {
1541
1788
  opts.specBody,
1542
1789
  opts.ticket
1543
1790
  );
1544
- const logPath = join3(
1791
+ const logPath = join4(
1545
1792
  opts.logsDir,
1546
1793
  opts.ticket.filename.replace(/\.md$/, ".respawn.log")
1547
1794
  );
@@ -1557,6 +1804,7 @@ async function respawnAndRetryMerge(opts) {
1557
1804
  logPath,
1558
1805
  cwd: opts.worktreePath,
1559
1806
  label,
1807
+ maxTurns: opts.maxTurns,
1560
1808
  onLine: opts.onLine
1561
1809
  });
1562
1810
  if (result.exitCode !== 0) {
@@ -1568,6 +1816,22 @@ async function respawnAndRetryMerge(opts) {
1568
1816
  }
1569
1817
  return false;
1570
1818
  }
1819
+ if (opts.testCmd) {
1820
+ const passed = await runTestGate({
1821
+ testCmd: opts.testCmd,
1822
+ ticket: opts.ticket,
1823
+ worktreePath: opts.worktreePath,
1824
+ timeout: opts.timeout,
1825
+ logsDir: opts.logsDir,
1826
+ log
1827
+ });
1828
+ if (!passed) {
1829
+ log(
1830
+ ` ${opts.ticket.filename}: tests failed after conflict respawn; not merging`
1831
+ );
1832
+ return false;
1833
+ }
1834
+ }
1571
1835
  const mergeResult = await mergeBranch(
1572
1836
  opts.repoRoot,
1573
1837
  opts.branch
@@ -1591,6 +1855,171 @@ async function respawnAndRetryMerge(opts) {
1591
1855
  return mergeResult.success;
1592
1856
  }
1593
1857
  __name(respawnAndRetryMerge, "respawnAndRetryMerge");
1858
+ var DEFAULT_RETRIES = 2;
1859
+ var DEFAULT_RETRY_DELAY = 30;
1860
+ function retryDelayFor(base, attempt) {
1861
+ return base * 2 ** (attempt - 2);
1862
+ }
1863
+ __name(retryDelayFor, "retryDelayFor");
1864
+ function sleep(ms) {
1865
+ return new Promise((resolve) => setTimeout(resolve, ms));
1866
+ }
1867
+ __name(sleep, "sleep");
1868
+ function attemptLogPath(logsDir, ticket, attempt, ext) {
1869
+ const stem = ticket.filename.replace(/\.md$/, "");
1870
+ const suffix = attempt > 1 ? `.attempt${attempt}` : "";
1871
+ return join4(logsDir, `${stem}${suffix}${ext}`);
1872
+ }
1873
+ __name(attemptLogPath, "attemptLogPath");
1874
+ async function runTicketAttempt(opts) {
1875
+ const { ticket, d } = opts;
1876
+ let worktree;
1877
+ try {
1878
+ worktree = await createWorktree({
1879
+ repoRoot: opts.repoRoot,
1880
+ repoName: opts.repoName,
1881
+ baseBranch: opts.baseBranch,
1882
+ ticket
1883
+ });
1884
+ } catch (error) {
1885
+ return { kind: "worktree-error", error };
1886
+ }
1887
+ d.setTicketStatus(ticket.num, "in-progress");
1888
+ const agentResult = await spawnAgent({
1889
+ prompt: renderPrompt(
1890
+ opts.specBody,
1891
+ ticket,
1892
+ { testCmd: opts.testCmd }
1893
+ ),
1894
+ agentCmd: opts.agentCmd,
1895
+ model: opts.model,
1896
+ timeout: opts.timeout,
1897
+ logPath: attemptLogPath(
1898
+ opts.logsDir,
1899
+ ticket,
1900
+ opts.attempt,
1901
+ ".log"
1902
+ ),
1903
+ cwd: worktree.worktreePath,
1904
+ label: ticket.filename.replace(/\.md$/, ""),
1905
+ maxTurns: opts.maxTurns,
1906
+ thinking: opts.thinking,
1907
+ onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1908
+ });
1909
+ if (agentResult.timedOut) {
1910
+ d.log(
1911
+ ` ${ticket.filename}: timed out; keeping the work it committed`
1912
+ );
1913
+ } else if (agentResult.exitCode !== 0) {
1914
+ d.log(
1915
+ ` ${ticket.filename}: agent exited ${agentResult.exitCode}`
1916
+ );
1917
+ await removeWorktreeQuietly(worktree.worktreePath, d);
1918
+ return {
1919
+ kind: "failed",
1920
+ reason: "exit",
1921
+ branchName: worktree.branchName,
1922
+ exitCode: agentResult.exitCode,
1923
+ completedItems: agentResult.completedItems
1924
+ };
1925
+ }
1926
+ if (isDirty(worktree.worktreePath)) {
1927
+ const committed = await commitDirty(
1928
+ worktree.worktreePath,
1929
+ `nightralph: auto-commit ${ticket.filename} remaining changes`
1930
+ );
1931
+ if (committed) {
1932
+ d.log(
1933
+ ` ${ticket.filename}: auto-committed remaining changes`
1934
+ );
1935
+ }
1936
+ }
1937
+ if (!hasNewCommits(
1938
+ opts.repoRoot,
1939
+ opts.baseBranch,
1940
+ worktree.branchName
1941
+ )) {
1942
+ return { kind: "no-commits" };
1943
+ }
1944
+ if (opts.testCmd) {
1945
+ const passed = await runTestGate({
1946
+ testCmd: opts.testCmd,
1947
+ ticket,
1948
+ worktreePath: worktree.worktreePath,
1949
+ timeout: opts.timeout,
1950
+ logsDir: opts.logsDir,
1951
+ attempt: opts.attempt,
1952
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
1953
+ });
1954
+ if (!passed) {
1955
+ await removeWorktreeQuietly(
1956
+ worktree.worktreePath,
1957
+ d
1958
+ );
1959
+ return {
1960
+ kind: "failed",
1961
+ reason: "tests",
1962
+ branchName: worktree.branchName,
1963
+ exitCode: 1,
1964
+ completedItems: agentResult.completedItems
1965
+ };
1966
+ }
1967
+ }
1968
+ return {
1969
+ kind: "ready",
1970
+ worktreePath: worktree.worktreePath,
1971
+ branchName: worktree.branchName,
1972
+ completedItems: agentResult.completedItems
1973
+ };
1974
+ }
1975
+ __name(runTicketAttempt, "runTicketAttempt");
1976
+ async function runTicketAttempts(opts) {
1977
+ const { ticket, d } = opts;
1978
+ const maxAttempts = opts.retries + 1;
1979
+ const isPi = basename2(opts.agentCmd) === "pi";
1980
+ let thinking = opts.thinking;
1981
+ let outcome = { kind: "no-commits" };
1982
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1983
+ if (attempt === 1) {
1984
+ d.log(` Starting ${ticket.filename}`);
1985
+ } else {
1986
+ thinking = escalateThinking(thinking);
1987
+ const delay = retryDelayFor(opts.retryDelay, attempt);
1988
+ d.log(
1989
+ ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isPi ? `, thinking ${thinking}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1990
+ );
1991
+ if (delay > 0) await sleep(delay * 1e3);
1992
+ }
1993
+ try {
1994
+ outcome = await runTicketAttempt({
1995
+ ...opts,
1996
+ attempt,
1997
+ thinking
1998
+ });
1999
+ } catch (error) {
2000
+ d.log(
2001
+ ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
2002
+ );
2003
+ return { kind: "error", error };
2004
+ }
2005
+ if (outcome.kind === "ready" || outcome.kind === "worktree-error") {
2006
+ return outcome;
2007
+ }
2008
+ }
2009
+ return outcome;
2010
+ }
2011
+ __name(runTicketAttempts, "runTicketAttempts");
2012
+ async function saveProgress(repoRoot, progressPath, progress, message, d) {
2013
+ writeProgress(progressPath, progress);
2014
+ try {
2015
+ await commitProgress(repoRoot, progressPath, message);
2016
+ } catch (err) {
2017
+ d.log(
2018
+ " Failed to commit progress: " + formatErrorMessage(err)
2019
+ );
2020
+ }
2021
+ }
2022
+ __name(saveProgress, "saveProgress");
1594
2023
  function runProgress(tickets) {
1595
2024
  return {
1596
2025
  completed: tickets.filter((t) => t.status === "done").length,
@@ -1598,15 +2027,21 @@ function runProgress(tickets) {
1598
2027
  };
1599
2028
  }
1600
2029
  __name(runProgress, "runProgress");
1601
- async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, display) {
2030
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, display, maxTurns, thinking, retries, retryDelay) {
1602
2031
  mkdirSync2(logsDir, { recursive: true });
1603
2032
  const d = display ?? createDisplay();
2033
+ const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
2034
+ const delayBase = retryDelay ?? 0;
2035
+ const isPi = basename2(agentCmd) === "pi";
1604
2036
  let totalWaves = 0;
1605
2037
  for (const _ of simulateWaves(tickets)) totalWaves++;
1606
2038
  let totalCompleted = 0;
1607
2039
  let waveNum = 0;
2040
+ const failedNums = /* @__PURE__ */ new Set();
1608
2041
  while (true) {
1609
- const ready = findReadyTickets(tickets);
2042
+ const ready = findReadyTickets(tickets).filter(
2043
+ (t) => !failedNums.has(t.num)
2044
+ );
1610
2045
  if (ready.length === 0) break;
1611
2046
  waveNum++;
1612
2047
  const readyNums = new Set(ready.map((t) => t.num));
@@ -1629,25 +2064,48 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1629
2064
  const results = await Promise.allSettled(
1630
2065
  ready.map(async (t) => {
1631
2066
  const prompt = renderPrompt(specBody, t);
1632
- const logPath = join3(
1633
- logsDir,
1634
- t.filename.replace(/\.md$/, ".log")
1635
- );
1636
2067
  const label = t.filename.replace(
1637
2068
  /\.md$/,
1638
2069
  ""
1639
2070
  );
1640
2071
  d.setTicketStatus(t.num, "in-progress");
1641
2072
  d.log(` Starting ${t.filename}`);
1642
- const result = await spawnAgent({
2073
+ let level = thinking;
2074
+ let result = await spawnAgent({
1643
2075
  prompt,
1644
2076
  agentCmd,
1645
2077
  model,
1646
2078
  timeout,
1647
- logPath,
2079
+ logPath: attemptLogPath(logsDir, t, 1, ".log"),
1648
2080
  label,
2081
+ maxTurns,
2082
+ thinking: level,
1649
2083
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1650
2084
  });
2085
+ for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
2086
+ level = escalateThinking(level);
2087
+ const delay = retryDelayFor(delayBase, attempt);
2088
+ d.log(
2089
+ ` ${t.filename}: agent exited ${result.exitCode}; retrying (attempt ${attempt}/${maxAttempts}` + (isPi ? `, thinking ${level}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
2090
+ );
2091
+ if (delay > 0) await sleep(delay * 1e3);
2092
+ result = await spawnAgent({
2093
+ prompt,
2094
+ agentCmd,
2095
+ model,
2096
+ timeout,
2097
+ logPath: attemptLogPath(
2098
+ logsDir,
2099
+ t,
2100
+ attempt,
2101
+ ".log"
2102
+ ),
2103
+ label,
2104
+ maxTurns,
2105
+ thinking: level,
2106
+ onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
2107
+ });
2108
+ }
1651
2109
  return { ticket: t, result };
1652
2110
  })
1653
2111
  );
@@ -1656,6 +2114,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1656
2114
  if (r.status === "rejected") continue;
1657
2115
  const { ticket, result } = r.value;
1658
2116
  if (result.exitCode !== 0) {
2117
+ failedNums.add(ticket.num);
1659
2118
  d.setTicketStatus(ticket.num, "failed");
1660
2119
  d.log(
1661
2120
  ` ${ticket.filename}: agent exited ${result.exitCode}`
@@ -1683,7 +2142,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1683
2142
  );
1684
2143
  d.log(
1685
2144
  `
1686
- Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
2145
+ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedNums.size > 0 ? `, ${failedNums.size} failed` : "")
1687
2146
  );
1688
2147
  d.cleanup();
1689
2148
  return { allDone: remaining.length === 0 };
@@ -1692,6 +2151,8 @@ __name(runWaves, "runWaves");
1692
2151
  async function runWavesIsolated(opts) {
1693
2152
  mkdirSync2(opts.logsDir, { recursive: true });
1694
2153
  const d = opts.display ?? createDisplay();
2154
+ const retries = opts.retries ?? DEFAULT_RETRIES;
2155
+ const retryDelay = opts.retryDelay ?? 0;
1695
2156
  let totalWaves = 0;
1696
2157
  for (const _ of simulateWaves(opts.tickets)) totalWaves++;
1697
2158
  const repoRoot = getRepoRoot();
@@ -1702,7 +2163,7 @@ async function runWavesIsolated(opts) {
1702
2163
  "Working tree is dirty; commit or stash changes before running"
1703
2164
  );
1704
2165
  }
1705
- const progressPath = join3(
2166
+ const progressPath = join4(
1706
2167
  dirname(opts.logsDir),
1707
2168
  "progress.md"
1708
2169
  );
@@ -1710,16 +2171,23 @@ async function runWavesIsolated(opts) {
1710
2171
  generateProgress(opts.featureName, opts.tickets),
1711
2172
  readProgress(progressPath)
1712
2173
  );
1713
- writeProgress(progressPath, progress);
1714
- await commitProgress(
2174
+ await saveProgress(
1715
2175
  repoRoot,
1716
2176
  progressPath,
1717
- `nightralph: start ${opts.featureName}`
2177
+ progress,
2178
+ `nightralph: start ${opts.featureName}`,
2179
+ d
1718
2180
  );
2181
+ if (!opts.testCmd) {
2182
+ d.log(" Test gate disabled: no test command");
2183
+ }
1719
2184
  let totalCompleted = 0;
1720
2185
  let waveNum = 0;
2186
+ const failedNums = /* @__PURE__ */ new Set();
1721
2187
  while (true) {
1722
- const ready = findReadyTickets(opts.tickets);
2188
+ const ready = findReadyTickets(opts.tickets).filter(
2189
+ (t) => !failedNums.has(t.num)
2190
+ );
1723
2191
  if (ready.length === 0) break;
1724
2192
  waveNum++;
1725
2193
  const readyNums = new Set(ready.map((t) => t.num));
@@ -1739,125 +2207,89 @@ async function runWavesIsolated(opts) {
1739
2207
  filename: t.filename
1740
2208
  }))
1741
2209
  );
1742
- const worktreeSettled = await Promise.allSettled(
1743
- ready.map((t) => createWorktree({
2210
+ const outcomes = await Promise.all(
2211
+ ready.map((t) => runTicketAttempts({
2212
+ ticket: t,
1744
2213
  repoRoot,
1745
2214
  repoName,
1746
2215
  baseBranch,
1747
- ticket: t
2216
+ specBody: opts.specBody,
2217
+ agentCmd: opts.agentCmd,
2218
+ model: opts.model,
2219
+ timeout: opts.timeout,
2220
+ logsDir: opts.logsDir,
2221
+ testCmd: opts.testCmd,
2222
+ maxTurns: opts.maxTurns,
2223
+ thinking: opts.thinking,
2224
+ retries,
2225
+ retryDelay,
2226
+ d
1748
2227
  }))
1749
2228
  );
1750
- const activeTickets = [];
1751
- const worktrees = [];
1752
- for (let i = 0; i < worktreeSettled.length; i++) {
1753
- const settled = worktreeSettled[i];
2229
+ let waveCompleted = 0;
2230
+ const successfulBranches = [];
2231
+ const ticketsByNum = /* @__PURE__ */ new Map();
2232
+ const worktreePathsByNum = /* @__PURE__ */ new Map();
2233
+ for (let i = 0; i < outcomes.length; i++) {
2234
+ const outcome = outcomes[i];
1754
2235
  const ticket = ready[i];
1755
- if (settled.status === "rejected") {
2236
+ if (outcome.kind === "worktree-error") {
2237
+ failedNums.add(ticket.num);
1756
2238
  d.setTicketStatus(ticket.num, "failed");
1757
2239
  d.log(
1758
- ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(settled.reason)}`
2240
+ ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(outcome.error)}`
1759
2241
  );
1760
2242
  continue;
1761
2243
  }
1762
- activeTickets.push(ticket);
1763
- worktrees.push(settled.value);
1764
- }
1765
- const results = await Promise.allSettled(
1766
- activeTickets.map((t, i) => {
1767
- const prompt = renderPrompt(opts.specBody, t);
1768
- const logPath = join3(
1769
- opts.logsDir,
1770
- t.filename.replace(/\.md$/, ".log")
1771
- );
1772
- const label = t.filename.replace(
1773
- /\.md$/,
1774
- ""
2244
+ if (outcome.kind === "error") {
2245
+ failedNums.add(ticket.num);
2246
+ d.setTicketStatus(ticket.num, "failed");
2247
+ d.log(
2248
+ ` ${ticket.filename}: attempt error: ` + formatErrorMessage(outcome.error)
1775
2249
  );
1776
- d.setTicketStatus(t.num, "in-progress");
1777
- d.log(` Starting ${t.filename}`);
1778
- return spawnAgent({
1779
- prompt,
1780
- agentCmd: opts.agentCmd,
1781
- model: opts.model,
1782
- timeout: opts.timeout,
1783
- logPath,
1784
- cwd: worktrees[i].worktreePath,
1785
- label,
1786
- onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1787
- });
1788
- })
1789
- );
1790
- let waveCompleted = 0;
1791
- const successfulBranches = [];
1792
- const ticketsByNum = /* @__PURE__ */ new Map();
1793
- const worktreePathsByNum = /* @__PURE__ */ new Map();
1794
- for (let i = 0; i < results.length; i++) {
1795
- const r = results[i];
1796
- if (r.status === "rejected") continue;
1797
- const agentResult = r.value;
1798
- const ticket = activeTickets[i];
1799
- const worktree = worktrees[i];
1800
- if (agentResult.exitCode !== 0) {
2250
+ continue;
2251
+ }
2252
+ if (outcome.kind === "no-commits") {
2253
+ failedNums.add(ticket.num);
1801
2254
  d.setTicketStatus(ticket.num, "failed");
1802
2255
  d.log(
1803
- ` ${ticket.filename}: agent exited ${agentResult.exitCode}`
2256
+ ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
1804
2257
  );
1805
- checkItems(ticket, agentResult.completedItems);
2258
+ continue;
2259
+ }
2260
+ if (outcome.kind === "failed") {
2261
+ failedNums.add(ticket.num);
2262
+ d.setTicketStatus(ticket.num, "failed");
2263
+ if (outcome.reason === "exit") {
2264
+ checkItems(ticket, outcome.completedItems);
2265
+ }
1806
2266
  checkTicket(progress, ticket.num, {
1807
- branch: worktree.branchName,
1808
- exitCode: agentResult.exitCode,
2267
+ branch: outcome.branchName,
2268
+ exitCode: outcome.exitCode,
1809
2269
  done: false
1810
2270
  });
1811
- writeProgress(progressPath, progress);
1812
- await commitProgress(
2271
+ await saveProgress(
1813
2272
  repoRoot,
1814
2273
  progressPath,
1815
- `nightralph: ${ticket.filename} failed`
1816
- );
1817
- try {
1818
- await removeWorktree(worktree.worktreePath);
1819
- } catch (err) {
1820
- d.log(
1821
- ` Failed to remove worktree ${worktree.worktreePath}: ` + formatErrorMessage(err)
1822
- );
1823
- }
1824
- continue;
1825
- }
1826
- if (isDirty(worktree.worktreePath)) {
1827
- const committed = await commitDirty(
1828
- worktree.worktreePath,
1829
- `nightralph: auto-commit ${ticket.filename} remaining changes`
1830
- );
1831
- if (committed) {
1832
- d.log(
1833
- ` ${ticket.filename}: auto-committed remaining changes`
1834
- );
1835
- }
1836
- }
1837
- if (!hasNewCommits(
1838
- repoRoot,
1839
- baseBranch,
1840
- worktree.branchName
1841
- )) {
1842
- d.setTicketStatus(ticket.num, "failed");
1843
- d.log(
1844
- ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
2274
+ progress,
2275
+ `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
2276
+ d
1845
2277
  );
1846
2278
  continue;
1847
2279
  }
1848
- checkItems(ticket, agentResult.completedItems);
2280
+ checkItems(ticket, outcome.completedItems);
1849
2281
  d.log(
1850
2282
  ` ${ticket.filename}: agent finished, awaiting merge`
1851
2283
  );
1852
2284
  ticketsByNum.set(ticket.num, ticket);
1853
2285
  worktreePathsByNum.set(
1854
2286
  ticket.num,
1855
- worktree.worktreePath
2287
+ outcome.worktreePath
1856
2288
  );
1857
2289
  successfulBranches.push({
1858
- branch: worktree.branchName,
2290
+ branch: outcome.branchName,
1859
2291
  ticketNum: ticket.num,
1860
- worktreePath: worktree.worktreePath
2292
+ worktreePath: outcome.worktreePath
1861
2293
  });
1862
2294
  }
1863
2295
  if (successfulBranches.length > 0) {
@@ -1884,23 +2316,21 @@ async function runWavesIsolated(opts) {
1884
2316
  branch: branchResult.branch,
1885
2317
  exitCode: 0
1886
2318
  });
1887
- writeProgress(progressPath, progress);
1888
- await commitProgress(
2319
+ await saveProgress(
1889
2320
  repoRoot,
1890
2321
  progressPath,
1891
- `nightralph: ${ticket.filename} done`
2322
+ progress,
2323
+ `nightralph: ${ticket.filename} done`,
2324
+ d
1892
2325
  );
1893
2326
  const worktreePath = worktreePathsByNum.get(
1894
2327
  ticket.num
1895
2328
  );
1896
2329
  if (worktreePath) {
1897
- try {
1898
- await removeWorktree(worktreePath);
1899
- } catch (err) {
1900
- d.log(
1901
- ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
1902
- );
1903
- }
2330
+ await removeWorktreeQuietly(
2331
+ worktreePath,
2332
+ d
2333
+ );
1904
2334
  }
1905
2335
  } else {
1906
2336
  const worktreePath = worktreePathsByNum.get(
@@ -1917,6 +2347,8 @@ async function runWavesIsolated(opts) {
1917
2347
  model: opts.model,
1918
2348
  timeout: opts.timeout,
1919
2349
  logsDir: opts.logsDir,
2350
+ testCmd: opts.testCmd,
2351
+ maxTurns: opts.maxTurns,
1920
2352
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
1921
2353
  }) : false;
1922
2354
  if (recovered) {
@@ -1931,15 +2363,19 @@ async function runWavesIsolated(opts) {
1931
2363
  branch: branchResult.branch,
1932
2364
  exitCode: 0
1933
2365
  });
1934
- writeProgress(progressPath, progress);
1935
- await commitProgress(
2366
+ await saveProgress(
1936
2367
  repoRoot,
1937
2368
  progressPath,
1938
- `nightralph: ${ticket.filename} done (respawn)`
2369
+ progress,
2370
+ `nightralph: ${ticket.filename} done (respawn)`,
2371
+ d
1939
2372
  );
1940
2373
  if (worktreePath) {
1941
2374
  try {
1942
- await removeWorktree(worktreePath);
2375
+ await removeWorktree(
2376
+ worktreePath,
2377
+ { force: true }
2378
+ );
1943
2379
  } catch (err) {
1944
2380
  d.log(
1945
2381
  ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
@@ -1960,11 +2396,12 @@ async function runWavesIsolated(opts) {
1960
2396
  exitCode: 0,
1961
2397
  done: false
1962
2398
  });
1963
- writeProgress(progressPath, progress);
1964
- await commitProgress(
2399
+ await saveProgress(
1965
2400
  repoRoot,
1966
2401
  progressPath,
1967
- `nightralph: ${ticket.filename} merge failed`
2402
+ progress,
2403
+ `nightralph: ${ticket.filename} merge failed`,
2404
+ d
1968
2405
  );
1969
2406
  }
1970
2407
  }
@@ -1978,7 +2415,10 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1978
2415
  const ticket = ticketsByNum.get(sb.ticketNum);
1979
2416
  if (ticket && ticket.status === "done") continue;
1980
2417
  try {
1981
- await removeWorktree(sb.worktreePath);
2418
+ await removeWorktree(
2419
+ sb.worktreePath,
2420
+ { force: true }
2421
+ );
1982
2422
  } catch (err) {
1983
2423
  d.log(
1984
2424
  ` Failed to remove worktree ${sb.worktreePath}: ` + formatErrorMessage(err)
@@ -2000,7 +2440,7 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
2000
2440
  );
2001
2441
  d.log(
2002
2442
  `
2003
- Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
2443
+ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedNums.size > 0 ? `, ${failedNums.size} failed` : "")
2004
2444
  );
2005
2445
  d.cleanup();
2006
2446
  return { allDone: remaining.length === 0 };
@@ -2008,8 +2448,8 @@ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.
2008
2448
  __name(runWavesIsolated, "runWavesIsolated");
2009
2449
 
2010
2450
  // src/resolve.ts
2011
- import { existsSync as existsSync3, readdirSync as readdirSync3 } from "node:fs";
2012
- import { dirname as dirname2, join as join4 } from "node:path";
2451
+ import { existsSync as existsSync4, readdirSync as readdirSync3 } from "node:fs";
2452
+ import { dirname as dirname2, join as join5 } from "node:path";
2013
2453
  import { fileURLToPath } from "node:url";
2014
2454
  import { select } from "@inquirer/prompts";
2015
2455
  function getScriptDir(metaUrl) {
@@ -2017,8 +2457,8 @@ function getScriptDir(metaUrl) {
2017
2457
  }
2018
2458
  __name(getScriptDir, "getScriptDir");
2019
2459
  function findFeatureDirs(cwd) {
2020
- const scratchDir = join4(cwd, ".scratch");
2021
- if (!existsSync3(scratchDir)) return [];
2460
+ const scratchDir = join5(cwd, ".scratch");
2461
+ if (!existsSync4(scratchDir)) return [];
2022
2462
  const entries = readdirSync3(
2023
2463
  scratchDir,
2024
2464
  { withFileTypes: true }
@@ -2026,17 +2466,17 @@ function findFeatureDirs(cwd) {
2026
2466
  const results = [];
2027
2467
  for (const entry of entries) {
2028
2468
  if (!entry.isDirectory()) continue;
2029
- const issuesDir = join4(
2469
+ const issuesDir = join5(
2030
2470
  scratchDir,
2031
2471
  entry.name,
2032
2472
  "issues"
2033
2473
  );
2034
- const specFile = join4(
2474
+ const specFile = join5(
2035
2475
  scratchDir,
2036
2476
  entry.name,
2037
2477
  "spec.md"
2038
2478
  );
2039
- if (existsSync3(issuesDir)) {
2479
+ if (existsSync4(issuesDir)) {
2040
2480
  results.push({
2041
2481
  name: entry.name,
2042
2482
  dir: issuesDir,
@@ -2049,10 +2489,10 @@ function findFeatureDirs(cwd) {
2049
2489
  __name(findFeatureDirs, "findFeatureDirs");
2050
2490
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
2051
2491
  if (specName) {
2052
- const featureDir = join4(cwd, ".scratch", specName);
2492
+ const featureDir = join5(cwd, ".scratch", specName);
2053
2493
  return {
2054
- dir: join4(featureDir, "issues"),
2055
- spec: join4(featureDir, "spec.md")
2494
+ dir: join5(featureDir, "issues"),
2495
+ spec: join5(featureDir, "spec.md")
2056
2496
  };
2057
2497
  }
2058
2498
  const features = findFeatureDirs(cwd);
@@ -2115,8 +2555,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2115
2555
  }
2116
2556
  }
2117
2557
  setup({
2118
- bundledSkillsDir: join5(scriptDir, "skills"),
2119
- docsTemplatesDir: join5(
2558
+ bundledSkillsDir: join6(scriptDir, "skills"),
2559
+ docsTemplatesDir: join6(
2120
2560
  scriptDir,
2121
2561
  "docs-templates"
2122
2562
  ),
@@ -2146,11 +2586,29 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2146
2586
  }).option("timeout", {
2147
2587
  type: "number",
2148
2588
  describe: "Kill agent after N seconds",
2149
- default: 900
2589
+ default: 3600
2590
+ }).option("test-cmd", {
2591
+ type: "string",
2592
+ 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. Pass "" to disable.'
2150
2593
  }).option("X", {
2151
2594
  type: "boolean",
2152
2595
  describe: "Stop on merge conflict instead of re-spawning",
2153
2596
  default: false
2597
+ }).option("max-turns", {
2598
+ type: "number",
2599
+ describe: "Max agentic turns per ticket (forwarded to the agent CLI)"
2600
+ }).option("thinking", {
2601
+ type: "string",
2602
+ choices: THINKING_LEVELS,
2603
+ describe: "Starting thinking level for the pi provider. Omitted = pi's default. Retries step up from here."
2604
+ }).option("retries", {
2605
+ type: "number",
2606
+ default: DEFAULT_RETRIES,
2607
+ describe: "Extra attempts per ticket after a failed run, each with a higher thinking level (pi). 0 disables retries."
2608
+ }).option("retry-delay", {
2609
+ type: "number",
2610
+ default: DEFAULT_RETRY_DELAY,
2611
+ describe: "Seconds to wait before the first retry; doubles each further retry. 0 disables the wait."
2154
2612
  }),
2155
2613
  async (argv) => {
2156
2614
  await runExecute({
@@ -2159,7 +2617,12 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2159
2617
  spec: argv.spec,
2160
2618
  dryRun: argv.dryRun,
2161
2619
  timeout: argv.timeout,
2162
- stopOnConflict: argv.X
2620
+ testCmd: argv.testCmd,
2621
+ stopOnConflict: argv.X,
2622
+ maxTurns: argv.maxTurns,
2623
+ thinking: argv.thinking,
2624
+ retries: argv.retries,
2625
+ retryDelay: argv.retryDelay
2163
2626
  });
2164
2627
  }
2165
2628
  ).strict().demandCommand(1, "Specify a command or provider").help().parse();
@@ -2187,15 +2650,18 @@ async function runExecute(argv) {
2187
2650
  const { dir, spec } = await resolveIssuesDir(
2188
2651
  argv.spec
2189
2652
  );
2190
- if (!existsSync4(dir)) {
2653
+ if (!existsSync5(dir)) {
2191
2654
  console.error(`Directory not found: ${dir}`);
2192
2655
  process.exit(2);
2193
2656
  }
2194
- if (!existsSync4(spec)) {
2657
+ if (!existsSync5(spec)) {
2195
2658
  console.error(`Spec file not found: ${spec}`);
2196
2659
  process.exit(2);
2197
2660
  }
2198
- const specBody = readFileSync4(spec, "utf8");
2661
+ const specBody = stripSpecSection(
2662
+ readFileSync5(spec, "utf8"),
2663
+ "User Stories"
2664
+ );
2199
2665
  const tickets = scanTickets(dir);
2200
2666
  if (tickets.length === 0) {
2201
2667
  console.log("No ticket files found. Exiting.");
@@ -2216,11 +2682,14 @@ async function runExecute(argv) {
2216
2682
  (b) => knownNums.has(b)
2217
2683
  );
2218
2684
  }
2219
- const logsDir = join5(dirname3(dir), "logs");
2220
- const timeout = argv.timeout ?? 300;
2685
+ const logsDir = join6(dirname3(dir), "logs");
2686
+ const timeout = argv.timeout ?? 900;
2687
+ const testCmd = argv.testCmd === void 0 ? detectTestCmd(
2688
+ isGitRepo() ? getRepoRoot() : process.cwd()
2689
+ ) : argv.testCmd || null;
2221
2690
  const featureName = extractFeatureName(spec);
2222
2691
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
2223
- const progressPath = join5(
2692
+ const progressPath = join6(
2224
2693
  dirname3(dir),
2225
2694
  "progress.md"
2226
2695
  );
@@ -2232,7 +2701,11 @@ async function runExecute(argv) {
2232
2701
  specBody,
2233
2702
  repoName,
2234
2703
  conflictStrategy,
2235
- progressPath
2704
+ progressPath,
2705
+ testCmd,
2706
+ thinking: argv.thinking,
2707
+ retries: argv.retries,
2708
+ retryDelay: argv.retryDelay
2236
2709
  });
2237
2710
  } else {
2238
2711
  dryRun(tickets, specBody);
@@ -2240,6 +2713,14 @@ async function runExecute(argv) {
2240
2713
  return;
2241
2714
  }
2242
2715
  const display = createDisplay();
2716
+ function onSignal(exitCode) {
2717
+ killRunningAgents();
2718
+ display.cleanup();
2719
+ process.exit(exitCode);
2720
+ }
2721
+ __name(onSignal, "onSignal");
2722
+ process.on("SIGINT", () => onSignal(130));
2723
+ process.on("SIGTERM", () => onSignal(143));
2243
2724
  let result;
2244
2725
  if (isGitRepo()) {
2245
2726
  result = await runWavesIsolated({
@@ -2251,7 +2732,12 @@ async function runExecute(argv) {
2251
2732
  logsDir,
2252
2733
  featureName,
2253
2734
  conflictStrategy,
2254
- display
2735
+ display,
2736
+ testCmd,
2737
+ maxTurns: argv.maxTurns,
2738
+ thinking: argv.thinking,
2739
+ retries: argv.retries,
2740
+ retryDelay: argv.retryDelay
2255
2741
  });
2256
2742
  } else {
2257
2743
  result = await runWaves(
@@ -2261,7 +2747,11 @@ async function runExecute(argv) {
2261
2747
  argv.model ?? null,
2262
2748
  timeout,
2263
2749
  logsDir,
2264
- display
2750
+ display,
2751
+ argv.maxTurns,
2752
+ argv.thinking,
2753
+ argv.retries,
2754
+ argv.retryDelay
2265
2755
  );
2266
2756
  }
2267
2757
  if (!result.allDone) {