nightralph 0.0.18 → 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
 
@@ -734,6 +757,8 @@ var FILLED_DOT = "\u25CF";
734
757
  var EMPTY_DOT = "\u25CB";
735
758
  var SEPARATOR_WIDTH = 50;
736
759
  var DEFAULT_COLUMNS = 80;
760
+ var MIN_LOG_ROWS = 1;
761
+ var MAX_MESSAGE_BUFFER = 200;
737
762
  var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
738
763
  function fitLine(line, width) {
739
764
  const visible = line.replace(ANSI_RE, "");
@@ -766,16 +791,14 @@ var StatusDisplay = class {
766
791
  config;
767
792
  viewportStart = 0;
768
793
  resizeListener = null;
769
- signalHandler = null;
770
794
  constructor(config) {
771
795
  this.config = {
772
- messageLines: config?.messageLines ?? 10,
773
796
  stream: config?.stream ?? process.stdout
774
797
  };
775
798
  }
776
799
  getAvailableRows() {
777
800
  const termHeight = this.config.stream.rows ?? 24;
778
- const overhead = 2 + this.config.messageLines;
801
+ const overhead = 2 + MIN_LOG_ROWS;
779
802
  const available = termHeight - overhead;
780
803
  return Math.max(1, available);
781
804
  }
@@ -809,13 +832,6 @@ var StatusDisplay = class {
809
832
  this.resizeListener
810
833
  );
811
834
  }
812
- if (this.signalHandler) {
813
- process.removeListener("SIGINT", this.signalHandler);
814
- process.removeListener("SIGTERM", this.signalHandler);
815
- }
816
- this.signalHandler = () => this.cleanup();
817
- process.on("SIGINT", this.signalHandler);
818
- process.on("SIGTERM", this.signalHandler);
819
835
  this.render();
820
836
  }
821
837
  setTicketStatus(num, status) {
@@ -842,7 +858,7 @@ var StatusDisplay = class {
842
858
  }
843
859
  log(message) {
844
860
  this.messages.push(message);
845
- if (this.messages.length > this.config.messageLines) {
861
+ if (this.messages.length > MAX_MESSAGE_BUFFER) {
846
862
  this.messages.shift();
847
863
  }
848
864
  this.render();
@@ -851,10 +867,6 @@ var StatusDisplay = class {
851
867
  if (this.resizeListener && this.config.stream.isTTY) {
852
868
  this.config.stream.removeListener("resize", this.resizeListener);
853
869
  }
854
- if (this.signalHandler) {
855
- process.removeListener("SIGINT", this.signalHandler);
856
- process.removeListener("SIGTERM", this.signalHandler);
857
- }
858
870
  this.config.stream.write(ansi.cursorShow());
859
871
  }
860
872
  render() {
@@ -941,18 +953,21 @@ var StatusDisplay = class {
941
953
  renderedTicketCount = (hasMoreAbove ? 1 : 0) + visibleTickets.length + (hasMoreBelow ? 1 : 0);
942
954
  }
943
955
  output += "-".repeat(Math.min(SEPARATOR_WIDTH, width)) + "\n";
944
- const msgLines = Math.max(
956
+ const termHeight = this.config.stream.rows ?? 24;
957
+ const logRows = Math.max(
945
958
  0,
946
- this.config.messageLines - this.messages.length
959
+ termHeight - 2 - renderedTicketCount
947
960
  );
948
- for (const msg of this.messages) {
961
+ const visibleMessages = this.messages.slice(-logRows);
962
+ for (const msg of visibleMessages) {
949
963
  output += `${fitLine(msg, width)}
950
964
  `;
951
965
  }
952
- for (let i = 0; i < msgLines; i++) {
966
+ const emptyRows = logRows - visibleMessages.length;
967
+ for (let i = 0; i < emptyRows; i++) {
953
968
  output += "\n";
954
969
  }
955
- this.renderedLines = 2 + renderedTicketCount + this.config.messageLines;
970
+ this.renderedLines = 2 + renderedTicketCount + logRows;
956
971
  this.config.stream.write(output);
957
972
  }
958
973
  };
@@ -984,11 +999,83 @@ function createDisplay(stream) {
984
999
  }
985
1000
  __name(createDisplay, "createDisplay");
986
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
+
987
1064
  // src/orchestrator.ts
988
1065
  function formatErrorMessage(error) {
989
1066
  return error instanceof Error ? error.message : String(error);
990
1067
  }
991
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");
992
1079
  var TICKET_RE = /^(\d+)-(.+)\.md$/;
993
1080
  function parseTicketFilename(filename) {
994
1081
  const m = filename.match(TICKET_RE);
@@ -1023,8 +1110,8 @@ function scanTickets(dir) {
1023
1110
  ));
1024
1111
  return files.map((filename) => {
1025
1112
  const parsed = parseTicketFilename(filename);
1026
- const filepath = join3(dir, filename);
1027
- const body = readFileSync3(filepath, "utf8");
1113
+ const filepath = join4(dir, filename);
1114
+ const body = readFileSync4(filepath, "utf8");
1028
1115
  return {
1029
1116
  num: parsed.num,
1030
1117
  slug: parsed.slug,
@@ -1079,7 +1166,39 @@ function markDone(ticket) {
1079
1166
  ticket.body = updated;
1080
1167
  }
1081
1168
  __name(markDone, "markDone");
1082
- 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
+ ] : [];
1083
1202
  return [
1084
1203
  "---- SPEC CONTEXT ----",
1085
1204
  specBody,
@@ -1091,10 +1210,12 @@ function renderPrompt(specBody, ticket) {
1091
1210
  "---- AGENT INSTRUCTIONS ----",
1092
1211
  "You are an autonomous coding agent. Implement the ticket.",
1093
1212
  "",
1094
- "Time is limited. Follow these rules:",
1095
- "- 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.",
1096
1215
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
1097
- "- 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.",
1098
1219
  "- Commit your work when finished.",
1099
1220
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
1100
1221
  ""
@@ -1120,7 +1241,8 @@ function renderMergePrompt(specBody, ticket) {
1120
1241
  "2. Make sure the code compiles and tests pass.",
1121
1242
  "3. Commit the resolved files.",
1122
1243
  "",
1123
- "Do NOT re-implement the ticket from scratch."
1244
+ "Do NOT re-implement the ticket from scratch.",
1245
+ "Do NOT run git push."
1124
1246
  ].join("\n");
1125
1247
  }
1126
1248
  __name(renderMergePrompt, "renderMergePrompt");
@@ -1199,7 +1321,11 @@ var PROVIDER_ARGS = {
1199
1321
  "stream-json",
1200
1322
  "--dangerously-skip-permissions"
1201
1323
  ],
1202
- 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"],
1203
1329
  pi: [
1204
1330
  "-p",
1205
1331
  "--verbose",
@@ -1214,6 +1340,21 @@ function getProviderArgs(cmd) {
1214
1340
  return PROVIDER_ARGS[name] ?? [];
1215
1341
  }
1216
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");
1217
1358
  function formatStreamLine(line) {
1218
1359
  let obj;
1219
1360
  try {
@@ -1314,7 +1455,7 @@ function createPiStreamFormatter() {
1314
1455
  __name(createPiStreamFormatter, "createPiStreamFormatter");
1315
1456
  function writePromptFile(prompt) {
1316
1457
  const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
1317
- const filePath = join3(tmpdir(), name);
1458
+ const filePath = join4(tmpdir(), name);
1318
1459
  writeFileSync3(filePath, prompt);
1319
1460
  return filePath;
1320
1461
  }
@@ -1326,6 +1467,22 @@ function cleanupPromptFile(filePath) {
1326
1467
  }
1327
1468
  }
1328
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");
1329
1486
  function spawnAgent(opts) {
1330
1487
  const args = [
1331
1488
  ...getProviderArgs(opts.agentCmd)
@@ -1333,9 +1490,15 @@ function spawnAgent(opts) {
1333
1490
  if (opts.model) {
1334
1491
  args.push("--model", opts.model);
1335
1492
  }
1493
+ if (opts.maxTurns) {
1494
+ args.push("--max-turns", String(opts.maxTurns));
1495
+ }
1336
1496
  const providerName = basename2(opts.agentCmd);
1337
1497
  const isClaude = providerName === "claude";
1338
1498
  const isPi = providerName === "pi";
1499
+ if (isPi && opts.thinking) {
1500
+ args.push("--thinking", opts.thinking);
1501
+ }
1339
1502
  const prefix = opts.label ? ` [${opts.label}] ` : " ";
1340
1503
  let promptFile = null;
1341
1504
  if (isPi) {
@@ -1346,10 +1509,12 @@ function spawnAgent(opts) {
1346
1509
  opts.logPath,
1347
1510
  { flags: "w" }
1348
1511
  );
1349
- const proc = spawn(opts.agentCmd, args, {
1512
+ const proc = spawn2(opts.agentCmd, args, {
1350
1513
  stdio: ["pipe", "pipe", "pipe"],
1351
- cwd: opts.cwd
1514
+ cwd: opts.cwd,
1515
+ detached: true
1352
1516
  });
1517
+ if (proc.pid !== void 0) runningAgents.add(proc.pid);
1353
1518
  proc.stdout.pipe(logStream);
1354
1519
  const piFormatter = isPi ? createPiStreamFormatter() : null;
1355
1520
  const completedItems = [];
@@ -1403,21 +1568,41 @@ function spawnAgent(opts) {
1403
1568
  console.error(output);
1404
1569
  }
1405
1570
  });
1571
+ let timedOut = false;
1406
1572
  const timeout = setTimeout(() => {
1407
- if (!proc.killed) {
1408
- console.warn(
1409
- `Agent did not exit in ${opts.timeout}s; killing.`
1410
- );
1411
- 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}`);
1412
1582
  }
1583
+ killProcessGroup(proc.pid);
1413
1584
  }, opts.timeout * 1e3);
1585
+ proc.stdin.on("error", (err) => {
1586
+ logStream.write(
1587
+ `nightralph: stdin write failed: ${formatErrorMessage(err)}
1588
+ `
1589
+ );
1590
+ });
1414
1591
  if (!promptFile) {
1415
1592
  proc.stdin.write(opts.prompt);
1416
1593
  }
1417
1594
  proc.stdin.end();
1418
1595
  return new Promise((resolve) => {
1419
- proc.on("close", (code) => {
1596
+ let settled = false;
1597
+ let graceTimer;
1598
+ function finish(exitCode) {
1599
+ if (settled) return;
1600
+ settled = true;
1420
1601
  clearTimeout(timeout);
1602
+ if (graceTimer) clearTimeout(graceTimer);
1603
+ if (proc.pid !== void 0) {
1604
+ runningAgents.delete(proc.pid);
1605
+ }
1421
1606
  if (piFormatter) {
1422
1607
  for (const line of piFormatter.flush()) {
1423
1608
  collectCompleted(line);
@@ -1433,13 +1618,27 @@ function spawnAgent(opts) {
1433
1618
  rlErr.close();
1434
1619
  logStream.end();
1435
1620
  if (promptFile) cleanupPromptFile(promptFile);
1436
- resolve({
1437
- exitCode: code ?? 1,
1438
- completedItems
1439
- });
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);
1440
1633
  });
1441
1634
  proc.on("error", (err) => {
1635
+ if (settled) return;
1636
+ settled = true;
1637
+ if (graceTimer) clearTimeout(graceTimer);
1442
1638
  clearTimeout(timeout);
1639
+ if (proc.pid !== void 0) {
1640
+ runningAgents.delete(proc.pid);
1641
+ }
1443
1642
  rl.close();
1444
1643
  rlErr.close();
1445
1644
  logStream.end();
@@ -1448,7 +1647,11 @@ function spawnAgent(opts) {
1448
1647
  "Agent process error:",
1449
1648
  formatErrorMessage(err)
1450
1649
  );
1451
- resolve({ exitCode: 1, completedItems });
1650
+ resolve({
1651
+ exitCode: 1,
1652
+ completedItems,
1653
+ timedOut
1654
+ });
1452
1655
  });
1453
1656
  });
1454
1657
  }
@@ -1511,18 +1714,65 @@ Prompt for first ready ticket (${firstReady[0].filename}):
1511
1714
  `
1512
1715
  );
1513
1716
  console.log(
1514
- renderPrompt(opts.specBody, firstReady[0])
1717
+ renderPrompt(
1718
+ opts.specBody,
1719
+ firstReady[0],
1720
+ { testCmd: opts.testCmd }
1721
+ )
1515
1722
  );
1516
1723
  }
1517
1724
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
1518
1725
  console.log(
1519
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
+ `
1520
1740
  Merge strategy: ${strategyDesc}`
1521
1741
  );
1522
1742
  console.log(`Progress file: ${opts.progressPath}`);
1523
1743
  }
1524
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");
1525
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");
1526
1776
  await updateWorktreeFromBase(
1527
1777
  opts.worktreePath,
1528
1778
  opts.baseBranch
@@ -1537,7 +1787,7 @@ async function respawnAndRetryMerge(opts) {
1537
1787
  opts.specBody,
1538
1788
  opts.ticket
1539
1789
  );
1540
- const logPath = join3(
1790
+ const logPath = join4(
1541
1791
  opts.logsDir,
1542
1792
  opts.ticket.filename.replace(/\.md$/, ".respawn.log")
1543
1793
  );
@@ -1553,6 +1803,7 @@ async function respawnAndRetryMerge(opts) {
1553
1803
  logPath,
1554
1804
  cwd: opts.worktreePath,
1555
1805
  label,
1806
+ maxTurns: opts.maxTurns,
1556
1807
  onLine: opts.onLine
1557
1808
  });
1558
1809
  if (result.exitCode !== 0) {
@@ -1564,6 +1815,22 @@ async function respawnAndRetryMerge(opts) {
1564
1815
  }
1565
1816
  return false;
1566
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
+ }
1567
1834
  const mergeResult = await mergeBranch(
1568
1835
  opts.repoRoot,
1569
1836
  opts.branch
@@ -1587,6 +1854,171 @@ async function respawnAndRetryMerge(opts) {
1587
1854
  return mergeResult.success;
1588
1855
  }
1589
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");
1590
2022
  function runProgress(tickets) {
1591
2023
  return {
1592
2024
  completed: tickets.filter((t) => t.status === "done").length,
@@ -1594,15 +2026,21 @@ function runProgress(tickets) {
1594
2026
  };
1595
2027
  }
1596
2028
  __name(runProgress, "runProgress");
1597
- 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) {
1598
2030
  mkdirSync2(logsDir, { recursive: true });
1599
2031
  const d = display ?? createDisplay();
2032
+ const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
2033
+ const delayBase = retryDelay ?? 0;
2034
+ const isPi = basename2(agentCmd) === "pi";
1600
2035
  let totalWaves = 0;
1601
2036
  for (const _ of simulateWaves(tickets)) totalWaves++;
1602
2037
  let totalCompleted = 0;
1603
2038
  let waveNum = 0;
2039
+ const failedNums = /* @__PURE__ */ new Set();
1604
2040
  while (true) {
1605
- const ready = findReadyTickets(tickets);
2041
+ const ready = findReadyTickets(tickets).filter(
2042
+ (t) => !failedNums.has(t.num)
2043
+ );
1606
2044
  if (ready.length === 0) break;
1607
2045
  waveNum++;
1608
2046
  const readyNums = new Set(ready.map((t) => t.num));
@@ -1625,25 +2063,48 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1625
2063
  const results = await Promise.allSettled(
1626
2064
  ready.map(async (t) => {
1627
2065
  const prompt = renderPrompt(specBody, t);
1628
- const logPath = join3(
1629
- logsDir,
1630
- t.filename.replace(/\.md$/, ".log")
1631
- );
1632
2066
  const label = t.filename.replace(
1633
2067
  /\.md$/,
1634
2068
  ""
1635
2069
  );
1636
2070
  d.setTicketStatus(t.num, "in-progress");
1637
2071
  d.log(` Starting ${t.filename}`);
1638
- const result = await spawnAgent({
2072
+ let level = thinking;
2073
+ let result = await spawnAgent({
1639
2074
  prompt,
1640
2075
  agentCmd,
1641
2076
  model,
1642
2077
  timeout,
1643
- logPath,
2078
+ logPath: attemptLogPath(logsDir, t, 1, ".log"),
1644
2079
  label,
2080
+ maxTurns,
2081
+ thinking: level,
1645
2082
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1646
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
+ }
1647
2108
  return { ticket: t, result };
1648
2109
  })
1649
2110
  );
@@ -1652,6 +2113,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1652
2113
  if (r.status === "rejected") continue;
1653
2114
  const { ticket, result } = r.value;
1654
2115
  if (result.exitCode !== 0) {
2116
+ failedNums.add(ticket.num);
1655
2117
  d.setTicketStatus(ticket.num, "failed");
1656
2118
  d.log(
1657
2119
  ` ${ticket.filename}: agent exited ${result.exitCode}`
@@ -1679,7 +2141,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
1679
2141
  );
1680
2142
  d.log(
1681
2143
  `
1682
- 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` : "")
1683
2145
  );
1684
2146
  d.cleanup();
1685
2147
  return { allDone: remaining.length === 0 };
@@ -1688,6 +2150,8 @@ __name(runWaves, "runWaves");
1688
2150
  async function runWavesIsolated(opts) {
1689
2151
  mkdirSync2(opts.logsDir, { recursive: true });
1690
2152
  const d = opts.display ?? createDisplay();
2153
+ const retries = opts.retries ?? DEFAULT_RETRIES;
2154
+ const retryDelay = opts.retryDelay ?? 0;
1691
2155
  let totalWaves = 0;
1692
2156
  for (const _ of simulateWaves(opts.tickets)) totalWaves++;
1693
2157
  const repoRoot = getRepoRoot();
@@ -1698,7 +2162,7 @@ async function runWavesIsolated(opts) {
1698
2162
  "Working tree is dirty; commit or stash changes before running"
1699
2163
  );
1700
2164
  }
1701
- const progressPath = join3(
2165
+ const progressPath = join4(
1702
2166
  dirname(opts.logsDir),
1703
2167
  "progress.md"
1704
2168
  );
@@ -1706,16 +2170,23 @@ async function runWavesIsolated(opts) {
1706
2170
  generateProgress(opts.featureName, opts.tickets),
1707
2171
  readProgress(progressPath)
1708
2172
  );
1709
- writeProgress(progressPath, progress);
1710
- await commitProgress(
2173
+ await saveProgress(
1711
2174
  repoRoot,
1712
2175
  progressPath,
1713
- `nightralph: start ${opts.featureName}`
2176
+ progress,
2177
+ `nightralph: start ${opts.featureName}`,
2178
+ d
1714
2179
  );
2180
+ if (!opts.testCmd) {
2181
+ d.log(" Test gate disabled: no test command");
2182
+ }
1715
2183
  let totalCompleted = 0;
1716
2184
  let waveNum = 0;
2185
+ const failedNums = /* @__PURE__ */ new Set();
1717
2186
  while (true) {
1718
- const ready = findReadyTickets(opts.tickets);
2187
+ const ready = findReadyTickets(opts.tickets).filter(
2188
+ (t) => !failedNums.has(t.num)
2189
+ );
1719
2190
  if (ready.length === 0) break;
1720
2191
  waveNum++;
1721
2192
  const readyNums = new Set(ready.map((t) => t.num));
@@ -1735,125 +2206,89 @@ async function runWavesIsolated(opts) {
1735
2206
  filename: t.filename
1736
2207
  }))
1737
2208
  );
1738
- const worktreeSettled = await Promise.allSettled(
1739
- ready.map((t) => createWorktree({
2209
+ const outcomes = await Promise.all(
2210
+ ready.map((t) => runTicketAttempts({
2211
+ ticket: t,
1740
2212
  repoRoot,
1741
2213
  repoName,
1742
2214
  baseBranch,
1743
- 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
1744
2226
  }))
1745
2227
  );
1746
- const activeTickets = [];
1747
- const worktrees = [];
1748
- for (let i = 0; i < worktreeSettled.length; i++) {
1749
- 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];
1750
2234
  const ticket = ready[i];
1751
- if (settled.status === "rejected") {
2235
+ if (outcome.kind === "worktree-error") {
2236
+ failedNums.add(ticket.num);
1752
2237
  d.setTicketStatus(ticket.num, "failed");
1753
2238
  d.log(
1754
- ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(settled.reason)}`
2239
+ ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(outcome.error)}`
1755
2240
  );
1756
2241
  continue;
1757
2242
  }
1758
- activeTickets.push(ticket);
1759
- worktrees.push(settled.value);
1760
- }
1761
- const results = await Promise.allSettled(
1762
- activeTickets.map((t, i) => {
1763
- const prompt = renderPrompt(opts.specBody, t);
1764
- const logPath = join3(
1765
- opts.logsDir,
1766
- t.filename.replace(/\.md$/, ".log")
1767
- );
1768
- const label = t.filename.replace(
1769
- /\.md$/,
1770
- ""
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)
1771
2248
  );
1772
- d.setTicketStatus(t.num, "in-progress");
1773
- d.log(` Starting ${t.filename}`);
1774
- return spawnAgent({
1775
- prompt,
1776
- agentCmd: opts.agentCmd,
1777
- model: opts.model,
1778
- timeout: opts.timeout,
1779
- logPath,
1780
- cwd: worktrees[i].worktreePath,
1781
- label,
1782
- onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1783
- });
1784
- })
1785
- );
1786
- let waveCompleted = 0;
1787
- const successfulBranches = [];
1788
- const ticketsByNum = /* @__PURE__ */ new Map();
1789
- const worktreePathsByNum = /* @__PURE__ */ new Map();
1790
- for (let i = 0; i < results.length; i++) {
1791
- const r = results[i];
1792
- if (r.status === "rejected") continue;
1793
- const agentResult = r.value;
1794
- const ticket = activeTickets[i];
1795
- const worktree = worktrees[i];
1796
- if (agentResult.exitCode !== 0) {
2249
+ continue;
2250
+ }
2251
+ if (outcome.kind === "no-commits") {
2252
+ failedNums.add(ticket.num);
1797
2253
  d.setTicketStatus(ticket.num, "failed");
1798
2254
  d.log(
1799
- ` ${ticket.filename}: agent exited ${agentResult.exitCode}`
2255
+ ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
1800
2256
  );
1801
- 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
+ }
1802
2265
  checkTicket(progress, ticket.num, {
1803
- branch: worktree.branchName,
1804
- exitCode: agentResult.exitCode,
2266
+ branch: outcome.branchName,
2267
+ exitCode: outcome.exitCode,
1805
2268
  done: false
1806
2269
  });
1807
- writeProgress(progressPath, progress);
1808
- await commitProgress(
2270
+ await saveProgress(
1809
2271
  repoRoot,
1810
2272
  progressPath,
1811
- `nightralph: ${ticket.filename} failed`
1812
- );
1813
- try {
1814
- await removeWorktree(worktree.worktreePath);
1815
- } catch (err) {
1816
- d.log(
1817
- ` Failed to remove worktree ${worktree.worktreePath}: ` + formatErrorMessage(err)
1818
- );
1819
- }
1820
- continue;
1821
- }
1822
- if (isDirty(worktree.worktreePath)) {
1823
- const committed = await commitDirty(
1824
- worktree.worktreePath,
1825
- `nightralph: auto-commit ${ticket.filename} remaining changes`
1826
- );
1827
- if (committed) {
1828
- d.log(
1829
- ` ${ticket.filename}: auto-committed remaining changes`
1830
- );
1831
- }
1832
- }
1833
- if (!hasNewCommits(
1834
- repoRoot,
1835
- baseBranch,
1836
- worktree.branchName
1837
- )) {
1838
- d.setTicketStatus(ticket.num, "failed");
1839
- d.log(
1840
- ` ${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
1841
2276
  );
1842
2277
  continue;
1843
2278
  }
1844
- checkItems(ticket, agentResult.completedItems);
2279
+ checkItems(ticket, outcome.completedItems);
1845
2280
  d.log(
1846
2281
  ` ${ticket.filename}: agent finished, awaiting merge`
1847
2282
  );
1848
2283
  ticketsByNum.set(ticket.num, ticket);
1849
2284
  worktreePathsByNum.set(
1850
2285
  ticket.num,
1851
- worktree.worktreePath
2286
+ outcome.worktreePath
1852
2287
  );
1853
2288
  successfulBranches.push({
1854
- branch: worktree.branchName,
2289
+ branch: outcome.branchName,
1855
2290
  ticketNum: ticket.num,
1856
- worktreePath: worktree.worktreePath
2291
+ worktreePath: outcome.worktreePath
1857
2292
  });
1858
2293
  }
1859
2294
  if (successfulBranches.length > 0) {
@@ -1880,23 +2315,21 @@ async function runWavesIsolated(opts) {
1880
2315
  branch: branchResult.branch,
1881
2316
  exitCode: 0
1882
2317
  });
1883
- writeProgress(progressPath, progress);
1884
- await commitProgress(
2318
+ await saveProgress(
1885
2319
  repoRoot,
1886
2320
  progressPath,
1887
- `nightralph: ${ticket.filename} done`
2321
+ progress,
2322
+ `nightralph: ${ticket.filename} done`,
2323
+ d
1888
2324
  );
1889
2325
  const worktreePath = worktreePathsByNum.get(
1890
2326
  ticket.num
1891
2327
  );
1892
2328
  if (worktreePath) {
1893
- try {
1894
- await removeWorktree(worktreePath);
1895
- } catch (err) {
1896
- d.log(
1897
- ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
1898
- );
1899
- }
2329
+ await removeWorktreeQuietly(
2330
+ worktreePath,
2331
+ d
2332
+ );
1900
2333
  }
1901
2334
  } else {
1902
2335
  const worktreePath = worktreePathsByNum.get(
@@ -1913,6 +2346,8 @@ async function runWavesIsolated(opts) {
1913
2346
  model: opts.model,
1914
2347
  timeout: opts.timeout,
1915
2348
  logsDir: opts.logsDir,
2349
+ testCmd: opts.testCmd,
2350
+ maxTurns: opts.maxTurns,
1916
2351
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
1917
2352
  }) : false;
1918
2353
  if (recovered) {
@@ -1927,15 +2362,19 @@ async function runWavesIsolated(opts) {
1927
2362
  branch: branchResult.branch,
1928
2363
  exitCode: 0
1929
2364
  });
1930
- writeProgress(progressPath, progress);
1931
- await commitProgress(
2365
+ await saveProgress(
1932
2366
  repoRoot,
1933
2367
  progressPath,
1934
- `nightralph: ${ticket.filename} done (respawn)`
2368
+ progress,
2369
+ `nightralph: ${ticket.filename} done (respawn)`,
2370
+ d
1935
2371
  );
1936
2372
  if (worktreePath) {
1937
2373
  try {
1938
- await removeWorktree(worktreePath);
2374
+ await removeWorktree(
2375
+ worktreePath,
2376
+ { force: true }
2377
+ );
1939
2378
  } catch (err) {
1940
2379
  d.log(
1941
2380
  ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
@@ -1956,11 +2395,12 @@ async function runWavesIsolated(opts) {
1956
2395
  exitCode: 0,
1957
2396
  done: false
1958
2397
  });
1959
- writeProgress(progressPath, progress);
1960
- await commitProgress(
2398
+ await saveProgress(
1961
2399
  repoRoot,
1962
2400
  progressPath,
1963
- `nightralph: ${ticket.filename} merge failed`
2401
+ progress,
2402
+ `nightralph: ${ticket.filename} merge failed`,
2403
+ d
1964
2404
  );
1965
2405
  }
1966
2406
  }
@@ -1974,7 +2414,10 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1974
2414
  const ticket = ticketsByNum.get(sb.ticketNum);
1975
2415
  if (ticket && ticket.status === "done") continue;
1976
2416
  try {
1977
- await removeWorktree(sb.worktreePath);
2417
+ await removeWorktree(
2418
+ sb.worktreePath,
2419
+ { force: true }
2420
+ );
1978
2421
  } catch (err) {
1979
2422
  d.log(
1980
2423
  ` Failed to remove worktree ${sb.worktreePath}: ` + formatErrorMessage(err)
@@ -1996,7 +2439,7 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1996
2439
  );
1997
2440
  d.log(
1998
2441
  `
1999
- 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` : "")
2000
2443
  );
2001
2444
  d.cleanup();
2002
2445
  return { allDone: remaining.length === 0 };
@@ -2004,8 +2447,8 @@ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.
2004
2447
  __name(runWavesIsolated, "runWavesIsolated");
2005
2448
 
2006
2449
  // src/resolve.ts
2007
- import { existsSync as existsSync3, readdirSync as readdirSync3 } from "node:fs";
2008
- 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";
2009
2452
  import { fileURLToPath } from "node:url";
2010
2453
  import { select } from "@inquirer/prompts";
2011
2454
  function getScriptDir(metaUrl) {
@@ -2013,8 +2456,8 @@ function getScriptDir(metaUrl) {
2013
2456
  }
2014
2457
  __name(getScriptDir, "getScriptDir");
2015
2458
  function findFeatureDirs(cwd) {
2016
- const scratchDir = join4(cwd, ".scratch");
2017
- if (!existsSync3(scratchDir)) return [];
2459
+ const scratchDir = join5(cwd, ".scratch");
2460
+ if (!existsSync4(scratchDir)) return [];
2018
2461
  const entries = readdirSync3(
2019
2462
  scratchDir,
2020
2463
  { withFileTypes: true }
@@ -2022,17 +2465,17 @@ function findFeatureDirs(cwd) {
2022
2465
  const results = [];
2023
2466
  for (const entry of entries) {
2024
2467
  if (!entry.isDirectory()) continue;
2025
- const issuesDir = join4(
2468
+ const issuesDir = join5(
2026
2469
  scratchDir,
2027
2470
  entry.name,
2028
2471
  "issues"
2029
2472
  );
2030
- const specFile = join4(
2473
+ const specFile = join5(
2031
2474
  scratchDir,
2032
2475
  entry.name,
2033
2476
  "spec.md"
2034
2477
  );
2035
- if (existsSync3(issuesDir)) {
2478
+ if (existsSync4(issuesDir)) {
2036
2479
  results.push({
2037
2480
  name: entry.name,
2038
2481
  dir: issuesDir,
@@ -2045,10 +2488,10 @@ function findFeatureDirs(cwd) {
2045
2488
  __name(findFeatureDirs, "findFeatureDirs");
2046
2489
  async function resolveIssuesDir(specName, cwd = process.cwd()) {
2047
2490
  if (specName) {
2048
- const featureDir = join4(cwd, ".scratch", specName);
2491
+ const featureDir = join5(cwd, ".scratch", specName);
2049
2492
  return {
2050
- dir: join4(featureDir, "issues"),
2051
- spec: join4(featureDir, "spec.md")
2493
+ dir: join5(featureDir, "issues"),
2494
+ spec: join5(featureDir, "spec.md")
2052
2495
  };
2053
2496
  }
2054
2497
  const features = findFeatureDirs(cwd);
@@ -2111,8 +2554,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2111
2554
  }
2112
2555
  }
2113
2556
  setup({
2114
- bundledSkillsDir: join5(scriptDir, "skills"),
2115
- docsTemplatesDir: join5(
2557
+ bundledSkillsDir: join6(scriptDir, "skills"),
2558
+ docsTemplatesDir: join6(
2116
2559
  scriptDir,
2117
2560
  "docs-templates"
2118
2561
  ),
@@ -2142,11 +2585,29 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2142
2585
  }).option("timeout", {
2143
2586
  type: "number",
2144
2587
  describe: "Kill agent after N seconds",
2145
- 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.'
2146
2592
  }).option("X", {
2147
2593
  type: "boolean",
2148
2594
  describe: "Stop on merge conflict instead of re-spawning",
2149
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."
2150
2611
  }),
2151
2612
  async (argv) => {
2152
2613
  await runExecute({
@@ -2155,7 +2616,12 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
2155
2616
  spec: argv.spec,
2156
2617
  dryRun: argv.dryRun,
2157
2618
  timeout: argv.timeout,
2158
- 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
2159
2625
  });
2160
2626
  }
2161
2627
  ).strict().demandCommand(1, "Specify a command or provider").help().parse();
@@ -2183,15 +2649,18 @@ async function runExecute(argv) {
2183
2649
  const { dir, spec } = await resolveIssuesDir(
2184
2650
  argv.spec
2185
2651
  );
2186
- if (!existsSync4(dir)) {
2652
+ if (!existsSync5(dir)) {
2187
2653
  console.error(`Directory not found: ${dir}`);
2188
2654
  process.exit(2);
2189
2655
  }
2190
- if (!existsSync4(spec)) {
2656
+ if (!existsSync5(spec)) {
2191
2657
  console.error(`Spec file not found: ${spec}`);
2192
2658
  process.exit(2);
2193
2659
  }
2194
- const specBody = readFileSync4(spec, "utf8");
2660
+ const specBody = stripSpecSection(
2661
+ readFileSync5(spec, "utf8"),
2662
+ "User Stories"
2663
+ );
2195
2664
  const tickets = scanTickets(dir);
2196
2665
  if (tickets.length === 0) {
2197
2666
  console.log("No ticket files found. Exiting.");
@@ -2212,11 +2681,14 @@ async function runExecute(argv) {
2212
2681
  (b) => knownNums.has(b)
2213
2682
  );
2214
2683
  }
2215
- const logsDir = join5(dirname3(dir), "logs");
2216
- 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;
2217
2689
  const featureName = extractFeatureName(spec);
2218
2690
  const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
2219
- const progressPath = join5(
2691
+ const progressPath = join6(
2220
2692
  dirname3(dir),
2221
2693
  "progress.md"
2222
2694
  );
@@ -2228,7 +2700,11 @@ async function runExecute(argv) {
2228
2700
  specBody,
2229
2701
  repoName,
2230
2702
  conflictStrategy,
2231
- progressPath
2703
+ progressPath,
2704
+ testCmd,
2705
+ thinking: argv.thinking,
2706
+ retries: argv.retries,
2707
+ retryDelay: argv.retryDelay
2232
2708
  });
2233
2709
  } else {
2234
2710
  dryRun(tickets, specBody);
@@ -2236,6 +2712,14 @@ async function runExecute(argv) {
2236
2712
  return;
2237
2713
  }
2238
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));
2239
2723
  let result;
2240
2724
  if (isGitRepo()) {
2241
2725
  result = await runWavesIsolated({
@@ -2247,7 +2731,12 @@ async function runExecute(argv) {
2247
2731
  logsDir,
2248
2732
  featureName,
2249
2733
  conflictStrategy,
2250
- display
2734
+ display,
2735
+ testCmd,
2736
+ maxTurns: argv.maxTurns,
2737
+ thinking: argv.thinking,
2738
+ retries: argv.retries,
2739
+ retryDelay: argv.retryDelay
2251
2740
  });
2252
2741
  } else {
2253
2742
  result = await runWaves(
@@ -2257,7 +2746,11 @@ async function runExecute(argv) {
2257
2746
  argv.model ?? null,
2258
2747
  timeout,
2259
2748
  logsDir,
2260
- display
2749
+ display,
2750
+ argv.maxTurns,
2751
+ argv.thinking,
2752
+ argv.retries,
2753
+ argv.retryDelay
2261
2754
  );
2262
2755
  }
2263
2756
  if (!result.allDone) {