nightralph 0.0.19 → 0.0.21

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