nightralph 0.0.52 → 0.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +145 -16
  2. package/dist/display.js +80 -62
  3. package/dist/display.js.map +2 -2
  4. package/dist/index.js +2099 -314
  5. package/dist/index.js.map +4 -4
  6. package/dist/learnings.js +356 -0
  7. package/dist/learnings.js.map +7 -0
  8. package/dist/meta.json +234 -14
  9. package/dist/orchestrator.js +528 -142
  10. package/dist/orchestrator.js.map +3 -3
  11. package/dist/preflight.js +34 -0
  12. package/dist/preflight.js.map +7 -0
  13. package/dist/progress.js +5 -4
  14. package/dist/progress.js.map +2 -2
  15. package/dist/retry.js +83 -0
  16. package/dist/retry.js.map +7 -0
  17. package/dist/review.js +662 -0
  18. package/dist/review.js.map +7 -0
  19. package/dist/rules.js +54 -0
  20. package/dist/rules.js.map +7 -0
  21. package/dist/src/display.d.ts +35 -33
  22. package/dist/src/display.d.ts.map +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/learnings.d.ts +68 -0
  25. package/dist/src/learnings.d.ts.map +1 -0
  26. package/dist/src/orchestrator.d.ts +24 -11
  27. package/dist/src/orchestrator.d.ts.map +1 -1
  28. package/dist/src/preflight.d.ts +6 -0
  29. package/dist/src/preflight.d.ts.map +1 -0
  30. package/dist/src/progress.d.ts +1 -1
  31. package/dist/src/progress.d.ts.map +1 -1
  32. package/dist/src/retry.d.ts +26 -0
  33. package/dist/src/retry.d.ts.map +1 -0
  34. package/dist/src/review.d.ts +127 -0
  35. package/dist/src/review.d.ts.map +1 -0
  36. package/dist/src/rules.d.ts +10 -0
  37. package/dist/src/rules.d.ts.map +1 -0
  38. package/dist/src/testcmd.d.ts +1 -0
  39. package/dist/src/testcmd.d.ts.map +1 -1
  40. package/dist/src/worktree.d.ts +30 -1
  41. package/dist/src/worktree.d.ts.map +1 -1
  42. package/dist/testcmd.js +13 -0
  43. package/dist/testcmd.js.map +2 -2
  44. package/dist/worktree.js +236 -32
  45. package/dist/worktree.js.map +2 -2
  46. package/package.json +5 -4
@@ -7,10 +7,11 @@ import {
7
7
  readFileSync,
8
8
  writeFileSync,
9
9
  unlinkSync,
10
- readdirSync
10
+ readdirSync,
11
+ statSync
11
12
  } from "node:fs";
12
13
  import { tmpdir } from "node:os";
13
- import { join, dirname, basename } from "node:path";
14
+ import { join, dirname, basename, relative } from "node:path";
14
15
  import { createInterface } from "node:readline";
15
16
  import { promisify } from "node:util";
16
17
  const execFileAsync = promisify(execFile);
@@ -23,7 +24,8 @@ import {
23
24
  buildBranchName,
24
25
  hasNewCommits,
25
26
  isDirty,
26
- commitDirty
27
+ commitDirty,
28
+ headCommit
27
29
  } from "./worktree.js";
28
30
  import {
29
31
  generateProgress,
@@ -41,9 +43,47 @@ import {
41
43
  updateWorktreeFromBase
42
44
  } from "./merge.js";
43
45
  import {
44
- createDisplay
46
+ createDisplay,
47
+ ticketLabel
45
48
  } from "./display.js";
46
49
  import { runTestCmd } from "./testcmd.js";
50
+ import {
51
+ readDocsRule,
52
+ installRule,
53
+ testGateRule,
54
+ LINT_RULE,
55
+ integrityRule,
56
+ CONFINEMENT_RULES,
57
+ commitRule,
58
+ notesRule,
59
+ agentsMdRule
60
+ } from "./rules.js";
61
+ import { runReviewPass, renderReviewSummary } from "./review.js";
62
+ import {
63
+ EFFORT_LADDERS,
64
+ escalateEffort,
65
+ retryDelayFor,
66
+ runWithRetries,
67
+ sleep
68
+ } from "./retry.js";
69
+ import {
70
+ EFFORT_LADDERS as EFFORT_LADDERS2,
71
+ ALL_EFFORT_LEVELS,
72
+ escalateEffort as escalateEffort2,
73
+ retryDelayFor as retryDelayFor2
74
+ } from "./retry.js";
75
+ import {
76
+ LOG_TAIL_LINES,
77
+ appendEntry,
78
+ collectNotes,
79
+ ensureNotesIgnored,
80
+ learningsPathFor,
81
+ readEntries,
82
+ renderEntry,
83
+ renderHistory,
84
+ selectHistory,
85
+ tailLines
86
+ } from "./learnings.js";
47
87
  function formatErrorMessage(error) {
48
88
  return error instanceof Error ? error.message : String(error);
49
89
  }
@@ -165,6 +205,37 @@ function findReadyTickets(tickets) {
165
205
  });
166
206
  }
167
207
  __name(findReadyTickets, "findReadyTickets");
208
+ function findBlockedTickets(tickets, failedKeys) {
209
+ const byKey = new Map(
210
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
211
+ );
212
+ function collectDead(ticket, visited, found) {
213
+ for (const b of ticket.blockers) {
214
+ const key = ticketKey(ticket.feature, b);
215
+ if (visited.has(key)) continue;
216
+ visited.add(key);
217
+ const blocker = byKey.get(key);
218
+ if (!blocker || blocker.status === "done") continue;
219
+ if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
220
+ found.add(key);
221
+ continue;
222
+ }
223
+ collectDead(blocker, visited, found);
224
+ }
225
+ }
226
+ __name(collectDead, "collectDead");
227
+ const blocked = /* @__PURE__ */ new Map();
228
+ for (const t of tickets) {
229
+ const key = ticketKey(t.feature, t.num);
230
+ if (t.status !== "ready-for-agent") continue;
231
+ if (failedKeys.has(key)) continue;
232
+ const found = /* @__PURE__ */ new Set();
233
+ collectDead(t, /* @__PURE__ */ new Set([key]), found);
234
+ if (found.size > 0) blocked.set(key, [...found]);
235
+ }
236
+ return blocked;
237
+ }
238
+ __name(findBlockedTickets, "findBlockedTickets");
168
239
  const COMPLETED_RE = /^\s*[-*+]?\s*\[x\]\s+(.+)$/i;
169
240
  const TOOL_USE_RE = /^\[[\w.]+\]/;
170
241
  const CHECKBOX_RE = /^(\s*[-*+] )\[( |x|X)\]( .*)$/;
@@ -305,11 +376,23 @@ function stripSpecSection(specBody, heading) {
305
376
  return out.join("\n");
306
377
  }
307
378
  __name(stripSpecSection, "stripSpecSection");
379
+ function reviewSpecBody(specBody, specBodies) {
380
+ if (!specBodies || specBodies.size <= 1) return specBody;
381
+ return [...specBodies].map(([name, body]) => `# Feature: ${name}
382
+
383
+ ${body}`).join("\n\n");
384
+ }
385
+ __name(reviewSpecBody, "reviewSpecBody");
308
386
  function lookupSpec(specBodies, fallback, feature) {
309
387
  if (!feature || !specBodies) return fallback;
310
388
  return specBodies.get(feature) ?? fallback;
311
389
  }
312
390
  __name(lookupSpec, "lookupSpec");
391
+ function historyBlock(history) {
392
+ if (!history) return [];
393
+ return [history, ""];
394
+ }
395
+ __name(historyBlock, "historyBlock");
313
396
  function renderPrompt(specBody, ticket, opts) {
314
397
  return [
315
398
  "---- SPEC CONTEXT ----",
@@ -319,27 +402,29 @@ function renderPrompt(specBody, ticket, opts) {
319
402
  ticket.filename,
320
403
  ticket.body,
321
404
  "",
405
+ ...historyBlock(opts.history),
322
406
  "---- AGENT INSTRUCTIONS ----",
323
407
  "You are an autonomous coding agent. Implement the ticket using /tdd.",
324
408
  "",
325
409
  "Follow these rules:",
326
- "- Read the files directly relevant to the ticket before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.",
410
+ readDocsRule("ticket"),
327
411
  "- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
328
412
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
329
- "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor.",
330
- `- While iterating, run only the tests relevant to what you changed. Run \`${opts.testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`,
331
- "- If the project has a lint script, run it and fix what it reports.",
332
- "- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a checklist item done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and say what blocked you.",
333
- "- Do NOT read or modify files outside your current directory (the worktree).",
334
- "- Do NOT run git push.",
335
- "- Commit after each checklist item, and make sure everything is committed when finished.",
413
+ ...installRule(opts.installCmd),
414
+ "- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor. For a checklist item that is pure scaffolding, config, or docs (where a test adds no value), skip test-first and record that decision in your notes file.",
415
+ testGateRule(opts.testCmd, "ticket"),
416
+ LINT_RULE,
417
+ integrityRule("checklist item"),
418
+ ...CONFINEMENT_RULES,
419
+ commitRule("checklist item"),
420
+ notesRule("checklist item", "ticket"),
336
421
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
337
- "- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ticket-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory.",
422
+ agentsMdRule("ticket"),
338
423
  ""
339
424
  ].join("\n");
340
425
  }
341
426
  __name(renderPrompt, "renderPrompt");
342
- function renderMergePrompt(specBody, ticket) {
427
+ function renderMergePrompt(specBody, ticket, opts = {}) {
343
428
  return [
344
429
  "---- SPEC CONTEXT ----",
345
430
  specBody,
@@ -360,6 +445,7 @@ function renderMergePrompt(specBody, ticket) {
360
445
  "4. Commit the resolved files.",
361
446
  "",
362
447
  "Do NOT re-implement the ticket from scratch.",
448
+ ...installRule(opts.installCmd),
363
449
  "Do NOT read or modify files outside your current directory (the worktree).",
364
450
  "Do NOT run git push."
365
451
  ].join("\n");
@@ -473,42 +559,6 @@ function getProviderArgs(cmd) {
473
559
  return PROVIDER_ARGS[name] ?? [];
474
560
  }
475
561
  __name(getProviderArgs, "getProviderArgs");
476
- const EFFORT_LADDERS = {
477
- pi: [
478
- "off",
479
- "minimal",
480
- "low",
481
- "medium",
482
- "high",
483
- "xhigh",
484
- "max"
485
- ],
486
- claude: [
487
- "low",
488
- "medium",
489
- "high",
490
- "xhigh",
491
- "max"
492
- ],
493
- codex: ["low", "medium", "high"]
494
- };
495
- const ALL_EFFORT_LEVELS = [
496
- "off",
497
- "minimal",
498
- "low",
499
- "medium",
500
- "high",
501
- "xhigh",
502
- "max"
503
- ];
504
- function escalateEffort(provider, level) {
505
- if (!level) return "high";
506
- const ladder = EFFORT_LADDERS[provider];
507
- const i = ladder.indexOf(level);
508
- if (i === -1) return level;
509
- return ladder[Math.min(i + 1, ladder.length - 1)];
510
- }
511
- __name(escalateEffort, "escalateEffort");
512
562
  function formatStreamLine(line) {
513
563
  let obj;
514
564
  try {
@@ -701,7 +751,7 @@ function spawnAgent(opts) {
701
751
  detached: true
702
752
  });
703
753
  if (proc.pid !== void 0) runningAgents.add(proc.pid);
704
- proc.stdout.pipe(logStream);
754
+ proc.stdout.pipe(logStream, { end: false });
705
755
  const piFormatter = isPi ? createPiStreamFormatter() : null;
706
756
  const completedItems = [];
707
757
  const seenItems = /* @__PURE__ */ new Set();
@@ -750,7 +800,7 @@ function spawnAgent(opts) {
750
800
  }
751
801
  }
752
802
  });
753
- proc.stderr.pipe(logStream);
803
+ proc.stderr.pipe(logStream, { end: false });
754
804
  const rlErr = createInterface({ input: proc.stderr });
755
805
  rlErr.on("line", (line) => {
756
806
  if (isClaude && !TOOL_USE_RE.test(line)) return;
@@ -835,16 +885,21 @@ function spawnAgent(opts) {
835
885
  }
836
886
  rl.close();
837
887
  rlErr.close();
838
- logStream.end();
888
+ const msg = "agent process error: " + formatErrorMessage(err);
889
+ logStream.write(`nightralph: ${msg}
890
+ `);
839
891
  if (promptFile) cleanupPromptFile(promptFile);
840
- console.error(
841
- "Agent process error:",
842
- formatErrorMessage(err)
843
- );
844
- resolve({
845
- exitCode: 1,
846
- completedItems,
847
- timedOut
892
+ if (opts.onLine) {
893
+ opts.onLine(`${prefix}${msg}`);
894
+ } else {
895
+ console.error(`${prefix}${msg}`);
896
+ }
897
+ logStream.end(() => {
898
+ resolve({
899
+ exitCode: 1,
900
+ completedItems,
901
+ timedOut
902
+ });
848
903
  });
849
904
  });
850
905
  });
@@ -864,6 +919,8 @@ function dryRun(tickets, specBody, testCmd) {
864
919
  "\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
865
920
  );
866
921
  }
922
+ console.log(`
923
+ Test command: ${testCmd || "(none)"}`);
867
924
  const firstReady = findReadyTickets(tickets);
868
925
  if (firstReady.length > 0) {
869
926
  console.log(
@@ -919,14 +976,20 @@ Prompt for first ready ticket (${firstReady[0].filename}):
919
976
  renderPrompt(
920
977
  opts.specBody,
921
978
  firstReady[0],
922
- { testCmd: opts.testCmd }
979
+ {
980
+ testCmd: opts.testCmd,
981
+ installCmd: opts.installCmd
982
+ }
923
983
  )
924
984
  );
925
985
  }
926
986
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
927
987
  console.log(
928
988
  `
929
- Test command: ${opts.testCmd}`
989
+ Test command: ${opts.testCmd || "(none)"}`
990
+ );
991
+ console.log(
992
+ `Install: ${opts.installCmd || "(none)"}`
930
993
  );
931
994
  console.log(
932
995
  `Effort: ${opts.effort ?? "(provider default)"}`
@@ -947,7 +1010,34 @@ Merge strategy: ${strategyDesc}`
947
1010
  console.log(`Progress file: ${opts.progressPath}`);
948
1011
  }
949
1012
  __name(dryRunIsolated, "dryRunIsolated");
1013
+ async function runInstall(opts) {
1014
+ const result = await runTestCmd({
1015
+ cmd: opts.installCmd,
1016
+ cwd: opts.worktreePath,
1017
+ timeout: opts.timeout,
1018
+ registry: opts.registry
1019
+ });
1020
+ const installLogPath = attemptLogPath(
1021
+ opts.logsDir,
1022
+ opts.ticket,
1023
+ opts.attempt ?? 1,
1024
+ ".install.log"
1025
+ );
1026
+ writeFileSync(installLogPath, result.output);
1027
+ if (result.exitCode !== 0) {
1028
+ opts.log(
1029
+ ` ${opts.ticket.filename}: install failed (exit ${result.exitCode}); see ${installLogPath}`
1030
+ );
1031
+ return false;
1032
+ }
1033
+ return true;
1034
+ }
1035
+ __name(runInstall, "runInstall");
950
1036
  async function runTestGate(opts) {
1037
+ if (opts.testCmd === "") {
1038
+ opts.log(` ${opts.ticket.filename}: no test gate`);
1039
+ return true;
1040
+ }
951
1041
  const result = await runTestCmd({
952
1042
  cmd: opts.testCmd,
953
1043
  cwd: opts.worktreePath,
@@ -989,9 +1079,22 @@ async function respawnAndRetryMerge(opts) {
989
1079
  } else {
990
1080
  console.log(msg1);
991
1081
  }
1082
+ if (opts.installCmd) {
1083
+ const installed = await runInstall({
1084
+ installCmd: opts.installCmd,
1085
+ ticket: opts.ticket,
1086
+ worktreePath: opts.worktreePath,
1087
+ timeout: opts.timeout,
1088
+ logsDir: opts.logsDir,
1089
+ log,
1090
+ registry: runningAgents
1091
+ });
1092
+ if (!installed) return false;
1093
+ }
992
1094
  const prompt = renderMergePrompt(
993
1095
  opts.specBody,
994
- opts.ticket
1096
+ opts.ticket,
1097
+ { installCmd: opts.installCmd }
995
1098
  );
996
1099
  const logPath = join(
997
1100
  opts.logsDir,
@@ -1090,20 +1193,31 @@ async function runPool(items, limit, fn) {
1090
1193
  return results;
1091
1194
  }
1092
1195
  __name(runPool, "runPool");
1093
- function retryDelayFor(base, attempt) {
1094
- return base * 2 ** (attempt - 2);
1095
- }
1096
- __name(retryDelayFor, "retryDelayFor");
1097
- function sleep(ms) {
1098
- return new Promise((resolve) => setTimeout(resolve, ms));
1099
- }
1100
- __name(sleep, "sleep");
1101
1196
  function attemptLogPath(logsDir, ticket, attempt, ext) {
1102
1197
  const stem = ticket.filename.replace(/\.md$/, "");
1103
1198
  const suffix = attempt > 1 ? `.attempt${attempt}` : "";
1104
1199
  return join(logsDir, `${stem}${suffix}${ext}`);
1105
1200
  }
1106
1201
  __name(attemptLogPath, "attemptLogPath");
1202
+ function buildHistory(learningsPath, ticket, d) {
1203
+ try {
1204
+ return renderHistory(selectHistory(
1205
+ readEntries(learningsPath),
1206
+ {
1207
+ key: ticketKey(ticket.feature, ticket.num),
1208
+ blockerKeys: ticket.blockers.map((b) => {
1209
+ return ticketKey(ticket.feature, b);
1210
+ })
1211
+ }
1212
+ ));
1213
+ } catch (err) {
1214
+ d.log(
1215
+ ` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
1216
+ );
1217
+ return "";
1218
+ }
1219
+ }
1220
+ __name(buildHistory, "buildHistory");
1107
1221
  async function runTicketAttempt(opts) {
1108
1222
  const { ticket, d } = opts;
1109
1223
  let worktree;
@@ -1122,6 +1236,38 @@ async function runTicketAttempt(opts) {
1122
1236
  "in-progress",
1123
1237
  ticket.feature
1124
1238
  );
1239
+ if (opts.installCmd) {
1240
+ const installed = await runInstall({
1241
+ installCmd: opts.installCmd,
1242
+ ticket,
1243
+ worktreePath: worktree.worktreePath,
1244
+ timeout: opts.timeout,
1245
+ logsDir: opts.logsDir,
1246
+ attempt: opts.attempt,
1247
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log"),
1248
+ registry: runningAgents
1249
+ });
1250
+ if (!installed) {
1251
+ await removeWorktreeQuietly(
1252
+ worktree.worktreePath,
1253
+ d
1254
+ );
1255
+ return {
1256
+ kind: "failed",
1257
+ reason: "install",
1258
+ branchName: worktree.branchName,
1259
+ exitCode: 1,
1260
+ completedItems: []
1261
+ };
1262
+ }
1263
+ }
1264
+ try {
1265
+ ensureNotesIgnored(worktree.worktreePath);
1266
+ } catch (err) {
1267
+ d.log(
1268
+ ` ${ticket.filename}: could not set up the notes directory: ${formatErrorMessage(err)}`
1269
+ );
1270
+ }
1125
1271
  const ticketSpec = lookupSpec(
1126
1272
  opts.specBodies,
1127
1273
  opts.specBody,
@@ -1131,7 +1277,17 @@ async function runTicketAttempt(opts) {
1131
1277
  prompt: renderPrompt(
1132
1278
  ticketSpec,
1133
1279
  ticket,
1134
- { testCmd: opts.testCmd }
1280
+ {
1281
+ testCmd: opts.testCmd,
1282
+ installCmd: opts.installCmd,
1283
+ // Rebuilt every attempt, so a retry sees the
1284
+ // entry its previous attempt just wrote
1285
+ history: buildHistory(
1286
+ opts.learningsPath,
1287
+ ticket,
1288
+ d
1289
+ )
1290
+ }
1135
1291
  ),
1136
1292
  agentCmd: opts.agentCmd,
1137
1293
  model: opts.model,
@@ -1148,6 +1304,20 @@ async function runTicketAttempt(opts) {
1148
1304
  effort: opts.effort,
1149
1305
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1150
1306
  });
1307
+ let notes = null;
1308
+ try {
1309
+ notes = collectNotes(worktree.worktreePath);
1310
+ } catch (err) {
1311
+ d.log(
1312
+ ` ${ticket.filename}: could not collect notes: ${formatErrorMessage(err)}`
1313
+ );
1314
+ }
1315
+ const agentRun = {
1316
+ exitCode: agentResult.exitCode,
1317
+ completedItems: agentResult.completedItems,
1318
+ timedOut: agentResult.timedOut,
1319
+ notes
1320
+ };
1151
1321
  if (agentResult.timedOut) {
1152
1322
  d.log(
1153
1323
  ` ${ticket.filename}: timed out; keeping the work it committed`
@@ -1162,7 +1332,8 @@ async function runTicketAttempt(opts) {
1162
1332
  reason: "exit",
1163
1333
  branchName: worktree.branchName,
1164
1334
  exitCode: agentResult.exitCode,
1165
- completedItems: agentResult.completedItems
1335
+ completedItems: agentResult.completedItems,
1336
+ agentRun
1166
1337
  };
1167
1338
  }
1168
1339
  if (isDirty(worktree.worktreePath)) {
@@ -1185,7 +1356,8 @@ async function runTicketAttempt(opts) {
1185
1356
  return {
1186
1357
  kind: "no-commits",
1187
1358
  worktreePath: worktree.worktreePath,
1188
- branchName: worktree.branchName
1359
+ branchName: worktree.branchName,
1360
+ agentRun
1189
1361
  };
1190
1362
  }
1191
1363
  const passed = await runTestGate({
@@ -1208,44 +1380,130 @@ async function runTicketAttempt(opts) {
1208
1380
  reason: "tests",
1209
1381
  branchName: worktree.branchName,
1210
1382
  exitCode: 1,
1211
- completedItems: agentResult.completedItems
1383
+ completedItems: agentResult.completedItems,
1384
+ agentRun
1212
1385
  };
1213
1386
  }
1214
1387
  return {
1215
1388
  kind: "ready",
1216
1389
  worktreePath: worktree.worktreePath,
1217
1390
  branchName: worktree.branchName,
1218
- completedItems: agentResult.completedItems
1391
+ completedItems: agentResult.completedItems,
1392
+ agentRun
1219
1393
  };
1220
1394
  }
1221
1395
  __name(runTicketAttempt, "runTicketAttempt");
1222
- async function runTicketAttempts(opts) {
1223
- const { ticket, d } = opts;
1224
- const maxAttempts = opts.retries + 1;
1225
- const providerName = basename(opts.agentCmd);
1226
- const isKnown = providerName in EFFORT_LADDERS;
1227
- let effort = opts.effort;
1228
- let outcome = {
1229
- kind: "no-commits",
1230
- worktreePath: "",
1231
- branchName: ""
1396
+ function readLogTail(path) {
1397
+ try {
1398
+ return tailLines(readFileSync(path, "utf8"), LOG_TAIL_LINES);
1399
+ } catch {
1400
+ return "";
1401
+ }
1402
+ }
1403
+ __name(readLogTail, "readLogTail");
1404
+ function attemptEntry(opts) {
1405
+ const { logsDir, ticket, attempt, effort, outcome } = opts;
1406
+ const base = {
1407
+ key: ticketKey(ticket.feature, ticket.num),
1408
+ label: ticketLabel(ticket),
1409
+ attempt,
1410
+ now: /* @__PURE__ */ new Date()
1232
1411
  };
1233
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1234
- if (attempt === 1) {
1235
- d.log(` Starting ${ticket.filename}`);
1236
- } else {
1237
- if (isKnown) {
1238
- effort = escalateEffort(
1239
- providerName,
1240
- effort
1241
- );
1412
+ const logFor = /* @__PURE__ */ __name((ext) => {
1413
+ const path = attemptLogPath(logsDir, ticket, attempt, ext);
1414
+ return { logName: basename(path), logTail: readLogTail(path) };
1415
+ }, "logFor");
1416
+ switch (outcome.kind) {
1417
+ case "ready":
1418
+ return {
1419
+ ...base,
1420
+ outcome: "done",
1421
+ run: outcome.agentRun,
1422
+ effort
1423
+ };
1424
+ case "failed":
1425
+ switch (outcome.reason) {
1426
+ case "exit":
1427
+ return {
1428
+ ...base,
1429
+ outcome: "exit",
1430
+ run: outcome.agentRun,
1431
+ effort
1432
+ };
1433
+ case "tests":
1434
+ return {
1435
+ ...base,
1436
+ outcome: "tests",
1437
+ run: outcome.agentRun,
1438
+ effort,
1439
+ ...logFor(".test.log")
1440
+ };
1441
+ case "install":
1442
+ return {
1443
+ ...base,
1444
+ outcome: "install",
1445
+ ...logFor(".install.log")
1446
+ };
1447
+ default: {
1448
+ const _exhaustive = outcome;
1449
+ return _exhaustive;
1450
+ }
1242
1451
  }
1243
- const delay = retryDelayFor(opts.retryDelay, attempt);
1244
- d.log(
1245
- ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1246
- );
1247
- if (delay > 0) await sleep(delay * 1e3);
1452
+ case "no-commits":
1453
+ return {
1454
+ ...base,
1455
+ outcome: "no-commits",
1456
+ run: outcome.agentRun,
1457
+ effort
1458
+ };
1459
+ case "error":
1460
+ case "worktree-error":
1461
+ return {
1462
+ ...base,
1463
+ outcome: "error",
1464
+ error: formatErrorMessage(outcome.error)
1465
+ };
1466
+ default: {
1467
+ const _exhaustive = outcome;
1468
+ return _exhaustive;
1248
1469
  }
1470
+ }
1471
+ }
1472
+ __name(attemptEntry, "attemptEntry");
1473
+ function recordAttempt(opts) {
1474
+ try {
1475
+ const entry = attemptEntry(opts);
1476
+ if (!entry) return;
1477
+ appendEntry(opts.learningsPath, renderEntry(entry));
1478
+ } catch (err) {
1479
+ opts.d.log(
1480
+ ` ${opts.ticket.filename}: could not record learnings: ${formatErrorMessage(err)}`
1481
+ );
1482
+ }
1483
+ }
1484
+ __name(recordAttempt, "recordAttempt");
1485
+ async function runTicketAttempts(opts) {
1486
+ const { ticket, d } = opts;
1487
+ return runWithRetries({
1488
+ maxAttempts: opts.retries + 1,
1489
+ retryDelay: opts.retryDelay,
1490
+ agentCmd: opts.agentCmd,
1491
+ effort: opts.effort,
1492
+ label: ticket.filename,
1493
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
1494
+ }, async (attempt, effort) => {
1495
+ const record = /* @__PURE__ */ __name((o) => {
1496
+ recordAttempt({
1497
+ learningsPath: opts.learningsPath,
1498
+ logsDir: opts.logsDir,
1499
+ ticket,
1500
+ attempt,
1501
+ effort,
1502
+ outcome: o,
1503
+ d
1504
+ });
1505
+ }, "record");
1506
+ let outcome;
1249
1507
  try {
1250
1508
  outcome = await runTicketAttempt({
1251
1509
  ...opts,
@@ -1256,25 +1514,46 @@ async function runTicketAttempts(opts) {
1256
1514
  d.log(
1257
1515
  ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
1258
1516
  );
1259
- return { kind: "error", error };
1517
+ const errorOutcome = { kind: "error", error };
1518
+ record(errorOutcome);
1519
+ return { result: errorOutcome, stop: true };
1260
1520
  }
1521
+ record(outcome);
1261
1522
  if (outcome.kind === "ready") {
1262
1523
  d.setTicketStatus(
1263
1524
  ticket.num,
1264
1525
  "awaiting-merge",
1265
1526
  ticket.feature
1266
1527
  );
1267
- return outcome;
1528
+ return { result: outcome, stop: true };
1268
1529
  }
1269
- if (outcome.kind === "worktree-error") return outcome;
1270
- }
1271
- return outcome;
1530
+ return {
1531
+ result: outcome,
1532
+ stop: outcome.kind === "worktree-error"
1533
+ };
1534
+ });
1272
1535
  }
1273
1536
  __name(runTicketAttempts, "runTicketAttempts");
1537
+ function failureNote(reason) {
1538
+ if (reason === "tests") return "tests failed";
1539
+ if (reason === "install") return "install failed";
1540
+ return "failed";
1541
+ }
1542
+ __name(failureNote, "failureNote");
1543
+ function isRegularFile(path) {
1544
+ try {
1545
+ return statSync(path).isFile();
1546
+ } catch {
1547
+ return false;
1548
+ }
1549
+ }
1550
+ __name(isRegularFile, "isRegularFile");
1274
1551
  async function saveProgress(repoRoot, progressPath, progress, message, d) {
1275
1552
  writeProgress(progressPath, progress);
1553
+ const learningsPath = learningsPathFor(progressPath);
1554
+ const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
1276
1555
  try {
1277
- await commitProgress(repoRoot, progressPath, message);
1556
+ await commitProgress(repoRoot, paths, message);
1278
1557
  } catch (err) {
1279
1558
  d.log(
1280
1559
  " Failed to commit progress: " + formatErrorMessage(err)
@@ -1344,6 +1623,29 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
1344
1623
  return lines.join("\n");
1345
1624
  }
1346
1625
  __name(renderFinishSummary, "renderFinishSummary");
1626
+ function sessionTickets(tickets) {
1627
+ return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
1628
+ }
1629
+ __name(sessionTickets, "sessionTickets");
1630
+ function toRef(t) {
1631
+ return { num: t.num, filename: t.filename, feature: t.feature };
1632
+ }
1633
+ __name(toRef, "toRef");
1634
+ function reportBlocked(d, tickets, failedKeys) {
1635
+ const byKey = new Map(
1636
+ tickets.map((t) => [ticketKey(t.feature, t.num), t])
1637
+ );
1638
+ for (const [key, dead] of findBlockedTickets(
1639
+ tickets,
1640
+ failedKeys
1641
+ )) {
1642
+ const ticket = byKey.get(key);
1643
+ if (!ticket) continue;
1644
+ const labels = dead.map((k) => byKey.get(k)).filter((t) => t !== void 0).map((t) => ticketLabel(toRef(t)));
1645
+ d.setBlocked(ticket.num, ticket.feature, labels);
1646
+ }
1647
+ }
1648
+ __name(reportBlocked, "reportBlocked");
1347
1649
  function runProgress(tickets) {
1348
1650
  return {
1349
1651
  completed: tickets.filter((t) => t.status === "done").length,
@@ -1364,7 +1666,9 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1364
1666
  let totalCompleted = 0;
1365
1667
  let waveNum = 0;
1366
1668
  const failedKeys = /* @__PURE__ */ new Set();
1669
+ const session = sessionTickets(tickets);
1367
1670
  while (true) {
1671
+ if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
1368
1672
  const ready = findReadyTickets(tickets).filter(
1369
1673
  (t) => !failedKeys.has(
1370
1674
  ticketKey(t.feature, t.num)
@@ -1372,12 +1676,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1372
1676
  );
1373
1677
  if (ready.length === 0) break;
1374
1678
  waveNum++;
1375
- const readyKeys = new Set(
1376
- ready.map((t) => ticketKey(t.feature, t.num))
1377
- );
1378
- const remaining2 = tickets.filter(
1379
- (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
1380
- );
1679
+ if (waveNum === 1) {
1680
+ d.startRun(session.map(toRef));
1681
+ reportBlocked(d, tickets, failedKeys);
1682
+ }
1381
1683
  d.startWave(
1382
1684
  waveNum,
1383
1685
  totalWaves,
@@ -1386,12 +1688,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1386
1688
  filename: t.filename,
1387
1689
  feature: t.feature
1388
1690
  })),
1389
- runProgress(tickets),
1390
- remaining2.map((t) => ({
1391
- num: t.num,
1392
- filename: t.filename,
1393
- feature: t.feature
1394
- }))
1691
+ runProgress(session)
1395
1692
  );
1396
1693
  const results = await runPool(
1397
1694
  ready,
@@ -1488,10 +1785,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1488
1785
  );
1489
1786
  }
1490
1787
  if (waveCompleted === 0) {
1491
- const remaining3 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
1788
+ const remaining2 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
1492
1789
  ticketKey(t.feature, t.num)
1493
1790
  ));
1494
- if (remaining3.length === 0) {
1791
+ if (remaining2.length === 0) {
1495
1792
  d.log(
1496
1793
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
1497
1794
  );
@@ -1499,10 +1796,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
1499
1796
  }
1500
1797
  d.log(
1501
1798
  `
1502
- No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.length === 1 ? "" : "s"} remain; continuing.`
1799
+ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
1503
1800
  );
1504
1801
  }
1505
1802
  }
1803
+ if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
1506
1804
  const remaining = tickets.filter(
1507
1805
  (t) => t.status === "ready-for-agent"
1508
1806
  );
@@ -1531,6 +1829,7 @@ async function runWavesIsolated(opts) {
1531
1829
  "Working tree is dirty; commit or stash changes before running"
1532
1830
  );
1533
1831
  }
1832
+ const launchCommit = headCommit(repoRoot);
1534
1833
  const features = new Set(
1535
1834
  opts.tickets.map((t) => t.feature).filter(Boolean)
1536
1835
  );
@@ -1595,7 +1894,9 @@ async function runWavesIsolated(opts) {
1595
1894
  let totalCompleted = 0;
1596
1895
  let waveNum = 0;
1597
1896
  const failedKeys = /* @__PURE__ */ new Set();
1897
+ const session = sessionTickets(opts.tickets);
1598
1898
  while (true) {
1899
+ if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
1599
1900
  const ready = findReadyTickets(opts.tickets).filter(
1600
1901
  (t) => !failedKeys.has(
1601
1902
  ticketKey(t.feature, t.num)
@@ -1603,12 +1904,10 @@ async function runWavesIsolated(opts) {
1603
1904
  );
1604
1905
  if (ready.length === 0) break;
1605
1906
  waveNum++;
1606
- const readyKeys = new Set(
1607
- ready.map((t) => ticketKey(t.feature, t.num))
1608
- );
1609
- const remaining2 = opts.tickets.filter(
1610
- (t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
1611
- );
1907
+ if (waveNum === 1) {
1908
+ d.startRun(session.map(toRef));
1909
+ reportBlocked(d, opts.tickets, failedKeys);
1910
+ }
1612
1911
  d.startWave(
1613
1912
  waveNum,
1614
1913
  totalWaves,
@@ -1617,12 +1916,7 @@ async function runWavesIsolated(opts) {
1617
1916
  filename: t.filename,
1618
1917
  feature: t.feature
1619
1918
  })),
1620
- runProgress(opts.tickets),
1621
- remaining2.map((t) => ({
1622
- num: t.num,
1623
- filename: t.filename,
1624
- feature: t.feature
1625
- }))
1919
+ runProgress(session)
1626
1920
  );
1627
1921
  const settled = await runPool(
1628
1922
  ready,
@@ -1639,13 +1933,34 @@ async function runWavesIsolated(opts) {
1639
1933
  timeout: opts.timeout,
1640
1934
  logsDir: opts.logsDir,
1641
1935
  testCmd: opts.testCmd,
1936
+ installCmd: opts.installCmd,
1642
1937
  maxTurns: opts.maxTurns,
1643
1938
  effort: opts.effort,
1939
+ learningsPath: learningsPathFor(
1940
+ tracker.pathFor(t.feature)
1941
+ ),
1644
1942
  retries,
1645
1943
  retryDelay,
1646
1944
  d
1647
1945
  })
1648
1946
  );
1947
+ const touched = new Set(ready.map((t) => {
1948
+ return learningsPathFor(tracker.pathFor(t.feature));
1949
+ }));
1950
+ for (const path of touched) {
1951
+ if (!isRegularFile(path)) continue;
1952
+ try {
1953
+ await commitProgress(
1954
+ repoRoot,
1955
+ path,
1956
+ "nightralph: record attempt learnings"
1957
+ );
1958
+ } catch (err) {
1959
+ d.log(
1960
+ " Failed to commit learnings: " + formatErrorMessage(err)
1961
+ );
1962
+ }
1963
+ }
1649
1964
  const outcomes = settled.map(
1650
1965
  (r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
1651
1966
  );
@@ -1749,7 +2064,7 @@ async function runWavesIsolated(opts) {
1749
2064
  repoRoot,
1750
2065
  tracker.pathFor(ticket.feature),
1751
2066
  tracker.dataFor(ticket.feature),
1752
- `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
2067
+ `nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
1753
2068
  d
1754
2069
  );
1755
2070
  continue;
@@ -1785,6 +2100,7 @@ async function runWavesIsolated(opts) {
1785
2100
  branches: successfulBranches,
1786
2101
  strategy: opts.conflictStrategy
1787
2102
  });
2103
+ const doneThisWave = [];
1788
2104
  for (const branchResult of mergeResult.merged) {
1789
2105
  const bk = branchToKey.get(
1790
2106
  branchResult.branch
@@ -1793,6 +2109,7 @@ async function runWavesIsolated(opts) {
1793
2109
  if (!ticket) continue;
1794
2110
  if (branchResult.merged) {
1795
2111
  markDone(ticket);
2112
+ doneThisWave.push(ticket);
1796
2113
  waveCompleted++;
1797
2114
  totalCompleted++;
1798
2115
  d.setTicketStatus(
@@ -1842,11 +2159,13 @@ async function runWavesIsolated(opts) {
1842
2159
  timeout: opts.timeout,
1843
2160
  logsDir: opts.logsDir,
1844
2161
  testCmd: opts.testCmd,
2162
+ installCmd: opts.installCmd,
1845
2163
  maxTurns: opts.maxTurns,
1846
2164
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
1847
2165
  }) : false;
1848
2166
  if (recovered) {
1849
2167
  markDone(ticket);
2168
+ doneThisWave.push(ticket);
1850
2169
  waveCompleted++;
1851
2170
  totalCompleted++;
1852
2171
  d.setTicketStatus(
@@ -1884,6 +2203,11 @@ async function runWavesIsolated(opts) {
1884
2203
  failedKeys.add(
1885
2204
  ticketKey(ticket.feature, ticket.num)
1886
2205
  );
2206
+ d.setTicketStatus(
2207
+ ticket.num,
2208
+ "failed",
2209
+ ticket.feature
2210
+ );
1887
2211
  d.log(
1888
2212
  ` ${ticket.filename}: merge failed, staying ready-for-agent`
1889
2213
  );
@@ -1897,6 +2221,28 @@ async function runWavesIsolated(opts) {
1897
2221
  exitCode: 0,
1898
2222
  done: false
1899
2223
  });
2224
+ try {
2225
+ appendEntry(
2226
+ learningsPathFor(
2227
+ tracker.pathFor(ticket.feature)
2228
+ ),
2229
+ renderEntry({
2230
+ outcome: "merge",
2231
+ key: ticketKey(
2232
+ ticket.feature,
2233
+ ticket.num
2234
+ ),
2235
+ label: ticketLabel(ticket),
2236
+ attempt: 0,
2237
+ now: /* @__PURE__ */ new Date(),
2238
+ stderr: branchResult.stderr ?? ""
2239
+ })
2240
+ );
2241
+ } catch (err) {
2242
+ d.log(
2243
+ ` ${ticket.filename}: could not record learnings: ` + formatErrorMessage(err)
2244
+ );
2245
+ }
1900
2246
  await saveProgress(
1901
2247
  repoRoot,
1902
2248
  tracker.pathFor(ticket.feature),
@@ -1907,6 +2253,7 @@ async function runWavesIsolated(opts) {
1907
2253
  }
1908
2254
  }
1909
2255
  }
2256
+ await commitTicketFiles(repoRoot, doneThisWave, d);
1910
2257
  if (mergeResult.stoppedAt) {
1911
2258
  d.log(
1912
2259
  `
@@ -1931,12 +2278,12 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1931
2278
  }
1932
2279
  }
1933
2280
  if (waveCompleted === 0) {
1934
- const remaining3 = findReadyTickets(
2281
+ const remaining2 = findReadyTickets(
1935
2282
  opts.tickets
1936
2283
  ).filter((t) => !failedKeys.has(
1937
2284
  ticketKey(t.feature, t.num)
1938
2285
  ));
1939
- if (remaining3.length === 0) {
2286
+ if (remaining2.length === 0) {
1940
2287
  d.log(
1941
2288
  "\nNo tickets completed this wave and no ready tickets remain. Stopping."
1942
2289
  );
@@ -1944,10 +2291,46 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1944
2291
  }
1945
2292
  d.log(
1946
2293
  `
1947
- No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.length === 1 ? "" : "s"} remain; continuing.`
2294
+ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
1948
2295
  );
1949
2296
  }
1950
2297
  }
2298
+ if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
2299
+ let reviewLine = null;
2300
+ if (opts.review) {
2301
+ try {
2302
+ const outcome = await runReviewPass({
2303
+ repoRoot,
2304
+ repoName,
2305
+ baseBranch,
2306
+ reviewPath: opts.review.reviewPath,
2307
+ forkedFrom: opts.review.forkedFrom,
2308
+ launchCommit,
2309
+ specBody: reviewSpecBody(
2310
+ opts.specBody,
2311
+ opts.specBodies
2312
+ ),
2313
+ agentCmd: opts.agentCmd,
2314
+ model: opts.model,
2315
+ timeout: opts.timeout,
2316
+ logsDir: opts.logsDir,
2317
+ testCmd: opts.testCmd,
2318
+ installCmd: opts.installCmd,
2319
+ maxTurns: opts.maxTurns,
2320
+ effort: opts.effort,
2321
+ retries,
2322
+ retryDelay,
2323
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
2324
+ }, { spawnAgent, registry: runningAgents });
2325
+ reviewLine = renderReviewSummary(
2326
+ outcome,
2327
+ relative(repoRoot, opts.review.reviewPath)
2328
+ );
2329
+ } catch (err) {
2330
+ d.log(`
2331
+ Review failed: ${formatErrorMessage(err)}`);
2332
+ }
2333
+ }
1951
2334
  const remaining = opts.tickets.filter(
1952
2335
  (t) => t.status === "ready-for-agent"
1953
2336
  );
@@ -1956,8 +2339,10 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
1956
2339
  totalCompleted,
1957
2340
  failedKeys
1958
2341
  ));
2342
+ if (reviewLine) d.log(reviewLine);
2343
+ d.log(`Work is on ${baseBranch}`);
1959
2344
  d.cleanup();
1960
- return { allDone: remaining.length === 0 };
2345
+ return { allDone: remaining.length === 0, branch: baseBranch };
1961
2346
  }
1962
2347
  __name(runWavesIsolated, "runWavesIsolated");
1963
2348
  export {
@@ -1966,7 +2351,7 @@ export {
1966
2351
  DEFAULT_CONCURRENCY,
1967
2352
  DEFAULT_RETRIES,
1968
2353
  DEFAULT_RETRY_DELAY,
1969
- EFFORT_LADDERS,
2354
+ EFFORT_LADDERS2 as EFFORT_LADDERS,
1970
2355
  HUMAN_STATUS,
1971
2356
  TICKET_RE,
1972
2357
  TOOL_USE_RE,
@@ -1974,7 +2359,8 @@ export {
1974
2359
  createPiStreamFormatter,
1975
2360
  dryRun,
1976
2361
  dryRunIsolated,
1977
- escalateEffort,
2362
+ escalateEffort2 as escalateEffort,
2363
+ findBlockedTickets,
1978
2364
  findReadyTickets,
1979
2365
  formatErrorMessage,
1980
2366
  formatStreamLine,
@@ -1989,7 +2375,7 @@ export {
1989
2375
  renderFinishSummary,
1990
2376
  renderMergePrompt,
1991
2377
  renderPrompt,
1992
- retryDelayFor,
2378
+ retryDelayFor2 as retryDelayFor,
1993
2379
  runPool,
1994
2380
  runWaves,
1995
2381
  runWavesIsolated,