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.
- package/README.md +145 -16
- package/dist/display.js +80 -62
- package/dist/display.js.map +2 -2
- package/dist/index.js +2099 -314
- package/dist/index.js.map +4 -4
- package/dist/learnings.js +356 -0
- package/dist/learnings.js.map +7 -0
- package/dist/meta.json +234 -14
- package/dist/orchestrator.js +528 -142
- package/dist/orchestrator.js.map +3 -3
- package/dist/preflight.js +34 -0
- package/dist/preflight.js.map +7 -0
- package/dist/progress.js +5 -4
- package/dist/progress.js.map +2 -2
- package/dist/retry.js +83 -0
- package/dist/retry.js.map +7 -0
- package/dist/review.js +662 -0
- package/dist/review.js.map +7 -0
- package/dist/rules.js +54 -0
- package/dist/rules.js.map +7 -0
- package/dist/src/display.d.ts +35 -33
- package/dist/src/display.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/learnings.d.ts +68 -0
- package/dist/src/learnings.d.ts.map +1 -0
- package/dist/src/orchestrator.d.ts +24 -11
- package/dist/src/orchestrator.d.ts.map +1 -1
- package/dist/src/preflight.d.ts +6 -0
- package/dist/src/preflight.d.ts.map +1 -0
- package/dist/src/progress.d.ts +1 -1
- package/dist/src/progress.d.ts.map +1 -1
- package/dist/src/retry.d.ts +26 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/review.d.ts +127 -0
- package/dist/src/review.d.ts.map +1 -0
- package/dist/src/rules.d.ts +10 -0
- package/dist/src/rules.d.ts.map +1 -0
- package/dist/src/testcmd.d.ts +1 -0
- package/dist/src/testcmd.d.ts.map +1 -1
- package/dist/src/worktree.d.ts +30 -1
- package/dist/src/worktree.d.ts.map +1 -1
- package/dist/testcmd.js +13 -0
- package/dist/testcmd.js.map +2 -2
- package/dist/worktree.js +236 -32
- package/dist/worktree.js.map +2 -2
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -3,8 +3,9 @@ var __defProp = Object.defineProperty;
|
|
|
3
3
|
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
7
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
|
|
8
|
+
import { dirname as dirname6, join as join9 } from "node:path";
|
|
8
9
|
import yargs from "yargs";
|
|
9
10
|
import { hideBin } from "yargs/helpers";
|
|
10
11
|
import { select } from "@inquirer/prompts";
|
|
@@ -198,14 +199,15 @@ __name(setup, "setup");
|
|
|
198
199
|
import { spawn as spawn2, execFile as execFile4 } from "node:child_process";
|
|
199
200
|
import {
|
|
200
201
|
createWriteStream,
|
|
201
|
-
mkdirSync as
|
|
202
|
-
readFileSync as
|
|
203
|
-
writeFileSync as
|
|
202
|
+
mkdirSync as mkdirSync3,
|
|
203
|
+
readFileSync as readFileSync6,
|
|
204
|
+
writeFileSync as writeFileSync5,
|
|
204
205
|
unlinkSync,
|
|
205
|
-
readdirSync as readdirSync2
|
|
206
|
+
readdirSync as readdirSync2,
|
|
207
|
+
statSync
|
|
206
208
|
} from "node:fs";
|
|
207
209
|
import { tmpdir } from "node:os";
|
|
208
|
-
import { join as
|
|
210
|
+
import { join as join6, dirname as dirname4, basename as basename5, relative as relative2 } from "node:path";
|
|
209
211
|
import { createInterface } from "node:readline";
|
|
210
212
|
import { promisify as promisify4 } from "node:util";
|
|
211
213
|
|
|
@@ -233,15 +235,138 @@ function getRepoName() {
|
|
|
233
235
|
return basename(dirname(resolve(commonDir)));
|
|
234
236
|
}
|
|
235
237
|
__name(getRepoName, "getRepoName");
|
|
236
|
-
function getCurrentBranch() {
|
|
238
|
+
function getCurrentBranch(cwd) {
|
|
237
239
|
const stdout = execFileSync(
|
|
238
240
|
"git",
|
|
239
241
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
240
|
-
{ encoding: "utf8" }
|
|
242
|
+
{ cwd, encoding: "utf8" }
|
|
241
243
|
);
|
|
242
244
|
return stdout.trim();
|
|
243
245
|
}
|
|
244
246
|
__name(getCurrentBranch, "getCurrentBranch");
|
|
247
|
+
function localBranchExists(repoRoot, name) {
|
|
248
|
+
try {
|
|
249
|
+
execFileSync(
|
|
250
|
+
"git",
|
|
251
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${name}`],
|
|
252
|
+
{ cwd: repoRoot, stdio: "ignore" }
|
|
253
|
+
);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
__name(localBranchExists, "localBranchExists");
|
|
260
|
+
function getDefaultBranch(repoRoot) {
|
|
261
|
+
try {
|
|
262
|
+
const ref = execFileSync(
|
|
263
|
+
"git",
|
|
264
|
+
[
|
|
265
|
+
"symbolic-ref",
|
|
266
|
+
"--quiet",
|
|
267
|
+
"--short",
|
|
268
|
+
"refs/remotes/origin/HEAD"
|
|
269
|
+
],
|
|
270
|
+
{
|
|
271
|
+
cwd: repoRoot,
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
274
|
+
}
|
|
275
|
+
).trim();
|
|
276
|
+
if (ref) return ref.replace(/^origin\//, "");
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
for (const name of ["main", "master"]) {
|
|
280
|
+
if (localBranchExists(repoRoot, name)) return name;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
__name(getDefaultBranch, "getDefaultBranch");
|
|
285
|
+
function integrationBranchName(features) {
|
|
286
|
+
const distinct = [...new Set(features.filter(Boolean))];
|
|
287
|
+
return distinct.length === 1 ? `nightralph--${distinct[0]}` : "nightralph--all";
|
|
288
|
+
}
|
|
289
|
+
__name(integrationBranchName, "integrationBranchName");
|
|
290
|
+
function planIntegrationBranch(current, defaultBranch, features) {
|
|
291
|
+
if (!defaultBranch || current === "HEAD") return null;
|
|
292
|
+
if (current !== defaultBranch) return null;
|
|
293
|
+
return integrationBranchName(features);
|
|
294
|
+
}
|
|
295
|
+
__name(planIntegrationBranch, "planIntegrationBranch");
|
|
296
|
+
function gitText(cwd, args) {
|
|
297
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
298
|
+
}
|
|
299
|
+
__name(gitText, "gitText");
|
|
300
|
+
function prepareIntegrationBranch(repoRoot, features) {
|
|
301
|
+
const current = getCurrentBranch(repoRoot);
|
|
302
|
+
const defaultBranch = getDefaultBranch(repoRoot);
|
|
303
|
+
const name = planIntegrationBranch(
|
|
304
|
+
current,
|
|
305
|
+
defaultBranch,
|
|
306
|
+
features
|
|
307
|
+
);
|
|
308
|
+
if (!name) {
|
|
309
|
+
const onIntegration = defaultBranch !== null && current === integrationBranchName(features);
|
|
310
|
+
return {
|
|
311
|
+
branch: current,
|
|
312
|
+
created: false,
|
|
313
|
+
resumed: false,
|
|
314
|
+
behind: 0,
|
|
315
|
+
fastForwarded: false,
|
|
316
|
+
launchBranch: current,
|
|
317
|
+
forkedFrom: onIntegration ? defaultBranch : null
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (isDirty(repoRoot)) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
"Working tree is dirty; commit or stash changes before running"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (localBranchExists(repoRoot, name)) {
|
|
326
|
+
const contained = isAncestor(repoRoot, name, current);
|
|
327
|
+
gitText(repoRoot, ["checkout", "--quiet", name]);
|
|
328
|
+
let behind = Number(gitText(
|
|
329
|
+
repoRoot,
|
|
330
|
+
["rev-list", "--count", `${name}..${current}`]
|
|
331
|
+
));
|
|
332
|
+
const fastForwarded = contained && behind > 0;
|
|
333
|
+
if (fastForwarded) {
|
|
334
|
+
gitText(repoRoot, ["merge", "--ff-only", "--quiet", current]);
|
|
335
|
+
behind = 0;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
branch: name,
|
|
339
|
+
created: false,
|
|
340
|
+
resumed: true,
|
|
341
|
+
behind,
|
|
342
|
+
fastForwarded,
|
|
343
|
+
launchBranch: current,
|
|
344
|
+
forkedFrom: defaultBranch
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
gitText(repoRoot, ["checkout", "--quiet", "-b", name]);
|
|
348
|
+
return {
|
|
349
|
+
branch: name,
|
|
350
|
+
created: true,
|
|
351
|
+
resumed: false,
|
|
352
|
+
behind: 0,
|
|
353
|
+
fastForwarded: false,
|
|
354
|
+
launchBranch: current,
|
|
355
|
+
forkedFrom: defaultBranch
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
__name(prepareIntegrationBranch, "prepareIntegrationBranch");
|
|
359
|
+
function describeIntegrationBranch(repoRoot, features) {
|
|
360
|
+
const current = getCurrentBranch(repoRoot);
|
|
361
|
+
const name = planIntegrationBranch(
|
|
362
|
+
current,
|
|
363
|
+
getDefaultBranch(repoRoot),
|
|
364
|
+
features
|
|
365
|
+
);
|
|
366
|
+
if (!name) return `Branch: merges into ${current}`;
|
|
367
|
+
return localBranchExists(repoRoot, name) ? `Branch: would resume ${name}` : `Branch: would create ${name} from ${current}`;
|
|
368
|
+
}
|
|
369
|
+
__name(describeIntegrationBranch, "describeIntegrationBranch");
|
|
245
370
|
async function branchExists(repoRoot, branchName) {
|
|
246
371
|
try {
|
|
247
372
|
await execFileAsync(
|
|
@@ -261,16 +386,20 @@ function buildBranchName(repoName, ticket) {
|
|
|
261
386
|
return `${repoName}--${prefix}${num}-${ticket.slug}`;
|
|
262
387
|
}
|
|
263
388
|
__name(buildBranchName, "buildBranchName");
|
|
264
|
-
async function
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
389
|
+
async function removeWorktreeNow(worktreePath, opts) {
|
|
390
|
+
const args = [
|
|
391
|
+
"-C",
|
|
392
|
+
worktreePath,
|
|
393
|
+
"worktree",
|
|
394
|
+
"remove"
|
|
395
|
+
];
|
|
396
|
+
if (opts?.force) args.push("--force");
|
|
397
|
+
args.push(worktreePath);
|
|
398
|
+
await execFileAsync("git", args);
|
|
399
|
+
}
|
|
400
|
+
__name(removeWorktreeNow, "removeWorktreeNow");
|
|
401
|
+
async function addWorktreeNow(opts) {
|
|
402
|
+
const { repoRoot, baseBranch, branchName, worktreePath } = opts;
|
|
274
403
|
if (existsSync2(worktreePath)) {
|
|
275
404
|
try {
|
|
276
405
|
await execFileAsync(
|
|
@@ -310,36 +439,56 @@ async function createWorktreeNow(opts) {
|
|
|
310
439
|
);
|
|
311
440
|
return { worktreePath, branchName };
|
|
312
441
|
}
|
|
313
|
-
__name(
|
|
442
|
+
__name(addWorktreeNow, "addWorktreeNow");
|
|
314
443
|
var worktreeQueue = Promise.resolve();
|
|
315
|
-
function
|
|
316
|
-
const result = worktreeQueue.then(
|
|
444
|
+
function enqueue(fn) {
|
|
445
|
+
const result = worktreeQueue.then(fn);
|
|
317
446
|
worktreeQueue = result.catch(() => {
|
|
318
447
|
});
|
|
319
448
|
return result;
|
|
320
449
|
}
|
|
450
|
+
__name(enqueue, "enqueue");
|
|
451
|
+
function createWorktree(opts) {
|
|
452
|
+
const { repoRoot, repoName, ticket } = opts;
|
|
453
|
+
const num = String(ticket.num).padStart(2, "0");
|
|
454
|
+
const prefix = ticket.feature ? `${ticket.feature}-` : "";
|
|
455
|
+
return enqueue(() => addWorktreeNow({
|
|
456
|
+
repoRoot,
|
|
457
|
+
baseBranch: opts.baseBranch,
|
|
458
|
+
branchName: buildBranchName(repoName, ticket),
|
|
459
|
+
worktreePath: join2(
|
|
460
|
+
repoRoot,
|
|
461
|
+
"..",
|
|
462
|
+
`${repoName}.${prefix}${num}-${ticket.slug}`
|
|
463
|
+
)
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
321
466
|
__name(createWorktree, "createWorktree");
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
args.push(worktreePath);
|
|
331
|
-
await execFileAsync("git", args);
|
|
467
|
+
function createNamedWorktree(opts) {
|
|
468
|
+
const { repoRoot, repoName, name } = opts;
|
|
469
|
+
return enqueue(() => addWorktreeNow({
|
|
470
|
+
repoRoot,
|
|
471
|
+
baseBranch: opts.baseBranch,
|
|
472
|
+
branchName: `${repoName}--${name}`,
|
|
473
|
+
worktreePath: join2(repoRoot, "..", `${repoName}.${name}`)
|
|
474
|
+
}));
|
|
332
475
|
}
|
|
333
|
-
__name(
|
|
476
|
+
__name(createNamedWorktree, "createNamedWorktree");
|
|
334
477
|
function removeWorktree(worktreePath, opts) {
|
|
335
|
-
|
|
336
|
-
() => removeWorktreeNow(worktreePath, opts)
|
|
337
|
-
);
|
|
338
|
-
worktreeQueue = result.catch(() => {
|
|
339
|
-
});
|
|
340
|
-
return result;
|
|
478
|
+
return enqueue(() => removeWorktreeNow(worktreePath, opts));
|
|
341
479
|
}
|
|
342
480
|
__name(removeWorktree, "removeWorktree");
|
|
481
|
+
function deleteBranch(repoRoot, name) {
|
|
482
|
+
return enqueue(async () => {
|
|
483
|
+
if (!await branchExists(repoRoot, name)) return;
|
|
484
|
+
await execFileAsync(
|
|
485
|
+
"git",
|
|
486
|
+
["branch", "-D", name],
|
|
487
|
+
{ cwd: repoRoot }
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
__name(deleteBranch, "deleteBranch");
|
|
343
492
|
function hasNewCommits(repoRoot, base, branch) {
|
|
344
493
|
const stdout = execFileSync(
|
|
345
494
|
"git",
|
|
@@ -349,6 +498,51 @@ function hasNewCommits(repoRoot, base, branch) {
|
|
|
349
498
|
return Number(stdout.trim()) > 0;
|
|
350
499
|
}
|
|
351
500
|
__name(hasNewCommits, "hasNewCommits");
|
|
501
|
+
function headCommit(cwd) {
|
|
502
|
+
return gitText(cwd, ["rev-parse", "HEAD"]);
|
|
503
|
+
}
|
|
504
|
+
__name(headCommit, "headCommit");
|
|
505
|
+
function mergeBase(cwd, a, b) {
|
|
506
|
+
try {
|
|
507
|
+
return gitText(cwd, ["merge-base", a, b]) || null;
|
|
508
|
+
} catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
__name(mergeBase, "mergeBase");
|
|
513
|
+
function isAncestor(cwd, ancestor, of) {
|
|
514
|
+
try {
|
|
515
|
+
execFileSync(
|
|
516
|
+
"git",
|
|
517
|
+
["merge-base", "--is-ancestor", ancestor, of],
|
|
518
|
+
{ cwd, stdio: "ignore" }
|
|
519
|
+
);
|
|
520
|
+
return true;
|
|
521
|
+
} catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
__name(isAncestor, "isAncestor");
|
|
526
|
+
function hasChangesOutside(cwd, from, to, excludeDir) {
|
|
527
|
+
try {
|
|
528
|
+
execFileSync(
|
|
529
|
+
"git",
|
|
530
|
+
[
|
|
531
|
+
"diff",
|
|
532
|
+
"--quiet",
|
|
533
|
+
`${from}..${to}`,
|
|
534
|
+
"--",
|
|
535
|
+
".",
|
|
536
|
+
`:(exclude)${excludeDir}`
|
|
537
|
+
],
|
|
538
|
+
{ cwd, stdio: "ignore" }
|
|
539
|
+
);
|
|
540
|
+
return false;
|
|
541
|
+
} catch {
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
__name(hasChangesOutside, "hasChangesOutside");
|
|
352
546
|
function isDirty(cwd) {
|
|
353
547
|
const stdout = execFileSync(
|
|
354
548
|
"git",
|
|
@@ -555,16 +749,17 @@ function writeProgress(progressPath, data) {
|
|
|
555
749
|
);
|
|
556
750
|
}
|
|
557
751
|
__name(writeProgress, "writeProgress");
|
|
558
|
-
async function commitProgress(repoRoot,
|
|
752
|
+
async function commitProgress(repoRoot, paths, message) {
|
|
753
|
+
const list = typeof paths === "string" ? [paths] : paths;
|
|
559
754
|
await execFileAsync2(
|
|
560
755
|
"git",
|
|
561
|
-
["add", "--force",
|
|
756
|
+
["add", "--force", "--", ...list],
|
|
562
757
|
{ cwd: repoRoot }
|
|
563
758
|
);
|
|
564
759
|
try {
|
|
565
760
|
await execFileAsync2(
|
|
566
761
|
"git",
|
|
567
|
-
["diff", "--cached", "--quiet", "--",
|
|
762
|
+
["diff", "--cached", "--quiet", "--", ...list],
|
|
568
763
|
{ cwd: repoRoot }
|
|
569
764
|
);
|
|
570
765
|
return false;
|
|
@@ -572,7 +767,7 @@ async function commitProgress(repoRoot, progressPath, message) {
|
|
|
572
767
|
}
|
|
573
768
|
await execFileAsync2(
|
|
574
769
|
"git",
|
|
575
|
-
["commit", "-m", message, "--",
|
|
770
|
+
["commit", "-m", message, "--", ...list],
|
|
576
771
|
{ cwd: repoRoot }
|
|
577
772
|
);
|
|
578
773
|
return true;
|
|
@@ -837,6 +1032,14 @@ var DEFAULT_COLUMNS = 80;
|
|
|
837
1032
|
var MIN_LOG_ROWS = 1;
|
|
838
1033
|
var MAX_MESSAGE_BUFFER = 200;
|
|
839
1034
|
var ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
1035
|
+
function rowText(ticket) {
|
|
1036
|
+
const label = ticketLabel(ticket);
|
|
1037
|
+
if (ticket.status !== "blocked" || !ticket.blockedBy?.length) {
|
|
1038
|
+
return label;
|
|
1039
|
+
}
|
|
1040
|
+
return `${label} (blocked by ${ticket.blockedBy.join(", ")})`;
|
|
1041
|
+
}
|
|
1042
|
+
__name(rowText, "rowText");
|
|
840
1043
|
function fitLine(line, width) {
|
|
841
1044
|
const visible = line.replace(ANSI_RE, "");
|
|
842
1045
|
if (visible.length <= width) return line;
|
|
@@ -860,13 +1063,16 @@ function renderDot(status) {
|
|
|
860
1063
|
case "in-progress":
|
|
861
1064
|
return color.yellow(FILLED_DOT);
|
|
862
1065
|
// The agent and its test gate are finished; the branch
|
|
863
|
-
// waits for the rest of the wave before it is merged.
|
|
1066
|
+
// waits for the rest of the wave before it is merged. It
|
|
1067
|
+
// stays cyan through the merge and any conflict respawn.
|
|
864
1068
|
case "awaiting-merge":
|
|
865
1069
|
return color.cyan(FILLED_DOT);
|
|
866
1070
|
case "done":
|
|
867
1071
|
return color.green(FILLED_DOT);
|
|
868
1072
|
case "failed":
|
|
869
1073
|
return color.red(FILLED_DOT);
|
|
1074
|
+
case "blocked":
|
|
1075
|
+
return color.dim(FILLED_DOT);
|
|
870
1076
|
}
|
|
871
1077
|
}
|
|
872
1078
|
__name(renderDot, "renderDot");
|
|
@@ -875,8 +1081,8 @@ var StatusDisplay = class {
|
|
|
875
1081
|
__name(this, "StatusDisplay");
|
|
876
1082
|
}
|
|
877
1083
|
wave = null;
|
|
878
|
-
|
|
879
|
-
|
|
1084
|
+
tickets = [];
|
|
1085
|
+
hasRun = false;
|
|
880
1086
|
messages = [];
|
|
881
1087
|
renderedLines = 0;
|
|
882
1088
|
config;
|
|
@@ -893,20 +1099,28 @@ var StatusDisplay = class {
|
|
|
893
1099
|
const available = termHeight - overhead;
|
|
894
1100
|
return Math.max(1, available);
|
|
895
1101
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
};
|
|
905
|
-
this.remaining = (remaining ?? []).map((t) => ({
|
|
1102
|
+
findTicket(num, feature) {
|
|
1103
|
+
const found = feature ? this.tickets.find(
|
|
1104
|
+
(t) => t.num === num && t.feature === feature
|
|
1105
|
+
) : this.tickets.find((t) => t.num === num);
|
|
1106
|
+
return found ?? null;
|
|
1107
|
+
}
|
|
1108
|
+
startRun(tickets) {
|
|
1109
|
+
this.tickets = tickets.map((t) => ({
|
|
906
1110
|
...t,
|
|
907
1111
|
status: "pending"
|
|
908
1112
|
}));
|
|
909
|
-
this.
|
|
1113
|
+
this.hasRun = true;
|
|
1114
|
+
this.viewportStart = 0;
|
|
1115
|
+
}
|
|
1116
|
+
startWave(waveNum, totalWaves, tickets, _progress) {
|
|
1117
|
+
if (!this.hasRun) {
|
|
1118
|
+
this.startRun(tickets);
|
|
1119
|
+
}
|
|
1120
|
+
this.wave = {
|
|
1121
|
+
waveNum,
|
|
1122
|
+
totalWaves
|
|
1123
|
+
};
|
|
910
1124
|
this.messages = [];
|
|
911
1125
|
this.viewportStart = 0;
|
|
912
1126
|
this.config.stream.write(ansi.cursorHide());
|
|
@@ -926,28 +1140,31 @@ var StatusDisplay = class {
|
|
|
926
1140
|
this.render();
|
|
927
1141
|
}
|
|
928
1142
|
setTicketStatus(num, status, feature) {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
this.viewportStart = ticketIndex;
|
|
941
|
-
} else if (ticketIndex >= this.viewportStart + availableRows) {
|
|
942
|
-
this.viewportStart = ticketIndex - availableRows + 3;
|
|
943
|
-
}
|
|
944
|
-
this.viewportStart = Math.max(
|
|
945
|
-
0,
|
|
946
|
-
Math.min(this.viewportStart, totalTickets - 1)
|
|
947
|
-
);
|
|
1143
|
+
const ticket = this.findTicket(num, feature);
|
|
1144
|
+
if (!ticket) return;
|
|
1145
|
+
ticket.status = status;
|
|
1146
|
+
if (status === "in-progress") {
|
|
1147
|
+
const ticketIndex = this.tickets.indexOf(ticket);
|
|
1148
|
+
const availableRows = this.getAvailableRows();
|
|
1149
|
+
const totalTickets = this.tickets.length;
|
|
1150
|
+
if (ticketIndex < this.viewportStart) {
|
|
1151
|
+
this.viewportStart = ticketIndex;
|
|
1152
|
+
} else if (ticketIndex >= this.viewportStart + availableRows) {
|
|
1153
|
+
this.viewportStart = ticketIndex - availableRows + 3;
|
|
948
1154
|
}
|
|
949
|
-
this.
|
|
1155
|
+
this.viewportStart = Math.max(
|
|
1156
|
+
0,
|
|
1157
|
+
Math.min(this.viewportStart, totalTickets - 1)
|
|
1158
|
+
);
|
|
950
1159
|
}
|
|
1160
|
+
this.render();
|
|
1161
|
+
}
|
|
1162
|
+
setBlocked(num, feature, blockedBy) {
|
|
1163
|
+
const ticket = this.findTicket(num, feature);
|
|
1164
|
+
if (!ticket) return;
|
|
1165
|
+
ticket.status = "blocked";
|
|
1166
|
+
ticket.blockedBy = [...blockedBy];
|
|
1167
|
+
this.render();
|
|
951
1168
|
}
|
|
952
1169
|
log(message) {
|
|
953
1170
|
if (!this.wave) {
|
|
@@ -974,59 +1191,52 @@ var StatusDisplay = class {
|
|
|
974
1191
|
if (this.renderedLines > 0) {
|
|
975
1192
|
output += ansi.eraseLines(this.renderedLines + 1);
|
|
976
1193
|
}
|
|
977
|
-
const
|
|
1194
|
+
const done = this.tickets.filter(
|
|
978
1195
|
(t) => t.status === "done"
|
|
979
1196
|
).length;
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
const pct = total === 0 ? 0 : Math.round(completed / total * 100);
|
|
1197
|
+
const total = this.tickets.length;
|
|
1198
|
+
const pct = total === 0 ? 0 : Math.round(done / total * 100);
|
|
983
1199
|
const width = Math.max(
|
|
984
1200
|
1,
|
|
985
1201
|
(this.config.stream.columns ?? DEFAULT_COLUMNS) - 1
|
|
986
1202
|
);
|
|
987
|
-
|
|
988
|
-
|
|
1203
|
+
const header = `Wave ${this.wave.waveNum}/${this.wave.totalWaves} - ${done}/${total} done - ${pct}%`;
|
|
1204
|
+
output += fitLine(header, width) + "\n";
|
|
989
1205
|
const availableRows = this.getAvailableRows();
|
|
990
|
-
const tickets = [
|
|
991
|
-
...this.wave.tickets,
|
|
992
|
-
...this.remaining
|
|
993
|
-
];
|
|
994
1206
|
let renderedTicketCount = 0;
|
|
995
|
-
if (tickets.length <= availableRows) {
|
|
996
|
-
for (const ticket of tickets) {
|
|
1207
|
+
if (this.tickets.length <= availableRows) {
|
|
1208
|
+
for (const ticket of this.tickets) {
|
|
997
1209
|
const dot = renderDot(ticket.status);
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
width - 2
|
|
1001
|
-
);
|
|
1210
|
+
const text = rowText(ticket);
|
|
1211
|
+
const name = fitLine(text, width - 2);
|
|
1002
1212
|
output += `${dot} ${name}
|
|
1003
1213
|
`;
|
|
1004
1214
|
}
|
|
1005
|
-
renderedTicketCount = tickets.length;
|
|
1215
|
+
renderedTicketCount = this.tickets.length;
|
|
1006
1216
|
} else {
|
|
1007
1217
|
this.viewportStart = Math.max(
|
|
1008
1218
|
0,
|
|
1009
1219
|
Math.min(
|
|
1010
1220
|
this.viewportStart,
|
|
1011
|
-
tickets.length - 1
|
|
1221
|
+
this.tickets.length - 1
|
|
1012
1222
|
)
|
|
1013
1223
|
);
|
|
1014
1224
|
let hasMoreAbove = this.viewportStart > 0;
|
|
1015
1225
|
let tentativeCount = availableRows - (hasMoreAbove ? 1 : 0);
|
|
1016
|
-
let hasMoreBelow = this.viewportStart + tentativeCount < tickets.length;
|
|
1226
|
+
let hasMoreBelow = this.viewportStart + tentativeCount < this.tickets.length;
|
|
1017
1227
|
let ticketsToShow = Math.max(
|
|
1018
1228
|
0,
|
|
1019
1229
|
tentativeCount - (hasMoreBelow ? 1 : 0)
|
|
1020
1230
|
);
|
|
1021
1231
|
const maxStart = Math.max(
|
|
1022
1232
|
0,
|
|
1023
|
-
tickets.length - Math.max(1, ticketsToShow)
|
|
1233
|
+
this.tickets.length - Math.max(1, ticketsToShow)
|
|
1024
1234
|
);
|
|
1025
1235
|
if (this.viewportStart > maxStart) {
|
|
1026
1236
|
this.viewportStart = maxStart;
|
|
1027
1237
|
hasMoreAbove = this.viewportStart > 0;
|
|
1028
1238
|
tentativeCount = availableRows - (hasMoreAbove ? 1 : 0);
|
|
1029
|
-
hasMoreBelow = this.viewportStart + tentativeCount < tickets.length;
|
|
1239
|
+
hasMoreBelow = this.viewportStart + tentativeCount < this.tickets.length;
|
|
1030
1240
|
ticketsToShow = Math.max(
|
|
1031
1241
|
0,
|
|
1032
1242
|
tentativeCount - (hasMoreBelow ? 1 : 0)
|
|
@@ -1037,21 +1247,19 @@ var StatusDisplay = class {
|
|
|
1037
1247
|
output += ` ... ${moreCount} more above
|
|
1038
1248
|
`;
|
|
1039
1249
|
}
|
|
1040
|
-
const visibleTickets = tickets.slice(
|
|
1250
|
+
const visibleTickets = this.tickets.slice(
|
|
1041
1251
|
this.viewportStart,
|
|
1042
1252
|
this.viewportStart + ticketsToShow
|
|
1043
1253
|
);
|
|
1044
1254
|
for (const ticket of visibleTickets) {
|
|
1045
1255
|
const dot = renderDot(ticket.status);
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
width - 2
|
|
1049
|
-
);
|
|
1256
|
+
const text = rowText(ticket);
|
|
1257
|
+
const name = fitLine(text, width - 2);
|
|
1050
1258
|
output += `${dot} ${name}
|
|
1051
1259
|
`;
|
|
1052
1260
|
}
|
|
1053
1261
|
if (hasMoreBelow) {
|
|
1054
|
-
const moreCount = tickets.length - (this.viewportStart + ticketsToShow);
|
|
1262
|
+
const moreCount = this.tickets.length - (this.viewportStart + ticketsToShow);
|
|
1055
1263
|
output += ` ... ${moreCount} more below
|
|
1056
1264
|
`;
|
|
1057
1265
|
}
|
|
@@ -1083,7 +1291,9 @@ var PlainDisplay = class {
|
|
|
1083
1291
|
static {
|
|
1084
1292
|
__name(this, "PlainDisplay");
|
|
1085
1293
|
}
|
|
1086
|
-
|
|
1294
|
+
startRun(_tickets) {
|
|
1295
|
+
}
|
|
1296
|
+
startWave(waveNum, _totalWaves, tickets, progress) {
|
|
1087
1297
|
const pct = progress && progress.total > 0 ? ` (${progress.completed}/${progress.total} done)` : "";
|
|
1088
1298
|
console.log(
|
|
1089
1299
|
`
|
|
@@ -1092,6 +1302,8 @@ Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
|
|
|
1092
1302
|
}
|
|
1093
1303
|
setTicketStatus(_num, _status, _feature) {
|
|
1094
1304
|
}
|
|
1305
|
+
setBlocked(_num, _feature, _blockedBy) {
|
|
1306
|
+
}
|
|
1095
1307
|
log(message) {
|
|
1096
1308
|
console.log(message);
|
|
1097
1309
|
}
|
|
@@ -1124,8 +1336,20 @@ function detectTestCmd(projectDir) {
|
|
|
1124
1336
|
}
|
|
1125
1337
|
}
|
|
1126
1338
|
__name(detectTestCmd, "detectTestCmd");
|
|
1339
|
+
function detectInstallCmd(projectDir) {
|
|
1340
|
+
const has = /* @__PURE__ */ __name((name) => existsSync3(join3(projectDir, name)), "has");
|
|
1341
|
+
if (!has("package.json")) return null;
|
|
1342
|
+
if (has("pnpm-lock.yaml")) {
|
|
1343
|
+
return "pnpm install --prefer-offline --frozen-lockfile";
|
|
1344
|
+
}
|
|
1345
|
+
if (has("package-lock.json")) {
|
|
1346
|
+
return "npm ci --prefer-offline --no-audit --no-fund";
|
|
1347
|
+
}
|
|
1348
|
+
return "npm install --prefer-offline --no-audit --no-fund --no-package-lock";
|
|
1349
|
+
}
|
|
1350
|
+
__name(detectInstallCmd, "detectInstallCmd");
|
|
1127
1351
|
function runTestCmd(opts) {
|
|
1128
|
-
return new Promise((
|
|
1352
|
+
return new Promise((resolve3) => {
|
|
1129
1353
|
let settled = false;
|
|
1130
1354
|
const child = spawn("sh", ["-c", opts.cmd], {
|
|
1131
1355
|
cwd: opts.cwd,
|
|
@@ -1157,7 +1381,7 @@ function runTestCmd(opts) {
|
|
|
1157
1381
|
opts.registry.delete(child.pid);
|
|
1158
1382
|
}
|
|
1159
1383
|
chunks.push(String(err));
|
|
1160
|
-
|
|
1384
|
+
resolve3({
|
|
1161
1385
|
exitCode: 1,
|
|
1162
1386
|
output: chunks.join("")
|
|
1163
1387
|
});
|
|
@@ -1169,7 +1393,7 @@ function runTestCmd(opts) {
|
|
|
1169
1393
|
if (child.pid !== void 0 && opts.registry) {
|
|
1170
1394
|
opts.registry.delete(child.pid);
|
|
1171
1395
|
}
|
|
1172
|
-
|
|
1396
|
+
resolve3({
|
|
1173
1397
|
exitCode: code ?? 1,
|
|
1174
1398
|
output: chunks.join("")
|
|
1175
1399
|
});
|
|
@@ -1178,6 +1402,1061 @@ function runTestCmd(opts) {
|
|
|
1178
1402
|
}
|
|
1179
1403
|
__name(runTestCmd, "runTestCmd");
|
|
1180
1404
|
|
|
1405
|
+
// src/learnings.ts
|
|
1406
|
+
import {
|
|
1407
|
+
appendFileSync,
|
|
1408
|
+
existsSync as existsSync4,
|
|
1409
|
+
mkdirSync as mkdirSync2,
|
|
1410
|
+
readFileSync as readFileSync4,
|
|
1411
|
+
rmSync,
|
|
1412
|
+
writeFileSync as writeFileSync3
|
|
1413
|
+
} from "node:fs";
|
|
1414
|
+
import { basename as basename2, dirname as dirname2, join as join4 } from "node:path";
|
|
1415
|
+
var MAX_OWN_ENTRIES = 4;
|
|
1416
|
+
var MAX_ENTRY_CHARS = 4e3;
|
|
1417
|
+
var LOG_TAIL_LINES = 40;
|
|
1418
|
+
var NOTES_PATH = ".nightralph/notes.md";
|
|
1419
|
+
var SENTINEL_PREFIX = "<!-- nightralph:entry";
|
|
1420
|
+
var TRUNCATED_MARKER = "[truncated]";
|
|
1421
|
+
var LEARNING_OUTCOMES = [
|
|
1422
|
+
"done",
|
|
1423
|
+
"exit",
|
|
1424
|
+
"tests",
|
|
1425
|
+
"install",
|
|
1426
|
+
"no-commits",
|
|
1427
|
+
"error",
|
|
1428
|
+
"merge"
|
|
1429
|
+
];
|
|
1430
|
+
function encodeKey(key) {
|
|
1431
|
+
return key.replace(/%/g, "%25").replace(/\s/g, (c) => encodeURIComponent(c));
|
|
1432
|
+
}
|
|
1433
|
+
__name(encodeKey, "encodeKey");
|
|
1434
|
+
function fence(content) {
|
|
1435
|
+
const longest = Math.max(
|
|
1436
|
+
0,
|
|
1437
|
+
...[...content.matchAll(/`+/g)].map((m) => m[0].length)
|
|
1438
|
+
);
|
|
1439
|
+
const ticks = "`".repeat(Math.max(3, longest + 1));
|
|
1440
|
+
return `${ticks}
|
|
1441
|
+
${content}
|
|
1442
|
+
${ticks}`;
|
|
1443
|
+
}
|
|
1444
|
+
__name(fence, "fence");
|
|
1445
|
+
function sanitize(text) {
|
|
1446
|
+
if (!text) return "";
|
|
1447
|
+
return text.replace(
|
|
1448
|
+
new RegExp(SENTINEL_PREFIX, "g"),
|
|
1449
|
+
"<!-- nightralph-entry"
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
__name(sanitize, "sanitize");
|
|
1453
|
+
function renderAgentSummary(run, effort) {
|
|
1454
|
+
const effortLine = effort ? `Effort: ${effort}.` : "";
|
|
1455
|
+
const exitLine = `Agent exit: ${run.exitCode}.`;
|
|
1456
|
+
const itemsText = run.completedItems.length > 0 ? run.completedItems.map(
|
|
1457
|
+
(item) => `"${sanitize(item)}"`
|
|
1458
|
+
).join(", ") + "." : "none.";
|
|
1459
|
+
const itemsLine = `Checked items: ${itemsText}`;
|
|
1460
|
+
return [
|
|
1461
|
+
effortLine,
|
|
1462
|
+
exitLine,
|
|
1463
|
+
itemsLine
|
|
1464
|
+
].filter(Boolean).join(" ");
|
|
1465
|
+
}
|
|
1466
|
+
__name(renderAgentSummary, "renderAgentSummary");
|
|
1467
|
+
function renderNotesSection(notes) {
|
|
1468
|
+
const notesSanitized = sanitize(notes);
|
|
1469
|
+
return notesSanitized.trim() ? `### Agent notes
|
|
1470
|
+
${notesSanitized}` : "The agent left no notes.";
|
|
1471
|
+
}
|
|
1472
|
+
__name(renderNotesSection, "renderNotesSection");
|
|
1473
|
+
function formatDate(date) {
|
|
1474
|
+
const year = date.getFullYear();
|
|
1475
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
1476
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
1477
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
1478
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
1479
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
1480
|
+
}
|
|
1481
|
+
__name(formatDate, "formatDate");
|
|
1482
|
+
function isLearningOutcome(value) {
|
|
1483
|
+
return LEARNING_OUTCOMES.some((o) => o === value);
|
|
1484
|
+
}
|
|
1485
|
+
__name(isLearningOutcome, "isLearningOutcome");
|
|
1486
|
+
function renderEntry(input) {
|
|
1487
|
+
const encodedKey = encodeKey(input.key);
|
|
1488
|
+
const hasRun = "run" in input;
|
|
1489
|
+
const timedOut = hasRun && input.run.timedOut;
|
|
1490
|
+
const timedOutStr = timedOut ? " timedout=true" : "";
|
|
1491
|
+
const sentinel = `<!-- nightralph:entry ticket=${encodedKey} attempt=${input.attempt} outcome=${input.outcome}${timedOutStr} -->`;
|
|
1492
|
+
const timestamp = formatDate(input.now);
|
|
1493
|
+
const attemptStr = input.outcome === "merge" ? "" : ` attempt ${input.attempt}`;
|
|
1494
|
+
let description;
|
|
1495
|
+
switch (input.outcome) {
|
|
1496
|
+
case "done":
|
|
1497
|
+
description = "done";
|
|
1498
|
+
break;
|
|
1499
|
+
case "exit":
|
|
1500
|
+
description = `failed (exit ${input.run.exitCode})`;
|
|
1501
|
+
break;
|
|
1502
|
+
case "tests":
|
|
1503
|
+
description = "failed (tests)";
|
|
1504
|
+
break;
|
|
1505
|
+
case "install":
|
|
1506
|
+
description = "failed (install)";
|
|
1507
|
+
break;
|
|
1508
|
+
case "no-commits":
|
|
1509
|
+
description = "no commits";
|
|
1510
|
+
break;
|
|
1511
|
+
case "error":
|
|
1512
|
+
description = "error";
|
|
1513
|
+
break;
|
|
1514
|
+
case "merge":
|
|
1515
|
+
description = "merge failed";
|
|
1516
|
+
break;
|
|
1517
|
+
default: {
|
|
1518
|
+
const _exhaustive = input;
|
|
1519
|
+
description = _exhaustive;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
const timedOutPrefix = timedOut ? "timed out, then " : "";
|
|
1523
|
+
const heading = `## ${timestamp} -- ${input.label}${attemptStr} -- ${timedOutPrefix}${description}`;
|
|
1524
|
+
let body;
|
|
1525
|
+
switch (input.outcome) {
|
|
1526
|
+
case "done":
|
|
1527
|
+
case "exit":
|
|
1528
|
+
case "no-commits": {
|
|
1529
|
+
const run = input.run;
|
|
1530
|
+
const summary = renderAgentSummary(run, input.effort);
|
|
1531
|
+
const notesSection = renderNotesSection(run.notes);
|
|
1532
|
+
body = summary + "\n\n" + notesSection;
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
case "tests": {
|
|
1536
|
+
const logTail = sanitize(input.logTail);
|
|
1537
|
+
const fenced = fence(logTail);
|
|
1538
|
+
const summary = renderAgentSummary(
|
|
1539
|
+
input.run,
|
|
1540
|
+
input.effort
|
|
1541
|
+
);
|
|
1542
|
+
const notesSection = renderNotesSection(input.run.notes);
|
|
1543
|
+
const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
|
|
1544
|
+
body = summary + `
|
|
1545
|
+
|
|
1546
|
+
Test gate failed.
|
|
1547
|
+
${lastNLines}
|
|
1548
|
+
${fenced}
|
|
1549
|
+
|
|
1550
|
+
` + notesSection;
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
case "install": {
|
|
1554
|
+
const logTail = sanitize(input.logTail);
|
|
1555
|
+
const fenced = fence(logTail);
|
|
1556
|
+
const lastNLines = `Last ${LOG_TAIL_LINES} lines of \`${sanitize(input.logName)}\`:`;
|
|
1557
|
+
body = `Install failed.
|
|
1558
|
+
${lastNLines}
|
|
1559
|
+
` + fenced;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
case "error": {
|
|
1563
|
+
const errorText = sanitize(input.error);
|
|
1564
|
+
const fenced = fence(errorText);
|
|
1565
|
+
body = fenced;
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
case "merge": {
|
|
1569
|
+
const stderrText = sanitize(input.stderr);
|
|
1570
|
+
const fenced = fence(stderrText);
|
|
1571
|
+
body = `Merge failed.
|
|
1572
|
+
${fenced}`;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
default: {
|
|
1576
|
+
const unknown = input;
|
|
1577
|
+
throw new Error(`Unknown outcome: ${JSON.stringify(unknown)}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return `${sentinel}
|
|
1581
|
+
${heading}
|
|
1582
|
+
|
|
1583
|
+
${body}
|
|
1584
|
+
|
|
1585
|
+
---
|
|
1586
|
+
`;
|
|
1587
|
+
}
|
|
1588
|
+
__name(renderEntry, "renderEntry");
|
|
1589
|
+
function parseEntries(text) {
|
|
1590
|
+
const lines = text.split("\n").map((line) => {
|
|
1591
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
1592
|
+
});
|
|
1593
|
+
const entries = [];
|
|
1594
|
+
let currentSentinel = null;
|
|
1595
|
+
let currentBody = [];
|
|
1596
|
+
const sentinelRegex = /^<!-- nightralph:entry (.*) -->$/;
|
|
1597
|
+
for (const line of lines) {
|
|
1598
|
+
const match = line.match(sentinelRegex);
|
|
1599
|
+
if (match) {
|
|
1600
|
+
if (currentSentinel !== null) {
|
|
1601
|
+
const entry = parseSentinel(currentSentinel, currentBody);
|
|
1602
|
+
if (entry) {
|
|
1603
|
+
entries.push(entry);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
currentSentinel = match[1];
|
|
1607
|
+
currentBody = [];
|
|
1608
|
+
} else if (currentSentinel !== null) {
|
|
1609
|
+
currentBody.push(line);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (currentSentinel !== null) {
|
|
1613
|
+
const entry = parseSentinel(currentSentinel, currentBody);
|
|
1614
|
+
if (entry) {
|
|
1615
|
+
entries.push(entry);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
return entries;
|
|
1619
|
+
}
|
|
1620
|
+
__name(parseEntries, "parseEntries");
|
|
1621
|
+
function parseSentinel(sentinel, bodyLines) {
|
|
1622
|
+
const parts = sentinel.split(/\s+/).filter(Boolean);
|
|
1623
|
+
const attrs = {};
|
|
1624
|
+
for (const part of parts) {
|
|
1625
|
+
const idx = part.indexOf("=");
|
|
1626
|
+
if (idx > 0) {
|
|
1627
|
+
const key2 = part.slice(0, idx);
|
|
1628
|
+
const value = part.slice(idx + 1);
|
|
1629
|
+
if (value) {
|
|
1630
|
+
attrs[key2] = value;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
let key = attrs.ticket;
|
|
1635
|
+
if (!key) return null;
|
|
1636
|
+
try {
|
|
1637
|
+
key = decodeURIComponent(key);
|
|
1638
|
+
} catch (_e) {
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
if (!key.trim()) return null;
|
|
1642
|
+
const attemptStr = attrs.attempt;
|
|
1643
|
+
if (!attemptStr) return null;
|
|
1644
|
+
if (!/^\d+$/.test(attemptStr)) return null;
|
|
1645
|
+
const attempt = Number(attemptStr);
|
|
1646
|
+
const outcome = attrs.outcome;
|
|
1647
|
+
if (!outcome || !isLearningOutcome(outcome)) return null;
|
|
1648
|
+
const timedOut = attrs.timedout === "true";
|
|
1649
|
+
let body = bodyLines.join("\n").trim();
|
|
1650
|
+
const lines = body.split("\n");
|
|
1651
|
+
if (lines.length > 0 && lines[lines.length - 1] === "---") {
|
|
1652
|
+
body = lines.slice(0, -1).join("\n").trim();
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
key,
|
|
1656
|
+
attempt,
|
|
1657
|
+
outcome,
|
|
1658
|
+
timedOut,
|
|
1659
|
+
body
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
__name(parseSentinel, "parseSentinel");
|
|
1663
|
+
function selectHistory(entries, opts) {
|
|
1664
|
+
const own = entries.filter((e) => e.key === opts.key).slice(-MAX_OWN_ENTRIES);
|
|
1665
|
+
const blockers = opts.blockerKeys.flatMap((k) => {
|
|
1666
|
+
const done = entries.filter((e) => {
|
|
1667
|
+
return e.key === k && e.outcome === "done";
|
|
1668
|
+
});
|
|
1669
|
+
const last = done[done.length - 1];
|
|
1670
|
+
return last ? [last] : [];
|
|
1671
|
+
});
|
|
1672
|
+
return { own, blockers };
|
|
1673
|
+
}
|
|
1674
|
+
__name(selectHistory, "selectHistory");
|
|
1675
|
+
function renderHistory(selection) {
|
|
1676
|
+
if (selection.own.length === 0 && selection.blockers.length === 0) {
|
|
1677
|
+
return "";
|
|
1678
|
+
}
|
|
1679
|
+
const framing = "These are records of earlier runs. The ticket or spec may have changed since then; where they disagree, follow the ticket and spec. Do not repeat an approach these notes say failed unless you can see why it failed.";
|
|
1680
|
+
const parts = [framing, ""];
|
|
1681
|
+
if (selection.own.length > 0) {
|
|
1682
|
+
parts.push("---- PREVIOUS ATTEMPTS AT THIS TICKET ----");
|
|
1683
|
+
parts.push(selection.own.map((e) => capEntry(e)).join("\n\n"));
|
|
1684
|
+
parts.push("");
|
|
1685
|
+
}
|
|
1686
|
+
if (selection.blockers.length > 0) {
|
|
1687
|
+
parts.push("---- WHAT THE BLOCKING TICKETS DID ----");
|
|
1688
|
+
parts.push(selection.blockers.map((e) => capEntry(e)).join("\n\n"));
|
|
1689
|
+
}
|
|
1690
|
+
return parts.join("\n");
|
|
1691
|
+
}
|
|
1692
|
+
__name(renderHistory, "renderHistory");
|
|
1693
|
+
function capEntry(entry) {
|
|
1694
|
+
if (entry.body.length <= MAX_ENTRY_CHARS) {
|
|
1695
|
+
return entry.body;
|
|
1696
|
+
}
|
|
1697
|
+
return entry.body.slice(0, MAX_ENTRY_CHARS) + "\n" + TRUNCATED_MARKER;
|
|
1698
|
+
}
|
|
1699
|
+
__name(capEntry, "capEntry");
|
|
1700
|
+
function tailLines(text, n) {
|
|
1701
|
+
let lines = text.split("\n");
|
|
1702
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
1703
|
+
lines = lines.slice(0, -1);
|
|
1704
|
+
}
|
|
1705
|
+
if (lines.length > n) {
|
|
1706
|
+
lines = lines.slice(-n);
|
|
1707
|
+
}
|
|
1708
|
+
return lines.join("\n");
|
|
1709
|
+
}
|
|
1710
|
+
__name(tailLines, "tailLines");
|
|
1711
|
+
function learningsPathFor(progressPath) {
|
|
1712
|
+
return join4(dirname2(progressPath), "learnings.md");
|
|
1713
|
+
}
|
|
1714
|
+
__name(learningsPathFor, "learningsPathFor");
|
|
1715
|
+
function readEntries(path) {
|
|
1716
|
+
if (!existsSync4(path)) return [];
|
|
1717
|
+
return parseEntries(readFileSync4(path, "utf8"));
|
|
1718
|
+
}
|
|
1719
|
+
__name(readEntries, "readEntries");
|
|
1720
|
+
function appendEntry(path, text) {
|
|
1721
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1722
|
+
appendFileSync(path, text);
|
|
1723
|
+
}
|
|
1724
|
+
__name(appendEntry, "appendEntry");
|
|
1725
|
+
function collectNotes(worktreePath, file = basename2(NOTES_PATH)) {
|
|
1726
|
+
const dir = join4(worktreePath, dirname2(NOTES_PATH));
|
|
1727
|
+
const path = join4(dir, file);
|
|
1728
|
+
const text = existsSync4(path) ? readFileSync4(path, "utf8").trim() : "";
|
|
1729
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1730
|
+
return text || null;
|
|
1731
|
+
}
|
|
1732
|
+
__name(collectNotes, "collectNotes");
|
|
1733
|
+
function ensureNotesIgnored(worktreePath) {
|
|
1734
|
+
const dir = join4(worktreePath, dirname2(NOTES_PATH));
|
|
1735
|
+
mkdirSync2(dir, { recursive: true });
|
|
1736
|
+
writeFileSync3(join4(dir, ".gitignore"), "*\n");
|
|
1737
|
+
}
|
|
1738
|
+
__name(ensureNotesIgnored, "ensureNotesIgnored");
|
|
1739
|
+
|
|
1740
|
+
// src/rules.ts
|
|
1741
|
+
function readDocsRule(subject) {
|
|
1742
|
+
return `- Read the files directly relevant to the ${subject} before writing code. Also read any AGENTS.md in or above the directories you will touch, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. If they do not exist, proceed silently.`;
|
|
1743
|
+
}
|
|
1744
|
+
__name(readDocsRule, "readDocsRule");
|
|
1745
|
+
function installRule(installCmd) {
|
|
1746
|
+
if (!installCmd) return [];
|
|
1747
|
+
return [
|
|
1748
|
+
`- Dependencies are already installed with \`${installCmd}\`. Do NOT reinstall them with any other command; to reinstall, run that same command. To add a dependency, use the package manager that command uses, so its lockfile stays in sync.`
|
|
1749
|
+
];
|
|
1750
|
+
}
|
|
1751
|
+
__name(installRule, "installRule");
|
|
1752
|
+
function testGateRule(testCmd, subject) {
|
|
1753
|
+
if (testCmd === "") {
|
|
1754
|
+
return `- Before each commit, run the tests relevant to what you changed. Do not commit with failing tests. The orchestrator runs no test command for this ${subject}.`;
|
|
1755
|
+
}
|
|
1756
|
+
return `- While iterating, run only the tests relevant to what you changed. Run \`${testCmd}\` in full once, as the final gate before your last commit. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ${subject} if it fails.`;
|
|
1757
|
+
}
|
|
1758
|
+
__name(testGateRule, "testGateRule");
|
|
1759
|
+
var LINT_RULE = "- If the project has a lint script, run it and fix what it reports.";
|
|
1760
|
+
function integrityRule(unit) {
|
|
1761
|
+
return `- INTEGRITY: never delete, skip, weaken, or write tautological tests to force a green result. Only report a ${unit} done when its real tests genuinely pass. If an item cannot be completed honestly, leave it unchecked and record what blocked you in your notes file.`;
|
|
1762
|
+
}
|
|
1763
|
+
__name(integrityRule, "integrityRule");
|
|
1764
|
+
var CONFINEMENT_RULES = [
|
|
1765
|
+
"- Do NOT read or modify files outside your current directory (the worktree).",
|
|
1766
|
+
"- Do NOT run git push."
|
|
1767
|
+
];
|
|
1768
|
+
function commitRule(unit) {
|
|
1769
|
+
return `- Commit after each ${unit}, and make sure everything is committed when finished, except your notes file.`;
|
|
1770
|
+
}
|
|
1771
|
+
__name(commitRule, "commitRule");
|
|
1772
|
+
function notesRule(unit, subject) {
|
|
1773
|
+
return `- Keep notes for this attempt in \`${NOTES_PATH}\` (create the directory). Update it after each ${unit} and as soon as you hit a blocker, not only at the end. Record what you implemented, files changed, how you read any vague acceptance criteria, what blocked you, and anything the next attempt at this ${subject} should know. Never commit, git add, or delete this file; the orchestrator collects it.`;
|
|
1774
|
+
}
|
|
1775
|
+
__name(notesRule, "notesRule");
|
|
1776
|
+
function agentsMdRule(subject) {
|
|
1777
|
+
return `- Before your final commit, record reusable learnings in the nearest AGENTS.md (in or above the directories you modified; prefer that over the repo root). Good additions: module conventions, gotchas, files that must change together, how to test that area, environment requirements. Do NOT add ${subject}-specific details or temporary debugging notes. Only write when you have knowledge that would help future work in that directory. Reusable patterns go in AGENTS.md; anything specific to this ${subject} goes in your notes file.`;
|
|
1778
|
+
}
|
|
1779
|
+
__name(agentsMdRule, "agentsMdRule");
|
|
1780
|
+
|
|
1781
|
+
// src/review.ts
|
|
1782
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1783
|
+
import { basename as basename4, dirname as dirname3, join as join5, relative } from "node:path";
|
|
1784
|
+
|
|
1785
|
+
// src/retry.ts
|
|
1786
|
+
import { basename as basename3 } from "node:path";
|
|
1787
|
+
var EFFORT_LADDERS = {
|
|
1788
|
+
pi: [
|
|
1789
|
+
"off",
|
|
1790
|
+
"minimal",
|
|
1791
|
+
"low",
|
|
1792
|
+
"medium",
|
|
1793
|
+
"high",
|
|
1794
|
+
"xhigh",
|
|
1795
|
+
"max"
|
|
1796
|
+
],
|
|
1797
|
+
claude: [
|
|
1798
|
+
"low",
|
|
1799
|
+
"medium",
|
|
1800
|
+
"high",
|
|
1801
|
+
"xhigh",
|
|
1802
|
+
"max"
|
|
1803
|
+
],
|
|
1804
|
+
codex: ["low", "medium", "high"]
|
|
1805
|
+
};
|
|
1806
|
+
var ALL_EFFORT_LEVELS = [
|
|
1807
|
+
"off",
|
|
1808
|
+
"minimal",
|
|
1809
|
+
"low",
|
|
1810
|
+
"medium",
|
|
1811
|
+
"high",
|
|
1812
|
+
"xhigh",
|
|
1813
|
+
"max"
|
|
1814
|
+
];
|
|
1815
|
+
function escalateEffort(provider, level) {
|
|
1816
|
+
if (!level) return "high";
|
|
1817
|
+
const ladder = EFFORT_LADDERS[provider];
|
|
1818
|
+
const i = ladder.indexOf(level);
|
|
1819
|
+
if (i === -1) return level;
|
|
1820
|
+
return ladder[Math.min(i + 1, ladder.length - 1)];
|
|
1821
|
+
}
|
|
1822
|
+
__name(escalateEffort, "escalateEffort");
|
|
1823
|
+
function retryDelayFor(base, attempt) {
|
|
1824
|
+
return base * 2 ** (attempt - 2);
|
|
1825
|
+
}
|
|
1826
|
+
__name(retryDelayFor, "retryDelayFor");
|
|
1827
|
+
function sleep(ms) {
|
|
1828
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1829
|
+
}
|
|
1830
|
+
__name(sleep, "sleep");
|
|
1831
|
+
async function runWithRetries(opts, run) {
|
|
1832
|
+
const maxAttempts = Math.max(1, opts.maxAttempts);
|
|
1833
|
+
const providerName = basename3(opts.agentCmd);
|
|
1834
|
+
const isKnown = providerName in EFFORT_LADDERS;
|
|
1835
|
+
const wait = opts.wait ?? sleep;
|
|
1836
|
+
let effort = opts.effort;
|
|
1837
|
+
for (let attempt = 1; ; attempt++) {
|
|
1838
|
+
if (attempt === 1) {
|
|
1839
|
+
opts.log(` Starting ${opts.label}`);
|
|
1840
|
+
} else {
|
|
1841
|
+
if (isKnown) {
|
|
1842
|
+
effort = escalateEffort(
|
|
1843
|
+
providerName,
|
|
1844
|
+
effort
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
const delay = retryDelayFor(opts.retryDelay, attempt);
|
|
1848
|
+
opts.log(
|
|
1849
|
+
` ${opts.label}: retrying (attempt ${attempt}/${maxAttempts}` + (isKnown && effort ? `, effort ${effort}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
|
|
1850
|
+
);
|
|
1851
|
+
if (delay > 0) await wait(delay * 1e3);
|
|
1852
|
+
}
|
|
1853
|
+
const step = await run(attempt, effort);
|
|
1854
|
+
if (step.stop || attempt >= maxAttempts) return step.result;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
__name(runWithRetries, "runWithRetries");
|
|
1858
|
+
|
|
1859
|
+
// src/review.ts
|
|
1860
|
+
var FINDINGS_FILE = "findings.md";
|
|
1861
|
+
var FINDINGS_PATH = `${dirname3(NOTES_PATH)}/${FINDINGS_FILE}`;
|
|
1862
|
+
var NO_FINDINGS = "No findings.";
|
|
1863
|
+
var REVIEW_EXCLUDE = ".scratch";
|
|
1864
|
+
var REVIEW_MARKER = "---- REVIEW INSTRUCTIONS ----";
|
|
1865
|
+
var FIX_MARKER = "---- REVIEW FINDINGS ----";
|
|
1866
|
+
function reviewExcludeFor(repoRoot, reviewPath) {
|
|
1867
|
+
let dir = dirname3(reviewPath);
|
|
1868
|
+
while (dirname3(dir) !== dir) {
|
|
1869
|
+
if (basename4(dir) === ".scratch") {
|
|
1870
|
+
const rel = relative(repoRoot, dir);
|
|
1871
|
+
if (rel && !rel.startsWith("..")) return rel;
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
dir = dirname3(dir);
|
|
1875
|
+
}
|
|
1876
|
+
return REVIEW_EXCLUDE;
|
|
1877
|
+
}
|
|
1878
|
+
__name(reviewExcludeFor, "reviewExcludeFor");
|
|
1879
|
+
function reviewDiffCommand(from, to, exclude = REVIEW_EXCLUDE) {
|
|
1880
|
+
return `git diff ${from}..${to} -- . ':(exclude)${exclude}'`;
|
|
1881
|
+
}
|
|
1882
|
+
__name(reviewDiffCommand, "reviewDiffCommand");
|
|
1883
|
+
function renderReviewPrompt(opts) {
|
|
1884
|
+
return [
|
|
1885
|
+
"---- SPEC CONTEXT ----",
|
|
1886
|
+
opts.specBody,
|
|
1887
|
+
"",
|
|
1888
|
+
REVIEW_MARKER,
|
|
1889
|
+
"You are reviewing work that autonomous agents merged into this branch, one ticket at a time. Review the combined change as a whole.",
|
|
1890
|
+
"",
|
|
1891
|
+
"The change to review:",
|
|
1892
|
+
"",
|
|
1893
|
+
` ${reviewDiffCommand(opts.from, opts.to, opts.exclude)}`,
|
|
1894
|
+
"",
|
|
1895
|
+
"Follow these rules:",
|
|
1896
|
+
"- Read any AGENTS.md in or above the directories the change touches, plus CONTEXT.md and the relevant docs/adr/ entries if they exist. Treat their contracts and the spec above as the standard the code must meet.",
|
|
1897
|
+
"- Look for correctness bugs, missing or weak tests, and code that breaks a documented contract. Ignore style preferences.",
|
|
1898
|
+
"- You may run the tests or the type checker to confirm a suspicion.",
|
|
1899
|
+
"- Do NOT edit, stage, or commit code. Anything you change here is discarded.",
|
|
1900
|
+
...CONFINEMENT_RULES,
|
|
1901
|
+
"",
|
|
1902
|
+
`Write your findings to \`${FINDINGS_PATH}\` (create the directory), most severe first, in exactly this form:`,
|
|
1903
|
+
"",
|
|
1904
|
+
"- [ ] <one-line summary of the finding>",
|
|
1905
|
+
" <file:line, and a concrete input or state that goes wrong>",
|
|
1906
|
+
"",
|
|
1907
|
+
"If you find nothing worth fixing, write the single line: " + NO_FINDINGS,
|
|
1908
|
+
"Never git add or commit that file; the orchestrator collects it.",
|
|
1909
|
+
""
|
|
1910
|
+
].join("\n");
|
|
1911
|
+
}
|
|
1912
|
+
__name(renderReviewPrompt, "renderReviewPrompt");
|
|
1913
|
+
var FIX_FAILURE_TEXT = {
|
|
1914
|
+
install: "dependency install failed",
|
|
1915
|
+
exit: "the agent exited with an error",
|
|
1916
|
+
"no-commits": "the agent made no commits",
|
|
1917
|
+
tests: "the test command failed after the agent finished",
|
|
1918
|
+
merge: "the fix branch did not merge cleanly"
|
|
1919
|
+
};
|
|
1920
|
+
function findingLines(findings) {
|
|
1921
|
+
return findings.flatMap((f) => [
|
|
1922
|
+
`- [ ] ${f.summary}`,
|
|
1923
|
+
...f.detail ? f.detail.split("\n").map((l) => ` ${l}`) : []
|
|
1924
|
+
]);
|
|
1925
|
+
}
|
|
1926
|
+
__name(findingLines, "findingLines");
|
|
1927
|
+
function previousBlock(previous) {
|
|
1928
|
+
if (!previous) return [];
|
|
1929
|
+
return [
|
|
1930
|
+
"---- PREVIOUS FIX ATTEMPT ----",
|
|
1931
|
+
`It failed: ${FIX_FAILURE_TEXT[previous.reason]}.`,
|
|
1932
|
+
previous.notes ?? "It left no notes.",
|
|
1933
|
+
""
|
|
1934
|
+
];
|
|
1935
|
+
}
|
|
1936
|
+
__name(previousBlock, "previousBlock");
|
|
1937
|
+
function renderFixPrompt(opts) {
|
|
1938
|
+
return [
|
|
1939
|
+
"---- SPEC CONTEXT ----",
|
|
1940
|
+
opts.specBody,
|
|
1941
|
+
"",
|
|
1942
|
+
FIX_MARKER,
|
|
1943
|
+
...findingLines(opts.findings),
|
|
1944
|
+
"",
|
|
1945
|
+
...previousBlock(opts.previous),
|
|
1946
|
+
"---- AGENT INSTRUCTIONS ----",
|
|
1947
|
+
"You are an autonomous coding agent. Fix the review findings above using /tdd.",
|
|
1948
|
+
"",
|
|
1949
|
+
"Follow these rules:",
|
|
1950
|
+
readDocsRule("findings"),
|
|
1951
|
+
"- Fix ONLY the listed findings. Do not fix unrelated problems you notice.",
|
|
1952
|
+
...installRule(opts.installCmd),
|
|
1953
|
+
"- Use /tdd for every finding: write a failing test that shows the problem, then write only enough code to make it pass, then refactor.",
|
|
1954
|
+
testGateRule(opts.testCmd, "fix"),
|
|
1955
|
+
LINT_RULE,
|
|
1956
|
+
integrityRule("finding"),
|
|
1957
|
+
...CONFINEMENT_RULES,
|
|
1958
|
+
commitRule("finding"),
|
|
1959
|
+
notesRule("finding", "fix"),
|
|
1960
|
+
"- After fixing each finding, print a line: [x] <finding summary> (matching the summary text exactly).",
|
|
1961
|
+
"- If a finding is wrong or cannot be fixed honestly, leave it unchecked and explain why in your notes file.",
|
|
1962
|
+
agentsMdRule("fix"),
|
|
1963
|
+
""
|
|
1964
|
+
].join("\n");
|
|
1965
|
+
}
|
|
1966
|
+
__name(renderFixPrompt, "renderFixPrompt");
|
|
1967
|
+
var SENTINEL_PREFIX2 = "<!-- nightralph:review";
|
|
1968
|
+
var SENTINEL_RE = /^<!-- nightralph:review (.*) -->$/;
|
|
1969
|
+
var SHA_RE = /^[0-9a-f]{4,64}$/;
|
|
1970
|
+
var FIX_HEADING = "### Fix";
|
|
1971
|
+
var FINDING_RE = /^ ?[-*+]\s+\[([ xX])\]\s+(.+)$/;
|
|
1972
|
+
var REVIEW_STATUSES = [
|
|
1973
|
+
"clean",
|
|
1974
|
+
"fixed",
|
|
1975
|
+
"unresolved",
|
|
1976
|
+
"review-failed"
|
|
1977
|
+
];
|
|
1978
|
+
function isReviewStatus(value) {
|
|
1979
|
+
return REVIEW_STATUSES.includes(value);
|
|
1980
|
+
}
|
|
1981
|
+
__name(isReviewStatus, "isReviewStatus");
|
|
1982
|
+
function collapse(s) {
|
|
1983
|
+
return s.replace(/\s+/g, " ").trim();
|
|
1984
|
+
}
|
|
1985
|
+
__name(collapse, "collapse");
|
|
1986
|
+
function normalize(s) {
|
|
1987
|
+
return collapse(s).toLowerCase();
|
|
1988
|
+
}
|
|
1989
|
+
__name(normalize, "normalize");
|
|
1990
|
+
function sanitize2(text) {
|
|
1991
|
+
return text.replace(
|
|
1992
|
+
new RegExp(SENTINEL_PREFIX2, "g"),
|
|
1993
|
+
"<!-- nightralph-review"
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
__name(sanitize2, "sanitize");
|
|
1997
|
+
function splitLines(text) {
|
|
1998
|
+
return text.split("\n").map((line) => {
|
|
1999
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
__name(splitLines, "splitLines");
|
|
2003
|
+
function parseFindings(text) {
|
|
2004
|
+
const findings = [];
|
|
2005
|
+
const details = [];
|
|
2006
|
+
for (const line of splitLines(text)) {
|
|
2007
|
+
const m = line.match(FINDING_RE);
|
|
2008
|
+
if (m) {
|
|
2009
|
+
findings.push({
|
|
2010
|
+
summary: collapse(m[2]),
|
|
2011
|
+
detail: "",
|
|
2012
|
+
checked: m[1] !== " "
|
|
2013
|
+
});
|
|
2014
|
+
details.push([]);
|
|
2015
|
+
} else if (findings.length > 0 && line.trim()) {
|
|
2016
|
+
details[details.length - 1].push(line.trim());
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (findings.length > 0) {
|
|
2020
|
+
return findings.map((f, i) => {
|
|
2021
|
+
return { ...f, detail: details[i].join("\n") };
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
const clean = splitLines(text).some((line) => {
|
|
2025
|
+
return normalize(line) === NO_FINDINGS.toLowerCase();
|
|
2026
|
+
});
|
|
2027
|
+
return clean ? [] : null;
|
|
2028
|
+
}
|
|
2029
|
+
__name(parseFindings, "parseFindings");
|
|
2030
|
+
function markFixed(findings, completedItems) {
|
|
2031
|
+
const done = new Set(completedItems.map(normalize));
|
|
2032
|
+
return findings.map((f) => {
|
|
2033
|
+
return { ...f, checked: done.has(normalize(f.summary)) };
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
__name(markFixed, "markFixed");
|
|
2037
|
+
function headline(input) {
|
|
2038
|
+
const n = input.findings.length;
|
|
2039
|
+
const fixed = input.findings.filter((f) => f.checked).length;
|
|
2040
|
+
switch (input.status) {
|
|
2041
|
+
case "clean":
|
|
2042
|
+
return "no findings";
|
|
2043
|
+
case "fixed":
|
|
2044
|
+
return `${fixed} of ${n} fixed`;
|
|
2045
|
+
case "unresolved":
|
|
2046
|
+
return `${n} finding${n === 1 ? "" : "s"}, fix failed`;
|
|
2047
|
+
case "review-failed":
|
|
2048
|
+
return "review failed";
|
|
2049
|
+
default: {
|
|
2050
|
+
const _exhaustive = input.status;
|
|
2051
|
+
return _exhaustive;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
__name(headline, "headline");
|
|
2056
|
+
function renderFindings(input) {
|
|
2057
|
+
if (input.status === "review-failed") {
|
|
2058
|
+
return ["The reviewer left no usable findings file."];
|
|
2059
|
+
}
|
|
2060
|
+
if (input.findings.length === 0) return [NO_FINDINGS];
|
|
2061
|
+
return input.findings.flatMap((f) => {
|
|
2062
|
+
const box = f.checked ? "[x]" : "[ ]";
|
|
2063
|
+
const detail = f.detail ? f.detail.split("\n").map((l) => ` ${sanitize2(l)}`) : [];
|
|
2064
|
+
return [`- ${box} ${sanitize2(collapse(f.summary))}`, ...detail];
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
__name(renderFindings, "renderFindings");
|
|
2068
|
+
function renderFix(fix) {
|
|
2069
|
+
if (!fix) return [];
|
|
2070
|
+
return [
|
|
2071
|
+
"",
|
|
2072
|
+
FIX_HEADING,
|
|
2073
|
+
`- attempts: ${fix.attempts} of ${fix.maxAttempts}`,
|
|
2074
|
+
...fix.effort ? [`- effort: ${fix.effort}`] : [],
|
|
2075
|
+
`- agent exit code: ${fix.exitCode ?? "none"}`,
|
|
2076
|
+
`- result: ${sanitize2(fix.result)}`,
|
|
2077
|
+
"",
|
|
2078
|
+
fix.notes ? sanitize2(fix.notes) : "The fixer left no notes."
|
|
2079
|
+
];
|
|
2080
|
+
}
|
|
2081
|
+
__name(renderFix, "renderFix");
|
|
2082
|
+
function renderReviewEntry(input) {
|
|
2083
|
+
return [
|
|
2084
|
+
`${SENTINEL_PREFIX2} from=${input.from} to=${input.to} status=${input.status} -->`,
|
|
2085
|
+
`## ${formatDate(input.now)} -- review ${input.from.slice(0, 7)}..${input.to.slice(0, 7)} -- ${headline(input)}`,
|
|
2086
|
+
...renderFindings(input),
|
|
2087
|
+
...renderFix(input.fix),
|
|
2088
|
+
"---",
|
|
2089
|
+
""
|
|
2090
|
+
].join("\n");
|
|
2091
|
+
}
|
|
2092
|
+
__name(renderReviewEntry, "renderReviewEntry");
|
|
2093
|
+
function parseEntry(sentinel, bodyLines) {
|
|
2094
|
+
const attrs = {};
|
|
2095
|
+
for (const part of sentinel.split(/\s+/).filter(Boolean)) {
|
|
2096
|
+
const idx = part.indexOf("=");
|
|
2097
|
+
if (idx > 0) attrs[part.slice(0, idx)] = part.slice(idx + 1);
|
|
2098
|
+
}
|
|
2099
|
+
const { from, to, status } = attrs;
|
|
2100
|
+
if (!from || !SHA_RE.test(from)) return null;
|
|
2101
|
+
if (!to || !SHA_RE.test(to)) return null;
|
|
2102
|
+
if (!status || !isReviewStatus(status)) return null;
|
|
2103
|
+
const lines = [...bodyLines];
|
|
2104
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim()) {
|
|
2105
|
+
lines.pop();
|
|
2106
|
+
}
|
|
2107
|
+
if (lines[lines.length - 1] === "---") lines.pop();
|
|
2108
|
+
const fixAt = lines.indexOf(FIX_HEADING);
|
|
2109
|
+
const findingLines2 = fixAt === -1 ? lines : lines.slice(0, fixAt);
|
|
2110
|
+
return {
|
|
2111
|
+
from,
|
|
2112
|
+
to,
|
|
2113
|
+
status,
|
|
2114
|
+
findings: parseFindings(findingLines2.join("\n")) ?? [],
|
|
2115
|
+
body: lines.join("\n").trim()
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
__name(parseEntry, "parseEntry");
|
|
2119
|
+
function parseReviewEntries(text) {
|
|
2120
|
+
const entries = [];
|
|
2121
|
+
let sentinel = null;
|
|
2122
|
+
let body = [];
|
|
2123
|
+
const flush = /* @__PURE__ */ __name(() => {
|
|
2124
|
+
if (sentinel === null) return;
|
|
2125
|
+
const entry = parseEntry(sentinel, body);
|
|
2126
|
+
if (entry) entries.push(entry);
|
|
2127
|
+
}, "flush");
|
|
2128
|
+
for (const line of splitLines(text)) {
|
|
2129
|
+
const m = line.match(SENTINEL_RE);
|
|
2130
|
+
if (m) {
|
|
2131
|
+
flush();
|
|
2132
|
+
sentinel = m[1];
|
|
2133
|
+
body = [];
|
|
2134
|
+
} else if (sentinel !== null) {
|
|
2135
|
+
body.push(line);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
flush();
|
|
2139
|
+
return entries;
|
|
2140
|
+
}
|
|
2141
|
+
__name(parseReviewEntries, "parseReviewEntries");
|
|
2142
|
+
function selectReviewBase(entries, opts) {
|
|
2143
|
+
const last = [...entries].reverse().find((e) => {
|
|
2144
|
+
return e.status !== "review-failed";
|
|
2145
|
+
});
|
|
2146
|
+
if (last && opts.isAncestor(last.to)) return last.to;
|
|
2147
|
+
return opts.forkPoint ?? opts.launchCommit;
|
|
2148
|
+
}
|
|
2149
|
+
__name(selectReviewBase, "selectReviewBase");
|
|
2150
|
+
function renderReviewSummary(outcome, reviewPath) {
|
|
2151
|
+
const see = ` -- see ${reviewPath}`;
|
|
2152
|
+
const plural = outcome.findings === 1 ? "" : "s";
|
|
2153
|
+
switch (outcome.status) {
|
|
2154
|
+
case "skipped":
|
|
2155
|
+
return null;
|
|
2156
|
+
case "clean":
|
|
2157
|
+
return `Review: no findings${see}`;
|
|
2158
|
+
case "fixed":
|
|
2159
|
+
return `Review: ${outcome.findings} finding${plural}, ${outcome.fixed} fixed${see}`;
|
|
2160
|
+
case "unresolved":
|
|
2161
|
+
return `Review: ${outcome.findings} finding${plural}, fix failed${see}`;
|
|
2162
|
+
case "review-failed":
|
|
2163
|
+
return `Review: the reviewer failed${see}`;
|
|
2164
|
+
default: {
|
|
2165
|
+
const _exhaustive = outcome.status;
|
|
2166
|
+
return _exhaustive;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
__name(renderReviewSummary, "renderReviewSummary");
|
|
2171
|
+
function logPath(logsDir, stem, attempt, ext) {
|
|
2172
|
+
const suffix = attempt > 1 ? `.attempt${attempt}` : "";
|
|
2173
|
+
return join5(logsDir, `${stem}${suffix}${ext}`);
|
|
2174
|
+
}
|
|
2175
|
+
__name(logPath, "logPath");
|
|
2176
|
+
async function runStep(opts, deps, cmd, cwd, path) {
|
|
2177
|
+
const result = await runTestCmd({
|
|
2178
|
+
cmd,
|
|
2179
|
+
cwd,
|
|
2180
|
+
timeout: opts.timeout,
|
|
2181
|
+
registry: deps.registry
|
|
2182
|
+
});
|
|
2183
|
+
writeFileSync4(path, result.output);
|
|
2184
|
+
return result.exitCode === 0;
|
|
2185
|
+
}
|
|
2186
|
+
__name(runStep, "runStep");
|
|
2187
|
+
async function discardWorktree(opts, wt) {
|
|
2188
|
+
try {
|
|
2189
|
+
await removeWorktree(wt.worktreePath, { force: true });
|
|
2190
|
+
await deleteBranch(opts.repoRoot, wt.branchName);
|
|
2191
|
+
} catch (err) {
|
|
2192
|
+
opts.log(
|
|
2193
|
+
` Failed to remove ${wt.worktreePath}: ` + (err instanceof Error ? err.message : String(err))
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
__name(discardWorktree, "discardWorktree");
|
|
2198
|
+
async function runReviewer(opts, deps, range) {
|
|
2199
|
+
return runWithRetries({
|
|
2200
|
+
maxAttempts: opts.retries + 1,
|
|
2201
|
+
retryDelay: opts.retryDelay,
|
|
2202
|
+
agentCmd: opts.agentCmd,
|
|
2203
|
+
effort: opts.effort,
|
|
2204
|
+
label: "review",
|
|
2205
|
+
log: opts.log
|
|
2206
|
+
}, async (attempt, effort) => {
|
|
2207
|
+
const wt = await createNamedWorktree({
|
|
2208
|
+
repoRoot: opts.repoRoot,
|
|
2209
|
+
repoName: opts.repoName,
|
|
2210
|
+
baseBranch: opts.baseBranch,
|
|
2211
|
+
name: "review"
|
|
2212
|
+
});
|
|
2213
|
+
try {
|
|
2214
|
+
if (opts.installCmd && !await runStep(
|
|
2215
|
+
opts,
|
|
2216
|
+
deps,
|
|
2217
|
+
opts.installCmd,
|
|
2218
|
+
wt.worktreePath,
|
|
2219
|
+
logPath(opts.logsDir, "review", attempt, ".install.log")
|
|
2220
|
+
)) {
|
|
2221
|
+
opts.log(" review: install failed");
|
|
2222
|
+
return { result: null, stop: false };
|
|
2223
|
+
}
|
|
2224
|
+
ensureNotesIgnored(wt.worktreePath);
|
|
2225
|
+
await deps.spawnAgent({
|
|
2226
|
+
prompt: renderReviewPrompt({
|
|
2227
|
+
specBody: opts.specBody,
|
|
2228
|
+
...range,
|
|
2229
|
+
exclude: reviewExcludeFor(
|
|
2230
|
+
opts.repoRoot,
|
|
2231
|
+
opts.reviewPath
|
|
2232
|
+
)
|
|
2233
|
+
}),
|
|
2234
|
+
agentCmd: opts.agentCmd,
|
|
2235
|
+
model: opts.model,
|
|
2236
|
+
timeout: opts.timeout,
|
|
2237
|
+
logPath: logPath(opts.logsDir, "review", attempt, ".log"),
|
|
2238
|
+
cwd: wt.worktreePath,
|
|
2239
|
+
label: "review",
|
|
2240
|
+
maxTurns: opts.maxTurns,
|
|
2241
|
+
effort,
|
|
2242
|
+
onLine: opts.log
|
|
2243
|
+
});
|
|
2244
|
+
const text = collectNotes(wt.worktreePath, FINDINGS_FILE);
|
|
2245
|
+
const findings = text === null ? null : parseFindings(text);
|
|
2246
|
+
if (findings === null) {
|
|
2247
|
+
opts.log(" review: no usable findings file");
|
|
2248
|
+
}
|
|
2249
|
+
return { result: findings, stop: findings !== null };
|
|
2250
|
+
} finally {
|
|
2251
|
+
await discardWorktree(opts, wt);
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
__name(runReviewer, "runReviewer");
|
|
2256
|
+
async function runFixer(opts, deps, findings) {
|
|
2257
|
+
const unchecked = findings.map((f) => ({ ...f, checked: false }));
|
|
2258
|
+
let previous;
|
|
2259
|
+
return runWithRetries({
|
|
2260
|
+
maxAttempts: opts.retries + 1,
|
|
2261
|
+
retryDelay: opts.retryDelay,
|
|
2262
|
+
agentCmd: opts.agentCmd,
|
|
2263
|
+
effort: opts.effort,
|
|
2264
|
+
label: "review fix",
|
|
2265
|
+
log: opts.log
|
|
2266
|
+
}, async (attempt, effort) => {
|
|
2267
|
+
const fail = /* @__PURE__ */ __name((reason, exitCode, notes) => {
|
|
2268
|
+
previous = { reason, notes };
|
|
2269
|
+
opts.log(` review fix: ${FIX_FAILURE_TEXT[reason]}`);
|
|
2270
|
+
return {
|
|
2271
|
+
result: {
|
|
2272
|
+
merged: false,
|
|
2273
|
+
findings: unchecked,
|
|
2274
|
+
attempts: attempt,
|
|
2275
|
+
effort,
|
|
2276
|
+
exitCode,
|
|
2277
|
+
result: FIX_FAILURE_TEXT[reason],
|
|
2278
|
+
notes
|
|
2279
|
+
},
|
|
2280
|
+
stop: false
|
|
2281
|
+
};
|
|
2282
|
+
}, "fail");
|
|
2283
|
+
const wt = await createNamedWorktree({
|
|
2284
|
+
repoRoot: opts.repoRoot,
|
|
2285
|
+
repoName: opts.repoName,
|
|
2286
|
+
baseBranch: opts.baseBranch,
|
|
2287
|
+
name: "review-fix"
|
|
2288
|
+
});
|
|
2289
|
+
try {
|
|
2290
|
+
if (opts.installCmd && !await runStep(
|
|
2291
|
+
opts,
|
|
2292
|
+
deps,
|
|
2293
|
+
opts.installCmd,
|
|
2294
|
+
wt.worktreePath,
|
|
2295
|
+
logPath(
|
|
2296
|
+
opts.logsDir,
|
|
2297
|
+
"review-fix",
|
|
2298
|
+
attempt,
|
|
2299
|
+
".install.log"
|
|
2300
|
+
)
|
|
2301
|
+
)) {
|
|
2302
|
+
return fail("install", null, null);
|
|
2303
|
+
}
|
|
2304
|
+
ensureNotesIgnored(wt.worktreePath);
|
|
2305
|
+
const agent = await deps.spawnAgent({
|
|
2306
|
+
prompt: renderFixPrompt({
|
|
2307
|
+
specBody: opts.specBody,
|
|
2308
|
+
findings,
|
|
2309
|
+
testCmd: opts.testCmd,
|
|
2310
|
+
installCmd: opts.installCmd,
|
|
2311
|
+
previous
|
|
2312
|
+
}),
|
|
2313
|
+
agentCmd: opts.agentCmd,
|
|
2314
|
+
model: opts.model,
|
|
2315
|
+
timeout: opts.timeout,
|
|
2316
|
+
logPath: logPath(
|
|
2317
|
+
opts.logsDir,
|
|
2318
|
+
"review-fix",
|
|
2319
|
+
attempt,
|
|
2320
|
+
".log"
|
|
2321
|
+
),
|
|
2322
|
+
cwd: wt.worktreePath,
|
|
2323
|
+
label: "review-fix",
|
|
2324
|
+
maxTurns: opts.maxTurns,
|
|
2325
|
+
effort,
|
|
2326
|
+
onLine: opts.log
|
|
2327
|
+
});
|
|
2328
|
+
const notes = collectNotes(wt.worktreePath);
|
|
2329
|
+
if (!agent.timedOut && agent.exitCode !== 0) {
|
|
2330
|
+
return fail("exit", agent.exitCode, notes);
|
|
2331
|
+
}
|
|
2332
|
+
if (isDirty(wt.worktreePath)) {
|
|
2333
|
+
await commitDirty(
|
|
2334
|
+
wt.worktreePath,
|
|
2335
|
+
"nightralph: auto-commit review fix remaining changes"
|
|
2336
|
+
);
|
|
2337
|
+
}
|
|
2338
|
+
if (!hasNewCommits(
|
|
2339
|
+
opts.repoRoot,
|
|
2340
|
+
opts.baseBranch,
|
|
2341
|
+
wt.branchName
|
|
2342
|
+
)) {
|
|
2343
|
+
return fail("no-commits", agent.exitCode, notes);
|
|
2344
|
+
}
|
|
2345
|
+
if (opts.testCmd === "") {
|
|
2346
|
+
opts.log(" review fix: no test gate");
|
|
2347
|
+
} else if (!await runStep(
|
|
2348
|
+
opts,
|
|
2349
|
+
deps,
|
|
2350
|
+
opts.testCmd,
|
|
2351
|
+
wt.worktreePath,
|
|
2352
|
+
logPath(
|
|
2353
|
+
opts.logsDir,
|
|
2354
|
+
"review-fix",
|
|
2355
|
+
attempt,
|
|
2356
|
+
".test.log"
|
|
2357
|
+
)
|
|
2358
|
+
)) {
|
|
2359
|
+
return fail("tests", agent.exitCode, notes);
|
|
2360
|
+
}
|
|
2361
|
+
const merged = await mergeBranch(
|
|
2362
|
+
opts.repoRoot,
|
|
2363
|
+
wt.branchName
|
|
2364
|
+
);
|
|
2365
|
+
if (!merged.success) {
|
|
2366
|
+
return fail(
|
|
2367
|
+
"merge",
|
|
2368
|
+
agent.exitCode,
|
|
2369
|
+
[notes, merged.stderr].filter(Boolean).join("\n\n") || null
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
opts.log(" review fix: merged");
|
|
2373
|
+
return {
|
|
2374
|
+
result: {
|
|
2375
|
+
merged: true,
|
|
2376
|
+
findings: markFixed(findings, agent.completedItems),
|
|
2377
|
+
attempts: attempt,
|
|
2378
|
+
effort,
|
|
2379
|
+
exitCode: agent.exitCode,
|
|
2380
|
+
result: "merged",
|
|
2381
|
+
notes
|
|
2382
|
+
},
|
|
2383
|
+
stop: true
|
|
2384
|
+
};
|
|
2385
|
+
} finally {
|
|
2386
|
+
await discardWorktree(opts, wt);
|
|
2387
|
+
}
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
__name(runFixer, "runFixer");
|
|
2391
|
+
function readReviewEntries(path) {
|
|
2392
|
+
if (!existsSync5(path)) return [];
|
|
2393
|
+
return parseReviewEntries(readFileSync5(path, "utf8"));
|
|
2394
|
+
}
|
|
2395
|
+
__name(readReviewEntries, "readReviewEntries");
|
|
2396
|
+
async function runReviewPass(opts, deps) {
|
|
2397
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
2398
|
+
const to = headCommit(opts.repoRoot);
|
|
2399
|
+
const forkPoint = opts.forkedFrom ? mergeBase(opts.repoRoot, opts.forkedFrom, to) : null;
|
|
2400
|
+
const from = selectReviewBase(readReviewEntries(opts.reviewPath), {
|
|
2401
|
+
isAncestor: /* @__PURE__ */ __name((sha) => isAncestor(opts.repoRoot, sha, to), "isAncestor"),
|
|
2402
|
+
forkPoint,
|
|
2403
|
+
launchCommit: opts.launchCommit
|
|
2404
|
+
});
|
|
2405
|
+
const exclude = reviewExcludeFor(opts.repoRoot, opts.reviewPath);
|
|
2406
|
+
if (!hasChangesOutside(opts.repoRoot, from, to, exclude)) {
|
|
2407
|
+
opts.log("\nReview: nothing new to review");
|
|
2408
|
+
return { status: "skipped", findings: 0, fixed: 0 };
|
|
2409
|
+
}
|
|
2410
|
+
opts.log(`
|
|
2411
|
+
Reviewing ${from.slice(0, 7)}..${to.slice(0, 7)}`);
|
|
2412
|
+
const findings = await runReviewer(opts, deps, { from, to });
|
|
2413
|
+
let entry;
|
|
2414
|
+
if (findings === null) {
|
|
2415
|
+
entry = {
|
|
2416
|
+
from,
|
|
2417
|
+
to,
|
|
2418
|
+
status: "review-failed",
|
|
2419
|
+
now: now(),
|
|
2420
|
+
findings: []
|
|
2421
|
+
};
|
|
2422
|
+
} else if (findings.length === 0) {
|
|
2423
|
+
entry = { from, to, status: "clean", now: now(), findings: [] };
|
|
2424
|
+
} else {
|
|
2425
|
+
opts.log(
|
|
2426
|
+
`Review: ${findings.length} finding${findings.length === 1 ? "" : "s"}`
|
|
2427
|
+
);
|
|
2428
|
+
const fix = await runFixer(opts, deps, findings);
|
|
2429
|
+
entry = {
|
|
2430
|
+
from,
|
|
2431
|
+
// A merged fix counts as reviewed
|
|
2432
|
+
to: fix.merged ? headCommit(opts.repoRoot) : to,
|
|
2433
|
+
status: fix.merged ? "fixed" : "unresolved",
|
|
2434
|
+
now: now(),
|
|
2435
|
+
findings: fix.findings,
|
|
2436
|
+
fix: {
|
|
2437
|
+
attempts: fix.attempts,
|
|
2438
|
+
maxAttempts: opts.retries + 1,
|
|
2439
|
+
effort: fix.effort,
|
|
2440
|
+
exitCode: fix.exitCode,
|
|
2441
|
+
result: fix.result,
|
|
2442
|
+
notes: fix.notes
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
appendEntry(opts.reviewPath, renderReviewEntry(entry));
|
|
2447
|
+
await commitProgress(
|
|
2448
|
+
opts.repoRoot,
|
|
2449
|
+
opts.reviewPath,
|
|
2450
|
+
"nightralph: record review"
|
|
2451
|
+
);
|
|
2452
|
+
return {
|
|
2453
|
+
status: entry.status,
|
|
2454
|
+
findings: entry.findings.length,
|
|
2455
|
+
fixed: entry.findings.filter((f) => f.checked).length
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
__name(runReviewPass, "runReviewPass");
|
|
2459
|
+
|
|
1181
2460
|
// src/orchestrator.ts
|
|
1182
2461
|
var execFileAsync4 = promisify4(execFile4);
|
|
1183
2462
|
function formatErrorMessage(error) {
|
|
@@ -1270,8 +2549,8 @@ function scanTickets(dir, feature = "") {
|
|
|
1270
2549
|
));
|
|
1271
2550
|
return files.map((filename) => {
|
|
1272
2551
|
const parsed = parseTicketFilename(filename);
|
|
1273
|
-
const filepath =
|
|
1274
|
-
const body =
|
|
2552
|
+
const filepath = join6(dir, filename);
|
|
2553
|
+
const body = readFileSync6(filepath, "utf8");
|
|
1275
2554
|
return {
|
|
1276
2555
|
num: parsed.num,
|
|
1277
2556
|
slug: parsed.slug,
|
|
@@ -1301,6 +2580,37 @@ function findReadyTickets(tickets) {
|
|
|
1301
2580
|
});
|
|
1302
2581
|
}
|
|
1303
2582
|
__name(findReadyTickets, "findReadyTickets");
|
|
2583
|
+
function findBlockedTickets(tickets, failedKeys) {
|
|
2584
|
+
const byKey = new Map(
|
|
2585
|
+
tickets.map((t) => [ticketKey(t.feature, t.num), t])
|
|
2586
|
+
);
|
|
2587
|
+
function collectDead(ticket, visited, found) {
|
|
2588
|
+
for (const b of ticket.blockers) {
|
|
2589
|
+
const key = ticketKey(ticket.feature, b);
|
|
2590
|
+
if (visited.has(key)) continue;
|
|
2591
|
+
visited.add(key);
|
|
2592
|
+
const blocker = byKey.get(key);
|
|
2593
|
+
if (!blocker || blocker.status === "done") continue;
|
|
2594
|
+
if (failedKeys.has(key) || blocker.status !== "ready-for-agent") {
|
|
2595
|
+
found.add(key);
|
|
2596
|
+
continue;
|
|
2597
|
+
}
|
|
2598
|
+
collectDead(blocker, visited, found);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
__name(collectDead, "collectDead");
|
|
2602
|
+
const blocked = /* @__PURE__ */ new Map();
|
|
2603
|
+
for (const t of tickets) {
|
|
2604
|
+
const key = ticketKey(t.feature, t.num);
|
|
2605
|
+
if (t.status !== "ready-for-agent") continue;
|
|
2606
|
+
if (failedKeys.has(key)) continue;
|
|
2607
|
+
const found = /* @__PURE__ */ new Set();
|
|
2608
|
+
collectDead(t, /* @__PURE__ */ new Set([key]), found);
|
|
2609
|
+
if (found.size > 0) blocked.set(key, [...found]);
|
|
2610
|
+
}
|
|
2611
|
+
return blocked;
|
|
2612
|
+
}
|
|
2613
|
+
__name(findBlockedTickets, "findBlockedTickets");
|
|
1304
2614
|
var COMPLETED_RE = /^\s*[-*+]?\s*\[x\]\s+(.+)$/i;
|
|
1305
2615
|
var TOOL_USE_RE = /^\[[\w.]+\]/;
|
|
1306
2616
|
var CHECKBOX_RE = /^(\s*[-*+] )\[( |x|X)\]( .*)$/;
|
|
@@ -1351,7 +2661,7 @@ function checkItems(ticket, completedItems) {
|
|
|
1351
2661
|
if (completedItems.length === 0) return;
|
|
1352
2662
|
let body;
|
|
1353
2663
|
try {
|
|
1354
|
-
body =
|
|
2664
|
+
body = readFileSync6(ticket.filepath, "utf8");
|
|
1355
2665
|
} catch {
|
|
1356
2666
|
body = ticket.body;
|
|
1357
2667
|
}
|
|
@@ -1375,7 +2685,7 @@ function checkItems(ticket, completedItems) {
|
|
|
1375
2685
|
}
|
|
1376
2686
|
if (!changed) return;
|
|
1377
2687
|
body = lines.join("\n");
|
|
1378
|
-
|
|
2688
|
+
writeFileSync5(ticket.filepath, body);
|
|
1379
2689
|
ticket.body = body;
|
|
1380
2690
|
}
|
|
1381
2691
|
__name(checkItems, "checkItems");
|
|
@@ -1399,7 +2709,7 @@ __name(checkAllItems, "checkAllItems");
|
|
|
1399
2709
|
function markDone(ticket) {
|
|
1400
2710
|
let body;
|
|
1401
2711
|
try {
|
|
1402
|
-
body =
|
|
2712
|
+
body = readFileSync6(ticket.filepath, "utf8");
|
|
1403
2713
|
} catch {
|
|
1404
2714
|
body = ticket.body;
|
|
1405
2715
|
}
|
|
@@ -1408,7 +2718,7 @@ function markDone(ticket) {
|
|
|
1408
2718
|
/\*\*Status:\*\*\s*.+/i,
|
|
1409
2719
|
"**Status:** done"
|
|
1410
2720
|
);
|
|
1411
|
-
|
|
2721
|
+
writeFileSync5(ticket.filepath, updated);
|
|
1412
2722
|
ticket.status = "done";
|
|
1413
2723
|
ticket.body = updated;
|
|
1414
2724
|
}
|
|
@@ -1441,11 +2751,23 @@ function stripSpecSection(specBody, heading) {
|
|
|
1441
2751
|
return out.join("\n");
|
|
1442
2752
|
}
|
|
1443
2753
|
__name(stripSpecSection, "stripSpecSection");
|
|
2754
|
+
function reviewSpecBody(specBody, specBodies) {
|
|
2755
|
+
if (!specBodies || specBodies.size <= 1) return specBody;
|
|
2756
|
+
return [...specBodies].map(([name, body]) => `# Feature: ${name}
|
|
2757
|
+
|
|
2758
|
+
${body}`).join("\n\n");
|
|
2759
|
+
}
|
|
2760
|
+
__name(reviewSpecBody, "reviewSpecBody");
|
|
1444
2761
|
function lookupSpec(specBodies, fallback, feature) {
|
|
1445
2762
|
if (!feature || !specBodies) return fallback;
|
|
1446
2763
|
return specBodies.get(feature) ?? fallback;
|
|
1447
2764
|
}
|
|
1448
2765
|
__name(lookupSpec, "lookupSpec");
|
|
2766
|
+
function historyBlock(history) {
|
|
2767
|
+
if (!history) return [];
|
|
2768
|
+
return [history, ""];
|
|
2769
|
+
}
|
|
2770
|
+
__name(historyBlock, "historyBlock");
|
|
1449
2771
|
function renderPrompt(specBody, ticket, opts) {
|
|
1450
2772
|
return [
|
|
1451
2773
|
"---- SPEC CONTEXT ----",
|
|
@@ -1455,27 +2777,29 @@ function renderPrompt(specBody, ticket, opts) {
|
|
|
1455
2777
|
ticket.filename,
|
|
1456
2778
|
ticket.body,
|
|
1457
2779
|
"",
|
|
2780
|
+
...historyBlock(opts.history),
|
|
1458
2781
|
"---- AGENT INSTRUCTIONS ----",
|
|
1459
2782
|
"You are an autonomous coding agent. Implement the ticket using /tdd.",
|
|
1460
2783
|
"",
|
|
1461
2784
|
"Follow these rules:",
|
|
1462
|
-
"
|
|
2785
|
+
readDocsRule("ticket"),
|
|
1463
2786
|
"- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
|
|
1464
2787
|
"- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
"
|
|
1470
|
-
|
|
1471
|
-
"
|
|
2788
|
+
...installRule(opts.installCmd),
|
|
2789
|
+
"- Use /tdd for every checklist item. For each item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass, then refactor. For a checklist item that is pure scaffolding, config, or docs (where a test adds no value), skip test-first and record that decision in your notes file.",
|
|
2790
|
+
testGateRule(opts.testCmd, "ticket"),
|
|
2791
|
+
LINT_RULE,
|
|
2792
|
+
integrityRule("checklist item"),
|
|
2793
|
+
...CONFINEMENT_RULES,
|
|
2794
|
+
commitRule("checklist item"),
|
|
2795
|
+
notesRule("checklist item", "ticket"),
|
|
1472
2796
|
"- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
|
|
1473
|
-
"
|
|
2797
|
+
agentsMdRule("ticket"),
|
|
1474
2798
|
""
|
|
1475
2799
|
].join("\n");
|
|
1476
2800
|
}
|
|
1477
2801
|
__name(renderPrompt, "renderPrompt");
|
|
1478
|
-
function renderMergePrompt(specBody, ticket) {
|
|
2802
|
+
function renderMergePrompt(specBody, ticket, opts = {}) {
|
|
1479
2803
|
return [
|
|
1480
2804
|
"---- SPEC CONTEXT ----",
|
|
1481
2805
|
specBody,
|
|
@@ -1496,6 +2820,7 @@ function renderMergePrompt(specBody, ticket) {
|
|
|
1496
2820
|
"4. Commit the resolved files.",
|
|
1497
2821
|
"",
|
|
1498
2822
|
"Do NOT re-implement the ticket from scratch.",
|
|
2823
|
+
...installRule(opts.installCmd),
|
|
1499
2824
|
"Do NOT read or modify files outside your current directory (the worktree).",
|
|
1500
2825
|
"Do NOT run git push."
|
|
1501
2826
|
].join("\n");
|
|
@@ -1603,48 +2928,12 @@ var PROVIDER_ARGS = {
|
|
|
1603
2928
|
"--mode",
|
|
1604
2929
|
"json"
|
|
1605
2930
|
]
|
|
1606
|
-
};
|
|
1607
|
-
function getProviderArgs(cmd) {
|
|
1608
|
-
const name =
|
|
1609
|
-
return PROVIDER_ARGS[name] ?? [];
|
|
1610
|
-
}
|
|
1611
|
-
__name(getProviderArgs, "getProviderArgs");
|
|
1612
|
-
var EFFORT_LADDERS = {
|
|
1613
|
-
pi: [
|
|
1614
|
-
"off",
|
|
1615
|
-
"minimal",
|
|
1616
|
-
"low",
|
|
1617
|
-
"medium",
|
|
1618
|
-
"high",
|
|
1619
|
-
"xhigh",
|
|
1620
|
-
"max"
|
|
1621
|
-
],
|
|
1622
|
-
claude: [
|
|
1623
|
-
"low",
|
|
1624
|
-
"medium",
|
|
1625
|
-
"high",
|
|
1626
|
-
"xhigh",
|
|
1627
|
-
"max"
|
|
1628
|
-
],
|
|
1629
|
-
codex: ["low", "medium", "high"]
|
|
1630
|
-
};
|
|
1631
|
-
var ALL_EFFORT_LEVELS = [
|
|
1632
|
-
"off",
|
|
1633
|
-
"minimal",
|
|
1634
|
-
"low",
|
|
1635
|
-
"medium",
|
|
1636
|
-
"high",
|
|
1637
|
-
"xhigh",
|
|
1638
|
-
"max"
|
|
1639
|
-
];
|
|
1640
|
-
function escalateEffort(provider, level) {
|
|
1641
|
-
if (!level) return "high";
|
|
1642
|
-
const ladder = EFFORT_LADDERS[provider];
|
|
1643
|
-
const i = ladder.indexOf(level);
|
|
1644
|
-
if (i === -1) return level;
|
|
1645
|
-
return ladder[Math.min(i + 1, ladder.length - 1)];
|
|
2931
|
+
};
|
|
2932
|
+
function getProviderArgs(cmd) {
|
|
2933
|
+
const name = basename5(cmd);
|
|
2934
|
+
return PROVIDER_ARGS[name] ?? [];
|
|
1646
2935
|
}
|
|
1647
|
-
__name(
|
|
2936
|
+
__name(getProviderArgs, "getProviderArgs");
|
|
1648
2937
|
function formatStreamLine(line) {
|
|
1649
2938
|
let obj;
|
|
1650
2939
|
try {
|
|
@@ -1745,8 +3034,8 @@ function createPiStreamFormatter() {
|
|
|
1745
3034
|
__name(createPiStreamFormatter, "createPiStreamFormatter");
|
|
1746
3035
|
function writePromptFile(prompt) {
|
|
1747
3036
|
const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
|
|
1748
|
-
const filePath =
|
|
1749
|
-
|
|
3037
|
+
const filePath = join6(tmpdir(), name);
|
|
3038
|
+
writeFileSync5(filePath, prompt);
|
|
1750
3039
|
return filePath;
|
|
1751
3040
|
}
|
|
1752
3041
|
__name(writePromptFile, "writePromptFile");
|
|
@@ -1787,7 +3076,7 @@ function spawnAgent(opts) {
|
|
|
1787
3076
|
if (opts.model) {
|
|
1788
3077
|
args.push("--model", opts.model);
|
|
1789
3078
|
}
|
|
1790
|
-
const providerName =
|
|
3079
|
+
const providerName = basename5(opts.agentCmd);
|
|
1791
3080
|
const isClaude = providerName === "claude";
|
|
1792
3081
|
const isPi = providerName === "pi";
|
|
1793
3082
|
if (opts.maxTurns) {
|
|
@@ -1837,7 +3126,7 @@ function spawnAgent(opts) {
|
|
|
1837
3126
|
detached: true
|
|
1838
3127
|
});
|
|
1839
3128
|
if (proc.pid !== void 0) runningAgents.add(proc.pid);
|
|
1840
|
-
proc.stdout.pipe(logStream);
|
|
3129
|
+
proc.stdout.pipe(logStream, { end: false });
|
|
1841
3130
|
const piFormatter = isPi ? createPiStreamFormatter() : null;
|
|
1842
3131
|
const completedItems = [];
|
|
1843
3132
|
const seenItems = /* @__PURE__ */ new Set();
|
|
@@ -1886,7 +3175,7 @@ function spawnAgent(opts) {
|
|
|
1886
3175
|
}
|
|
1887
3176
|
}
|
|
1888
3177
|
});
|
|
1889
|
-
proc.stderr.pipe(logStream);
|
|
3178
|
+
proc.stderr.pipe(logStream, { end: false });
|
|
1890
3179
|
const rlErr = createInterface({ input: proc.stderr });
|
|
1891
3180
|
rlErr.on("line", (line) => {
|
|
1892
3181
|
if (isClaude && !TOOL_USE_RE.test(line)) return;
|
|
@@ -1921,7 +3210,7 @@ function spawnAgent(opts) {
|
|
|
1921
3210
|
proc.stdin.write(opts.prompt);
|
|
1922
3211
|
}
|
|
1923
3212
|
proc.stdin.end();
|
|
1924
|
-
return new Promise((
|
|
3213
|
+
return new Promise((resolve3) => {
|
|
1925
3214
|
let settled = false;
|
|
1926
3215
|
let graceTimer;
|
|
1927
3216
|
function finish(exitCode) {
|
|
@@ -1947,7 +3236,7 @@ function spawnAgent(opts) {
|
|
|
1947
3236
|
rlErr.close();
|
|
1948
3237
|
logStream.end();
|
|
1949
3238
|
if (promptFile) cleanupPromptFile(promptFile);
|
|
1950
|
-
|
|
3239
|
+
resolve3({ exitCode, completedItems, timedOut });
|
|
1951
3240
|
}
|
|
1952
3241
|
__name(finish, "finish");
|
|
1953
3242
|
proc.on("exit", (code) => {
|
|
@@ -1971,16 +3260,21 @@ function spawnAgent(opts) {
|
|
|
1971
3260
|
}
|
|
1972
3261
|
rl.close();
|
|
1973
3262
|
rlErr.close();
|
|
1974
|
-
|
|
3263
|
+
const msg = "agent process error: " + formatErrorMessage(err);
|
|
3264
|
+
logStream.write(`nightralph: ${msg}
|
|
3265
|
+
`);
|
|
1975
3266
|
if (promptFile) cleanupPromptFile(promptFile);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
3267
|
+
if (opts.onLine) {
|
|
3268
|
+
opts.onLine(`${prefix}${msg}`);
|
|
3269
|
+
} else {
|
|
3270
|
+
console.error(`${prefix}${msg}`);
|
|
3271
|
+
}
|
|
3272
|
+
logStream.end(() => {
|
|
3273
|
+
resolve3({
|
|
3274
|
+
exitCode: 1,
|
|
3275
|
+
completedItems,
|
|
3276
|
+
timedOut
|
|
3277
|
+
});
|
|
1984
3278
|
});
|
|
1985
3279
|
});
|
|
1986
3280
|
});
|
|
@@ -2000,6 +3294,8 @@ function dryRun(tickets, specBody, testCmd) {
|
|
|
2000
3294
|
"\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
|
|
2001
3295
|
);
|
|
2002
3296
|
}
|
|
3297
|
+
console.log(`
|
|
3298
|
+
Test command: ${testCmd || "(none)"}`);
|
|
2003
3299
|
const firstReady = findReadyTickets(tickets);
|
|
2004
3300
|
if (firstReady.length > 0) {
|
|
2005
3301
|
console.log(
|
|
@@ -2055,14 +3351,20 @@ Prompt for first ready ticket (${firstReady[0].filename}):
|
|
|
2055
3351
|
renderPrompt(
|
|
2056
3352
|
opts.specBody,
|
|
2057
3353
|
firstReady[0],
|
|
2058
|
-
{
|
|
3354
|
+
{
|
|
3355
|
+
testCmd: opts.testCmd,
|
|
3356
|
+
installCmd: opts.installCmd
|
|
3357
|
+
}
|
|
2059
3358
|
)
|
|
2060
3359
|
);
|
|
2061
3360
|
}
|
|
2062
3361
|
const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
|
|
2063
3362
|
console.log(
|
|
2064
3363
|
`
|
|
2065
|
-
Test command: ${opts.testCmd}`
|
|
3364
|
+
Test command: ${opts.testCmd || "(none)"}`
|
|
3365
|
+
);
|
|
3366
|
+
console.log(
|
|
3367
|
+
`Install: ${opts.installCmd || "(none)"}`
|
|
2066
3368
|
);
|
|
2067
3369
|
console.log(
|
|
2068
3370
|
`Effort: ${opts.effort ?? "(provider default)"}`
|
|
@@ -2083,7 +3385,34 @@ Merge strategy: ${strategyDesc}`
|
|
|
2083
3385
|
console.log(`Progress file: ${opts.progressPath}`);
|
|
2084
3386
|
}
|
|
2085
3387
|
__name(dryRunIsolated, "dryRunIsolated");
|
|
3388
|
+
async function runInstall(opts) {
|
|
3389
|
+
const result = await runTestCmd({
|
|
3390
|
+
cmd: opts.installCmd,
|
|
3391
|
+
cwd: opts.worktreePath,
|
|
3392
|
+
timeout: opts.timeout,
|
|
3393
|
+
registry: opts.registry
|
|
3394
|
+
});
|
|
3395
|
+
const installLogPath = attemptLogPath(
|
|
3396
|
+
opts.logsDir,
|
|
3397
|
+
opts.ticket,
|
|
3398
|
+
opts.attempt ?? 1,
|
|
3399
|
+
".install.log"
|
|
3400
|
+
);
|
|
3401
|
+
writeFileSync5(installLogPath, result.output);
|
|
3402
|
+
if (result.exitCode !== 0) {
|
|
3403
|
+
opts.log(
|
|
3404
|
+
` ${opts.ticket.filename}: install failed (exit ${result.exitCode}); see ${installLogPath}`
|
|
3405
|
+
);
|
|
3406
|
+
return false;
|
|
3407
|
+
}
|
|
3408
|
+
return true;
|
|
3409
|
+
}
|
|
3410
|
+
__name(runInstall, "runInstall");
|
|
2086
3411
|
async function runTestGate(opts) {
|
|
3412
|
+
if (opts.testCmd === "") {
|
|
3413
|
+
opts.log(` ${opts.ticket.filename}: no test gate`);
|
|
3414
|
+
return true;
|
|
3415
|
+
}
|
|
2087
3416
|
const result = await runTestCmd({
|
|
2088
3417
|
cmd: opts.testCmd,
|
|
2089
3418
|
cwd: opts.worktreePath,
|
|
@@ -2096,7 +3425,7 @@ async function runTestGate(opts) {
|
|
|
2096
3425
|
opts.attempt ?? 1,
|
|
2097
3426
|
".test.log"
|
|
2098
3427
|
);
|
|
2099
|
-
|
|
3428
|
+
writeFileSync5(testLogPath, result.output);
|
|
2100
3429
|
if (result.exitCode !== 0) {
|
|
2101
3430
|
opts.log(
|
|
2102
3431
|
` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
|
|
@@ -2125,11 +3454,24 @@ async function respawnAndRetryMerge(opts) {
|
|
|
2125
3454
|
} else {
|
|
2126
3455
|
console.log(msg1);
|
|
2127
3456
|
}
|
|
3457
|
+
if (opts.installCmd) {
|
|
3458
|
+
const installed = await runInstall({
|
|
3459
|
+
installCmd: opts.installCmd,
|
|
3460
|
+
ticket: opts.ticket,
|
|
3461
|
+
worktreePath: opts.worktreePath,
|
|
3462
|
+
timeout: opts.timeout,
|
|
3463
|
+
logsDir: opts.logsDir,
|
|
3464
|
+
log,
|
|
3465
|
+
registry: runningAgents
|
|
3466
|
+
});
|
|
3467
|
+
if (!installed) return false;
|
|
3468
|
+
}
|
|
2128
3469
|
const prompt = renderMergePrompt(
|
|
2129
3470
|
opts.specBody,
|
|
2130
|
-
opts.ticket
|
|
3471
|
+
opts.ticket,
|
|
3472
|
+
{ installCmd: opts.installCmd }
|
|
2131
3473
|
);
|
|
2132
|
-
const
|
|
3474
|
+
const logPath2 = join6(
|
|
2133
3475
|
opts.logsDir,
|
|
2134
3476
|
opts.ticket.filename.replace(/\.md$/, ".respawn.log")
|
|
2135
3477
|
);
|
|
@@ -2142,7 +3484,7 @@ async function respawnAndRetryMerge(opts) {
|
|
|
2142
3484
|
agentCmd: opts.agentCmd,
|
|
2143
3485
|
model: opts.model,
|
|
2144
3486
|
timeout: opts.timeout,
|
|
2145
|
-
logPath,
|
|
3487
|
+
logPath: logPath2,
|
|
2146
3488
|
cwd: opts.worktreePath,
|
|
2147
3489
|
label,
|
|
2148
3490
|
maxTurns: opts.maxTurns,
|
|
@@ -2226,20 +3568,31 @@ async function runPool(items, limit, fn) {
|
|
|
2226
3568
|
return results;
|
|
2227
3569
|
}
|
|
2228
3570
|
__name(runPool, "runPool");
|
|
2229
|
-
function retryDelayFor(base, attempt) {
|
|
2230
|
-
return base * 2 ** (attempt - 2);
|
|
2231
|
-
}
|
|
2232
|
-
__name(retryDelayFor, "retryDelayFor");
|
|
2233
|
-
function sleep(ms) {
|
|
2234
|
-
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2235
|
-
}
|
|
2236
|
-
__name(sleep, "sleep");
|
|
2237
3571
|
function attemptLogPath(logsDir, ticket, attempt, ext) {
|
|
2238
3572
|
const stem = ticket.filename.replace(/\.md$/, "");
|
|
2239
3573
|
const suffix = attempt > 1 ? `.attempt${attempt}` : "";
|
|
2240
|
-
return
|
|
3574
|
+
return join6(logsDir, `${stem}${suffix}${ext}`);
|
|
2241
3575
|
}
|
|
2242
3576
|
__name(attemptLogPath, "attemptLogPath");
|
|
3577
|
+
function buildHistory(learningsPath, ticket, d) {
|
|
3578
|
+
try {
|
|
3579
|
+
return renderHistory(selectHistory(
|
|
3580
|
+
readEntries(learningsPath),
|
|
3581
|
+
{
|
|
3582
|
+
key: ticketKey(ticket.feature, ticket.num),
|
|
3583
|
+
blockerKeys: ticket.blockers.map((b) => {
|
|
3584
|
+
return ticketKey(ticket.feature, b);
|
|
3585
|
+
})
|
|
3586
|
+
}
|
|
3587
|
+
));
|
|
3588
|
+
} catch (err) {
|
|
3589
|
+
d.log(
|
|
3590
|
+
` ${ticket.filename}: could not read learnings: ${formatErrorMessage(err)}`
|
|
3591
|
+
);
|
|
3592
|
+
return "";
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
__name(buildHistory, "buildHistory");
|
|
2243
3596
|
async function runTicketAttempt(opts) {
|
|
2244
3597
|
const { ticket, d } = opts;
|
|
2245
3598
|
let worktree;
|
|
@@ -2258,6 +3611,38 @@ async function runTicketAttempt(opts) {
|
|
|
2258
3611
|
"in-progress",
|
|
2259
3612
|
ticket.feature
|
|
2260
3613
|
);
|
|
3614
|
+
if (opts.installCmd) {
|
|
3615
|
+
const installed = await runInstall({
|
|
3616
|
+
installCmd: opts.installCmd,
|
|
3617
|
+
ticket,
|
|
3618
|
+
worktreePath: worktree.worktreePath,
|
|
3619
|
+
timeout: opts.timeout,
|
|
3620
|
+
logsDir: opts.logsDir,
|
|
3621
|
+
attempt: opts.attempt,
|
|
3622
|
+
log: /* @__PURE__ */ __name((msg) => d.log(msg), "log"),
|
|
3623
|
+
registry: runningAgents
|
|
3624
|
+
});
|
|
3625
|
+
if (!installed) {
|
|
3626
|
+
await removeWorktreeQuietly(
|
|
3627
|
+
worktree.worktreePath,
|
|
3628
|
+
d
|
|
3629
|
+
);
|
|
3630
|
+
return {
|
|
3631
|
+
kind: "failed",
|
|
3632
|
+
reason: "install",
|
|
3633
|
+
branchName: worktree.branchName,
|
|
3634
|
+
exitCode: 1,
|
|
3635
|
+
completedItems: []
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
try {
|
|
3640
|
+
ensureNotesIgnored(worktree.worktreePath);
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
d.log(
|
|
3643
|
+
` ${ticket.filename}: could not set up the notes directory: ${formatErrorMessage(err)}`
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
2261
3646
|
const ticketSpec = lookupSpec(
|
|
2262
3647
|
opts.specBodies,
|
|
2263
3648
|
opts.specBody,
|
|
@@ -2267,7 +3652,17 @@ async function runTicketAttempt(opts) {
|
|
|
2267
3652
|
prompt: renderPrompt(
|
|
2268
3653
|
ticketSpec,
|
|
2269
3654
|
ticket,
|
|
2270
|
-
{
|
|
3655
|
+
{
|
|
3656
|
+
testCmd: opts.testCmd,
|
|
3657
|
+
installCmd: opts.installCmd,
|
|
3658
|
+
// Rebuilt every attempt, so a retry sees the
|
|
3659
|
+
// entry its previous attempt just wrote
|
|
3660
|
+
history: buildHistory(
|
|
3661
|
+
opts.learningsPath,
|
|
3662
|
+
ticket,
|
|
3663
|
+
d
|
|
3664
|
+
)
|
|
3665
|
+
}
|
|
2271
3666
|
),
|
|
2272
3667
|
agentCmd: opts.agentCmd,
|
|
2273
3668
|
model: opts.model,
|
|
@@ -2284,6 +3679,20 @@ async function runTicketAttempt(opts) {
|
|
|
2284
3679
|
effort: opts.effort,
|
|
2285
3680
|
onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
|
|
2286
3681
|
});
|
|
3682
|
+
let notes = null;
|
|
3683
|
+
try {
|
|
3684
|
+
notes = collectNotes(worktree.worktreePath);
|
|
3685
|
+
} catch (err) {
|
|
3686
|
+
d.log(
|
|
3687
|
+
` ${ticket.filename}: could not collect notes: ${formatErrorMessage(err)}`
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
const agentRun = {
|
|
3691
|
+
exitCode: agentResult.exitCode,
|
|
3692
|
+
completedItems: agentResult.completedItems,
|
|
3693
|
+
timedOut: agentResult.timedOut,
|
|
3694
|
+
notes
|
|
3695
|
+
};
|
|
2287
3696
|
if (agentResult.timedOut) {
|
|
2288
3697
|
d.log(
|
|
2289
3698
|
` ${ticket.filename}: timed out; keeping the work it committed`
|
|
@@ -2298,7 +3707,8 @@ async function runTicketAttempt(opts) {
|
|
|
2298
3707
|
reason: "exit",
|
|
2299
3708
|
branchName: worktree.branchName,
|
|
2300
3709
|
exitCode: agentResult.exitCode,
|
|
2301
|
-
completedItems: agentResult.completedItems
|
|
3710
|
+
completedItems: agentResult.completedItems,
|
|
3711
|
+
agentRun
|
|
2302
3712
|
};
|
|
2303
3713
|
}
|
|
2304
3714
|
if (isDirty(worktree.worktreePath)) {
|
|
@@ -2321,7 +3731,8 @@ async function runTicketAttempt(opts) {
|
|
|
2321
3731
|
return {
|
|
2322
3732
|
kind: "no-commits",
|
|
2323
3733
|
worktreePath: worktree.worktreePath,
|
|
2324
|
-
branchName: worktree.branchName
|
|
3734
|
+
branchName: worktree.branchName,
|
|
3735
|
+
agentRun
|
|
2325
3736
|
};
|
|
2326
3737
|
}
|
|
2327
3738
|
const passed = await runTestGate({
|
|
@@ -2344,44 +3755,130 @@ async function runTicketAttempt(opts) {
|
|
|
2344
3755
|
reason: "tests",
|
|
2345
3756
|
branchName: worktree.branchName,
|
|
2346
3757
|
exitCode: 1,
|
|
2347
|
-
completedItems: agentResult.completedItems
|
|
3758
|
+
completedItems: agentResult.completedItems,
|
|
3759
|
+
agentRun
|
|
2348
3760
|
};
|
|
2349
3761
|
}
|
|
2350
3762
|
return {
|
|
2351
3763
|
kind: "ready",
|
|
2352
3764
|
worktreePath: worktree.worktreePath,
|
|
2353
3765
|
branchName: worktree.branchName,
|
|
2354
|
-
completedItems: agentResult.completedItems
|
|
3766
|
+
completedItems: agentResult.completedItems,
|
|
3767
|
+
agentRun
|
|
2355
3768
|
};
|
|
2356
3769
|
}
|
|
2357
3770
|
__name(runTicketAttempt, "runTicketAttempt");
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
3771
|
+
function readLogTail(path) {
|
|
3772
|
+
try {
|
|
3773
|
+
return tailLines(readFileSync6(path, "utf8"), LOG_TAIL_LINES);
|
|
3774
|
+
} catch {
|
|
3775
|
+
return "";
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
__name(readLogTail, "readLogTail");
|
|
3779
|
+
function attemptEntry(opts) {
|
|
3780
|
+
const { logsDir, ticket, attempt, effort, outcome } = opts;
|
|
3781
|
+
const base = {
|
|
3782
|
+
key: ticketKey(ticket.feature, ticket.num),
|
|
3783
|
+
label: ticketLabel(ticket),
|
|
3784
|
+
attempt,
|
|
3785
|
+
now: /* @__PURE__ */ new Date()
|
|
2368
3786
|
};
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
3787
|
+
const logFor = /* @__PURE__ */ __name((ext) => {
|
|
3788
|
+
const path = attemptLogPath(logsDir, ticket, attempt, ext);
|
|
3789
|
+
return { logName: basename5(path), logTail: readLogTail(path) };
|
|
3790
|
+
}, "logFor");
|
|
3791
|
+
switch (outcome.kind) {
|
|
3792
|
+
case "ready":
|
|
3793
|
+
return {
|
|
3794
|
+
...base,
|
|
3795
|
+
outcome: "done",
|
|
3796
|
+
run: outcome.agentRun,
|
|
3797
|
+
effort
|
|
3798
|
+
};
|
|
3799
|
+
case "failed":
|
|
3800
|
+
switch (outcome.reason) {
|
|
3801
|
+
case "exit":
|
|
3802
|
+
return {
|
|
3803
|
+
...base,
|
|
3804
|
+
outcome: "exit",
|
|
3805
|
+
run: outcome.agentRun,
|
|
3806
|
+
effort
|
|
3807
|
+
};
|
|
3808
|
+
case "tests":
|
|
3809
|
+
return {
|
|
3810
|
+
...base,
|
|
3811
|
+
outcome: "tests",
|
|
3812
|
+
run: outcome.agentRun,
|
|
3813
|
+
effort,
|
|
3814
|
+
...logFor(".test.log")
|
|
3815
|
+
};
|
|
3816
|
+
case "install":
|
|
3817
|
+
return {
|
|
3818
|
+
...base,
|
|
3819
|
+
outcome: "install",
|
|
3820
|
+
...logFor(".install.log")
|
|
3821
|
+
};
|
|
3822
|
+
default: {
|
|
3823
|
+
const _exhaustive = outcome;
|
|
3824
|
+
return _exhaustive;
|
|
3825
|
+
}
|
|
2378
3826
|
}
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
3827
|
+
case "no-commits":
|
|
3828
|
+
return {
|
|
3829
|
+
...base,
|
|
3830
|
+
outcome: "no-commits",
|
|
3831
|
+
run: outcome.agentRun,
|
|
3832
|
+
effort
|
|
3833
|
+
};
|
|
3834
|
+
case "error":
|
|
3835
|
+
case "worktree-error":
|
|
3836
|
+
return {
|
|
3837
|
+
...base,
|
|
3838
|
+
outcome: "error",
|
|
3839
|
+
error: formatErrorMessage(outcome.error)
|
|
3840
|
+
};
|
|
3841
|
+
default: {
|
|
3842
|
+
const _exhaustive = outcome;
|
|
3843
|
+
return _exhaustive;
|
|
2384
3844
|
}
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
__name(attemptEntry, "attemptEntry");
|
|
3848
|
+
function recordAttempt(opts) {
|
|
3849
|
+
try {
|
|
3850
|
+
const entry = attemptEntry(opts);
|
|
3851
|
+
if (!entry) return;
|
|
3852
|
+
appendEntry(opts.learningsPath, renderEntry(entry));
|
|
3853
|
+
} catch (err) {
|
|
3854
|
+
opts.d.log(
|
|
3855
|
+
` ${opts.ticket.filename}: could not record learnings: ${formatErrorMessage(err)}`
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
__name(recordAttempt, "recordAttempt");
|
|
3860
|
+
async function runTicketAttempts(opts) {
|
|
3861
|
+
const { ticket, d } = opts;
|
|
3862
|
+
return runWithRetries({
|
|
3863
|
+
maxAttempts: opts.retries + 1,
|
|
3864
|
+
retryDelay: opts.retryDelay,
|
|
3865
|
+
agentCmd: opts.agentCmd,
|
|
3866
|
+
effort: opts.effort,
|
|
3867
|
+
label: ticket.filename,
|
|
3868
|
+
log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
|
|
3869
|
+
}, async (attempt, effort) => {
|
|
3870
|
+
const record = /* @__PURE__ */ __name((o) => {
|
|
3871
|
+
recordAttempt({
|
|
3872
|
+
learningsPath: opts.learningsPath,
|
|
3873
|
+
logsDir: opts.logsDir,
|
|
3874
|
+
ticket,
|
|
3875
|
+
attempt,
|
|
3876
|
+
effort,
|
|
3877
|
+
outcome: o,
|
|
3878
|
+
d
|
|
3879
|
+
});
|
|
3880
|
+
}, "record");
|
|
3881
|
+
let outcome;
|
|
2385
3882
|
try {
|
|
2386
3883
|
outcome = await runTicketAttempt({
|
|
2387
3884
|
...opts,
|
|
@@ -2392,25 +3889,46 @@ async function runTicketAttempts(opts) {
|
|
|
2392
3889
|
d.log(
|
|
2393
3890
|
` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
|
|
2394
3891
|
);
|
|
2395
|
-
|
|
3892
|
+
const errorOutcome = { kind: "error", error };
|
|
3893
|
+
record(errorOutcome);
|
|
3894
|
+
return { result: errorOutcome, stop: true };
|
|
2396
3895
|
}
|
|
3896
|
+
record(outcome);
|
|
2397
3897
|
if (outcome.kind === "ready") {
|
|
2398
3898
|
d.setTicketStatus(
|
|
2399
3899
|
ticket.num,
|
|
2400
3900
|
"awaiting-merge",
|
|
2401
3901
|
ticket.feature
|
|
2402
3902
|
);
|
|
2403
|
-
return outcome;
|
|
3903
|
+
return { result: outcome, stop: true };
|
|
2404
3904
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
3905
|
+
return {
|
|
3906
|
+
result: outcome,
|
|
3907
|
+
stop: outcome.kind === "worktree-error"
|
|
3908
|
+
};
|
|
3909
|
+
});
|
|
2408
3910
|
}
|
|
2409
3911
|
__name(runTicketAttempts, "runTicketAttempts");
|
|
3912
|
+
function failureNote(reason) {
|
|
3913
|
+
if (reason === "tests") return "tests failed";
|
|
3914
|
+
if (reason === "install") return "install failed";
|
|
3915
|
+
return "failed";
|
|
3916
|
+
}
|
|
3917
|
+
__name(failureNote, "failureNote");
|
|
3918
|
+
function isRegularFile(path) {
|
|
3919
|
+
try {
|
|
3920
|
+
return statSync(path).isFile();
|
|
3921
|
+
} catch {
|
|
3922
|
+
return false;
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
__name(isRegularFile, "isRegularFile");
|
|
2410
3926
|
async function saveProgress(repoRoot, progressPath, progress, message, d) {
|
|
2411
3927
|
writeProgress(progressPath, progress);
|
|
3928
|
+
const learningsPath = learningsPathFor(progressPath);
|
|
3929
|
+
const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
|
|
2412
3930
|
try {
|
|
2413
|
-
await commitProgress(repoRoot,
|
|
3931
|
+
await commitProgress(repoRoot, paths, message);
|
|
2414
3932
|
} catch (err) {
|
|
2415
3933
|
d.log(
|
|
2416
3934
|
" Failed to commit progress: " + formatErrorMessage(err)
|
|
@@ -2480,6 +3998,29 @@ function renderFinishSummary(tickets, totalCompleted, failedKeys) {
|
|
|
2480
3998
|
return lines.join("\n");
|
|
2481
3999
|
}
|
|
2482
4000
|
__name(renderFinishSummary, "renderFinishSummary");
|
|
4001
|
+
function sessionTickets(tickets) {
|
|
4002
|
+
return [...tickets].filter((t) => t.status === "ready-for-agent").sort((a, b) => a.feature.localeCompare(b.feature) || a.num - b.num);
|
|
4003
|
+
}
|
|
4004
|
+
__name(sessionTickets, "sessionTickets");
|
|
4005
|
+
function toRef(t) {
|
|
4006
|
+
return { num: t.num, filename: t.filename, feature: t.feature };
|
|
4007
|
+
}
|
|
4008
|
+
__name(toRef, "toRef");
|
|
4009
|
+
function reportBlocked(d, tickets, failedKeys) {
|
|
4010
|
+
const byKey = new Map(
|
|
4011
|
+
tickets.map((t) => [ticketKey(t.feature, t.num), t])
|
|
4012
|
+
);
|
|
4013
|
+
for (const [key, dead] of findBlockedTickets(
|
|
4014
|
+
tickets,
|
|
4015
|
+
failedKeys
|
|
4016
|
+
)) {
|
|
4017
|
+
const ticket = byKey.get(key);
|
|
4018
|
+
if (!ticket) continue;
|
|
4019
|
+
const labels = dead.map((k) => byKey.get(k)).filter((t) => t !== void 0).map((t) => ticketLabel(toRef(t)));
|
|
4020
|
+
d.setBlocked(ticket.num, ticket.feature, labels);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
__name(reportBlocked, "reportBlocked");
|
|
2483
4024
|
function runProgress(tickets) {
|
|
2484
4025
|
return {
|
|
2485
4026
|
completed: tickets.filter((t) => t.status === "done").length,
|
|
@@ -2488,19 +4029,21 @@ function runProgress(tickets) {
|
|
|
2488
4029
|
}
|
|
2489
4030
|
__name(runProgress, "runProgress");
|
|
2490
4031
|
async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
|
|
2491
|
-
|
|
4032
|
+
mkdirSync3(logsDir, { recursive: true });
|
|
2492
4033
|
const d = display ?? createDisplay();
|
|
2493
4034
|
const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
|
|
2494
4035
|
const delayBase = retryDelay ?? 0;
|
|
2495
4036
|
const poolSize = concurrency ?? DEFAULT_CONCURRENCY;
|
|
2496
|
-
const providerName =
|
|
4037
|
+
const providerName = basename5(agentCmd);
|
|
2497
4038
|
const isKnown = providerName in EFFORT_LADDERS;
|
|
2498
4039
|
let totalWaves = 0;
|
|
2499
4040
|
for (const _ of simulateWaves(tickets)) totalWaves++;
|
|
2500
4041
|
let totalCompleted = 0;
|
|
2501
4042
|
let waveNum = 0;
|
|
2502
4043
|
const failedKeys = /* @__PURE__ */ new Set();
|
|
4044
|
+
const session = sessionTickets(tickets);
|
|
2503
4045
|
while (true) {
|
|
4046
|
+
if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
|
|
2504
4047
|
const ready = findReadyTickets(tickets).filter(
|
|
2505
4048
|
(t) => !failedKeys.has(
|
|
2506
4049
|
ticketKey(t.feature, t.num)
|
|
@@ -2508,12 +4051,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
|
|
|
2508
4051
|
);
|
|
2509
4052
|
if (ready.length === 0) break;
|
|
2510
4053
|
waveNum++;
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
(t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
|
|
2516
|
-
);
|
|
4054
|
+
if (waveNum === 1) {
|
|
4055
|
+
d.startRun(session.map(toRef));
|
|
4056
|
+
reportBlocked(d, tickets, failedKeys);
|
|
4057
|
+
}
|
|
2517
4058
|
d.startWave(
|
|
2518
4059
|
waveNum,
|
|
2519
4060
|
totalWaves,
|
|
@@ -2522,12 +4063,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
|
|
|
2522
4063
|
filename: t.filename,
|
|
2523
4064
|
feature: t.feature
|
|
2524
4065
|
})),
|
|
2525
|
-
runProgress(
|
|
2526
|
-
remaining2.map((t) => ({
|
|
2527
|
-
num: t.num,
|
|
2528
|
-
filename: t.filename,
|
|
2529
|
-
feature: t.feature
|
|
2530
|
-
}))
|
|
4066
|
+
runProgress(session)
|
|
2531
4067
|
);
|
|
2532
4068
|
const results = await runPool(
|
|
2533
4069
|
ready,
|
|
@@ -2624,10 +4160,10 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
|
|
|
2624
4160
|
);
|
|
2625
4161
|
}
|
|
2626
4162
|
if (waveCompleted === 0) {
|
|
2627
|
-
const
|
|
4163
|
+
const remaining2 = findReadyTickets(tickets).filter((t) => !failedKeys.has(
|
|
2628
4164
|
ticketKey(t.feature, t.num)
|
|
2629
4165
|
));
|
|
2630
|
-
if (
|
|
4166
|
+
if (remaining2.length === 0) {
|
|
2631
4167
|
d.log(
|
|
2632
4168
|
"\nNo tickets completed this wave and no ready tickets remain. Stopping."
|
|
2633
4169
|
);
|
|
@@ -2635,10 +4171,11 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, te
|
|
|
2635
4171
|
}
|
|
2636
4172
|
d.log(
|
|
2637
4173
|
`
|
|
2638
|
-
No tickets completed this wave. ${
|
|
4174
|
+
No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
|
|
2639
4175
|
);
|
|
2640
4176
|
}
|
|
2641
4177
|
}
|
|
4178
|
+
if (waveNum > 0) reportBlocked(d, tickets, failedKeys);
|
|
2642
4179
|
const remaining = tickets.filter(
|
|
2643
4180
|
(t) => t.status === "ready-for-agent"
|
|
2644
4181
|
);
|
|
@@ -2652,7 +4189,7 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
|
|
|
2652
4189
|
}
|
|
2653
4190
|
__name(runWaves, "runWaves");
|
|
2654
4191
|
async function runWavesIsolated(opts) {
|
|
2655
|
-
|
|
4192
|
+
mkdirSync3(opts.logsDir, { recursive: true });
|
|
2656
4193
|
const d = opts.display ?? createDisplay();
|
|
2657
4194
|
const retries = opts.retries ?? DEFAULT_RETRIES;
|
|
2658
4195
|
const retryDelay = opts.retryDelay ?? 0;
|
|
@@ -2667,12 +4204,13 @@ async function runWavesIsolated(opts) {
|
|
|
2667
4204
|
"Working tree is dirty; commit or stash changes before running"
|
|
2668
4205
|
);
|
|
2669
4206
|
}
|
|
4207
|
+
const launchCommit = headCommit(repoRoot);
|
|
2670
4208
|
const features = new Set(
|
|
2671
4209
|
opts.tickets.map((t) => t.feature).filter(Boolean)
|
|
2672
4210
|
);
|
|
2673
4211
|
const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
|
|
2674
|
-
const fallbackProgressPath =
|
|
2675
|
-
|
|
4212
|
+
const fallbackProgressPath = join6(
|
|
4213
|
+
dirname4(opts.logsDir),
|
|
2676
4214
|
"progress.md"
|
|
2677
4215
|
);
|
|
2678
4216
|
let tracker;
|
|
@@ -2731,7 +4269,9 @@ async function runWavesIsolated(opts) {
|
|
|
2731
4269
|
let totalCompleted = 0;
|
|
2732
4270
|
let waveNum = 0;
|
|
2733
4271
|
const failedKeys = /* @__PURE__ */ new Set();
|
|
4272
|
+
const session = sessionTickets(opts.tickets);
|
|
2734
4273
|
while (true) {
|
|
4274
|
+
if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
|
|
2735
4275
|
const ready = findReadyTickets(opts.tickets).filter(
|
|
2736
4276
|
(t) => !failedKeys.has(
|
|
2737
4277
|
ticketKey(t.feature, t.num)
|
|
@@ -2739,12 +4279,10 @@ async function runWavesIsolated(opts) {
|
|
|
2739
4279
|
);
|
|
2740
4280
|
if (ready.length === 0) break;
|
|
2741
4281
|
waveNum++;
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
(t) => t.status === "ready-for-agent" && !readyKeys.has(ticketKey(t.feature, t.num))
|
|
2747
|
-
);
|
|
4282
|
+
if (waveNum === 1) {
|
|
4283
|
+
d.startRun(session.map(toRef));
|
|
4284
|
+
reportBlocked(d, opts.tickets, failedKeys);
|
|
4285
|
+
}
|
|
2748
4286
|
d.startWave(
|
|
2749
4287
|
waveNum,
|
|
2750
4288
|
totalWaves,
|
|
@@ -2753,12 +4291,7 @@ async function runWavesIsolated(opts) {
|
|
|
2753
4291
|
filename: t.filename,
|
|
2754
4292
|
feature: t.feature
|
|
2755
4293
|
})),
|
|
2756
|
-
runProgress(
|
|
2757
|
-
remaining2.map((t) => ({
|
|
2758
|
-
num: t.num,
|
|
2759
|
-
filename: t.filename,
|
|
2760
|
-
feature: t.feature
|
|
2761
|
-
}))
|
|
4294
|
+
runProgress(session)
|
|
2762
4295
|
);
|
|
2763
4296
|
const settled = await runPool(
|
|
2764
4297
|
ready,
|
|
@@ -2775,13 +4308,34 @@ async function runWavesIsolated(opts) {
|
|
|
2775
4308
|
timeout: opts.timeout,
|
|
2776
4309
|
logsDir: opts.logsDir,
|
|
2777
4310
|
testCmd: opts.testCmd,
|
|
4311
|
+
installCmd: opts.installCmd,
|
|
2778
4312
|
maxTurns: opts.maxTurns,
|
|
2779
4313
|
effort: opts.effort,
|
|
4314
|
+
learningsPath: learningsPathFor(
|
|
4315
|
+
tracker.pathFor(t.feature)
|
|
4316
|
+
),
|
|
2780
4317
|
retries,
|
|
2781
4318
|
retryDelay,
|
|
2782
4319
|
d
|
|
2783
4320
|
})
|
|
2784
4321
|
);
|
|
4322
|
+
const touched = new Set(ready.map((t) => {
|
|
4323
|
+
return learningsPathFor(tracker.pathFor(t.feature));
|
|
4324
|
+
}));
|
|
4325
|
+
for (const path of touched) {
|
|
4326
|
+
if (!isRegularFile(path)) continue;
|
|
4327
|
+
try {
|
|
4328
|
+
await commitProgress(
|
|
4329
|
+
repoRoot,
|
|
4330
|
+
path,
|
|
4331
|
+
"nightralph: record attempt learnings"
|
|
4332
|
+
);
|
|
4333
|
+
} catch (err) {
|
|
4334
|
+
d.log(
|
|
4335
|
+
" Failed to commit learnings: " + formatErrorMessage(err)
|
|
4336
|
+
);
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
2785
4339
|
const outcomes = settled.map(
|
|
2786
4340
|
(r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
|
|
2787
4341
|
);
|
|
@@ -2885,7 +4439,7 @@ async function runWavesIsolated(opts) {
|
|
|
2885
4439
|
repoRoot,
|
|
2886
4440
|
tracker.pathFor(ticket.feature),
|
|
2887
4441
|
tracker.dataFor(ticket.feature),
|
|
2888
|
-
`nightralph: ${ticket.filename} ` + (outcome.reason
|
|
4442
|
+
`nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
|
|
2889
4443
|
d
|
|
2890
4444
|
);
|
|
2891
4445
|
continue;
|
|
@@ -2921,6 +4475,7 @@ async function runWavesIsolated(opts) {
|
|
|
2921
4475
|
branches: successfulBranches,
|
|
2922
4476
|
strategy: opts.conflictStrategy
|
|
2923
4477
|
});
|
|
4478
|
+
const doneThisWave = [];
|
|
2924
4479
|
for (const branchResult of mergeResult.merged) {
|
|
2925
4480
|
const bk = branchToKey.get(
|
|
2926
4481
|
branchResult.branch
|
|
@@ -2929,6 +4484,7 @@ async function runWavesIsolated(opts) {
|
|
|
2929
4484
|
if (!ticket) continue;
|
|
2930
4485
|
if (branchResult.merged) {
|
|
2931
4486
|
markDone(ticket);
|
|
4487
|
+
doneThisWave.push(ticket);
|
|
2932
4488
|
waveCompleted++;
|
|
2933
4489
|
totalCompleted++;
|
|
2934
4490
|
d.setTicketStatus(
|
|
@@ -2978,11 +4534,13 @@ async function runWavesIsolated(opts) {
|
|
|
2978
4534
|
timeout: opts.timeout,
|
|
2979
4535
|
logsDir: opts.logsDir,
|
|
2980
4536
|
testCmd: opts.testCmd,
|
|
4537
|
+
installCmd: opts.installCmd,
|
|
2981
4538
|
maxTurns: opts.maxTurns,
|
|
2982
4539
|
onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
|
|
2983
4540
|
}) : false;
|
|
2984
4541
|
if (recovered) {
|
|
2985
4542
|
markDone(ticket);
|
|
4543
|
+
doneThisWave.push(ticket);
|
|
2986
4544
|
waveCompleted++;
|
|
2987
4545
|
totalCompleted++;
|
|
2988
4546
|
d.setTicketStatus(
|
|
@@ -3020,6 +4578,11 @@ async function runWavesIsolated(opts) {
|
|
|
3020
4578
|
failedKeys.add(
|
|
3021
4579
|
ticketKey(ticket.feature, ticket.num)
|
|
3022
4580
|
);
|
|
4581
|
+
d.setTicketStatus(
|
|
4582
|
+
ticket.num,
|
|
4583
|
+
"failed",
|
|
4584
|
+
ticket.feature
|
|
4585
|
+
);
|
|
3023
4586
|
d.log(
|
|
3024
4587
|
` ${ticket.filename}: merge failed, staying ready-for-agent`
|
|
3025
4588
|
);
|
|
@@ -3033,6 +4596,28 @@ async function runWavesIsolated(opts) {
|
|
|
3033
4596
|
exitCode: 0,
|
|
3034
4597
|
done: false
|
|
3035
4598
|
});
|
|
4599
|
+
try {
|
|
4600
|
+
appendEntry(
|
|
4601
|
+
learningsPathFor(
|
|
4602
|
+
tracker.pathFor(ticket.feature)
|
|
4603
|
+
),
|
|
4604
|
+
renderEntry({
|
|
4605
|
+
outcome: "merge",
|
|
4606
|
+
key: ticketKey(
|
|
4607
|
+
ticket.feature,
|
|
4608
|
+
ticket.num
|
|
4609
|
+
),
|
|
4610
|
+
label: ticketLabel(ticket),
|
|
4611
|
+
attempt: 0,
|
|
4612
|
+
now: /* @__PURE__ */ new Date(),
|
|
4613
|
+
stderr: branchResult.stderr ?? ""
|
|
4614
|
+
})
|
|
4615
|
+
);
|
|
4616
|
+
} catch (err) {
|
|
4617
|
+
d.log(
|
|
4618
|
+
` ${ticket.filename}: could not record learnings: ` + formatErrorMessage(err)
|
|
4619
|
+
);
|
|
4620
|
+
}
|
|
3036
4621
|
await saveProgress(
|
|
3037
4622
|
repoRoot,
|
|
3038
4623
|
tracker.pathFor(ticket.feature),
|
|
@@ -3043,6 +4628,7 @@ async function runWavesIsolated(opts) {
|
|
|
3043
4628
|
}
|
|
3044
4629
|
}
|
|
3045
4630
|
}
|
|
4631
|
+
await commitTicketFiles(repoRoot, doneThisWave, d);
|
|
3046
4632
|
if (mergeResult.stoppedAt) {
|
|
3047
4633
|
d.log(
|
|
3048
4634
|
`
|
|
@@ -3067,12 +4653,12 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
|
|
|
3067
4653
|
}
|
|
3068
4654
|
}
|
|
3069
4655
|
if (waveCompleted === 0) {
|
|
3070
|
-
const
|
|
4656
|
+
const remaining2 = findReadyTickets(
|
|
3071
4657
|
opts.tickets
|
|
3072
4658
|
).filter((t) => !failedKeys.has(
|
|
3073
4659
|
ticketKey(t.feature, t.num)
|
|
3074
4660
|
));
|
|
3075
|
-
if (
|
|
4661
|
+
if (remaining2.length === 0) {
|
|
3076
4662
|
d.log(
|
|
3077
4663
|
"\nNo tickets completed this wave and no ready tickets remain. Stopping."
|
|
3078
4664
|
);
|
|
@@ -3080,8 +4666,44 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
|
|
|
3080
4666
|
}
|
|
3081
4667
|
d.log(
|
|
3082
4668
|
`
|
|
3083
|
-
No tickets completed this wave. ${
|
|
4669
|
+
No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.length === 1 ? "" : "s"} remain; continuing.`
|
|
4670
|
+
);
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4673
|
+
if (waveNum > 0) reportBlocked(d, opts.tickets, failedKeys);
|
|
4674
|
+
let reviewLine = null;
|
|
4675
|
+
if (opts.review) {
|
|
4676
|
+
try {
|
|
4677
|
+
const outcome = await runReviewPass({
|
|
4678
|
+
repoRoot,
|
|
4679
|
+
repoName,
|
|
4680
|
+
baseBranch,
|
|
4681
|
+
reviewPath: opts.review.reviewPath,
|
|
4682
|
+
forkedFrom: opts.review.forkedFrom,
|
|
4683
|
+
launchCommit,
|
|
4684
|
+
specBody: reviewSpecBody(
|
|
4685
|
+
opts.specBody,
|
|
4686
|
+
opts.specBodies
|
|
4687
|
+
),
|
|
4688
|
+
agentCmd: opts.agentCmd,
|
|
4689
|
+
model: opts.model,
|
|
4690
|
+
timeout: opts.timeout,
|
|
4691
|
+
logsDir: opts.logsDir,
|
|
4692
|
+
testCmd: opts.testCmd,
|
|
4693
|
+
installCmd: opts.installCmd,
|
|
4694
|
+
maxTurns: opts.maxTurns,
|
|
4695
|
+
effort: opts.effort,
|
|
4696
|
+
retries,
|
|
4697
|
+
retryDelay,
|
|
4698
|
+
log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
|
|
4699
|
+
}, { spawnAgent, registry: runningAgents });
|
|
4700
|
+
reviewLine = renderReviewSummary(
|
|
4701
|
+
outcome,
|
|
4702
|
+
relative2(repoRoot, opts.review.reviewPath)
|
|
3084
4703
|
);
|
|
4704
|
+
} catch (err) {
|
|
4705
|
+
d.log(`
|
|
4706
|
+
Review failed: ${formatErrorMessage(err)}`);
|
|
3085
4707
|
}
|
|
3086
4708
|
}
|
|
3087
4709
|
const remaining = opts.tickets.filter(
|
|
@@ -3092,23 +4714,55 @@ No tickets completed this wave. ${remaining3.length} ready ticket${remaining3.le
|
|
|
3092
4714
|
totalCompleted,
|
|
3093
4715
|
failedKeys
|
|
3094
4716
|
));
|
|
4717
|
+
if (reviewLine) d.log(reviewLine);
|
|
4718
|
+
d.log(`Work is on ${baseBranch}`);
|
|
3095
4719
|
d.cleanup();
|
|
3096
|
-
return { allDone: remaining.length === 0 };
|
|
4720
|
+
return { allDone: remaining.length === 0, branch: baseBranch };
|
|
3097
4721
|
}
|
|
3098
4722
|
__name(runWavesIsolated, "runWavesIsolated");
|
|
3099
4723
|
|
|
4724
|
+
// src/preflight.ts
|
|
4725
|
+
import { accessSync, constants, statSync as statSync2 } from "node:fs";
|
|
4726
|
+
import { delimiter, join as join7, resolve as resolve2 } from "node:path";
|
|
4727
|
+
function isExecutableFile(file) {
|
|
4728
|
+
try {
|
|
4729
|
+
if (!statSync2(file).isFile()) return false;
|
|
4730
|
+
accessSync(file, constants.X_OK);
|
|
4731
|
+
return true;
|
|
4732
|
+
} catch {
|
|
4733
|
+
return false;
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
__name(isExecutableFile, "isExecutableFile");
|
|
4737
|
+
function resolveExecutable(cmd, opts = {}) {
|
|
4738
|
+
if (cmd === "") return null;
|
|
4739
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
4740
|
+
if (cmd.includes("/")) {
|
|
4741
|
+
const full = resolve2(cwd, cmd);
|
|
4742
|
+
return isExecutableFile(full) ? full : null;
|
|
4743
|
+
}
|
|
4744
|
+
const path = opts.path ?? process.env.PATH ?? "";
|
|
4745
|
+
for (const dir of path.split(delimiter)) {
|
|
4746
|
+
if (dir === "") continue;
|
|
4747
|
+
const candidate = join7(dir, cmd);
|
|
4748
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
4749
|
+
}
|
|
4750
|
+
return null;
|
|
4751
|
+
}
|
|
4752
|
+
__name(resolveExecutable, "resolveExecutable");
|
|
4753
|
+
|
|
3100
4754
|
// src/resolve.ts
|
|
3101
|
-
import { existsSync as
|
|
3102
|
-
import { dirname as
|
|
4755
|
+
import { existsSync as existsSync6, readdirSync as readdirSync3 } from "node:fs";
|
|
4756
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
3103
4757
|
import { fileURLToPath } from "node:url";
|
|
3104
4758
|
import { checkbox, Separator } from "@inquirer/prompts";
|
|
3105
4759
|
function getScriptDir(metaUrl) {
|
|
3106
|
-
return
|
|
4760
|
+
return dirname5(fileURLToPath(metaUrl));
|
|
3107
4761
|
}
|
|
3108
4762
|
__name(getScriptDir, "getScriptDir");
|
|
3109
4763
|
function findFeatureDirs(cwd) {
|
|
3110
|
-
const scratchDir =
|
|
3111
|
-
if (!
|
|
4764
|
+
const scratchDir = join8(cwd, ".scratch");
|
|
4765
|
+
if (!existsSync6(scratchDir)) return [];
|
|
3112
4766
|
const entries = readdirSync3(
|
|
3113
4767
|
scratchDir,
|
|
3114
4768
|
{ withFileTypes: true }
|
|
@@ -3116,18 +4770,18 @@ function findFeatureDirs(cwd) {
|
|
|
3116
4770
|
const results = [];
|
|
3117
4771
|
for (const entry of entries) {
|
|
3118
4772
|
if (!entry.isDirectory()) continue;
|
|
3119
|
-
const issuesDir =
|
|
4773
|
+
const issuesDir = join8(
|
|
3120
4774
|
scratchDir,
|
|
3121
4775
|
entry.name,
|
|
3122
4776
|
"issues"
|
|
3123
4777
|
);
|
|
3124
|
-
const specFile =
|
|
4778
|
+
const specFile = join8(
|
|
3125
4779
|
scratchDir,
|
|
3126
4780
|
entry.name,
|
|
3127
4781
|
"spec.md"
|
|
3128
4782
|
);
|
|
3129
|
-
if (!
|
|
3130
|
-
const progressFile =
|
|
4783
|
+
if (!existsSync6(issuesDir)) continue;
|
|
4784
|
+
const progressFile = join8(
|
|
3131
4785
|
scratchDir,
|
|
3132
4786
|
entry.name,
|
|
3133
4787
|
"progress.md"
|
|
@@ -3146,10 +4800,10 @@ __name(findFeatureDirs, "findFeatureDirs");
|
|
|
3146
4800
|
var ALL_VALUE = "__all__";
|
|
3147
4801
|
async function resolveIssuesDir(specName, cwd = process.cwd()) {
|
|
3148
4802
|
if (specName) {
|
|
3149
|
-
const featureDir =
|
|
4803
|
+
const featureDir = join8(cwd, ".scratch", specName);
|
|
3150
4804
|
return [{
|
|
3151
|
-
dir:
|
|
3152
|
-
spec:
|
|
4805
|
+
dir: join8(featureDir, "issues"),
|
|
4806
|
+
spec: join8(featureDir, "spec.md"),
|
|
3153
4807
|
name: specName
|
|
3154
4808
|
}];
|
|
3155
4809
|
}
|
|
@@ -3228,8 +4882,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3228
4882
|
}
|
|
3229
4883
|
}
|
|
3230
4884
|
setup({
|
|
3231
|
-
bundledSkillsDir:
|
|
3232
|
-
docsTemplatesDir:
|
|
4885
|
+
bundledSkillsDir: join9(scriptDir, "skills"),
|
|
4886
|
+
docsTemplatesDir: join9(
|
|
3233
4887
|
scriptDir,
|
|
3234
4888
|
"docs-templates"
|
|
3235
4889
|
),
|
|
@@ -3264,7 +4918,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3264
4918
|
default: 3600
|
|
3265
4919
|
}).option("test-cmd", {
|
|
3266
4920
|
type: "string",
|
|
3267
|
-
describe: 'Command run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to "npm test".'
|
|
4921
|
+
describe: 'Command run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to "npm test" when package.json has a test script; otherwise the run has no test gate. "" disables it.'
|
|
4922
|
+
}).option("install-cmd", {
|
|
4923
|
+
type: "string",
|
|
4924
|
+
describe: `Command run in each fresh worktree before the agent starts. Defaults to the install command for the project's lockfile (npm ci or pnpm install); "" disables it.`
|
|
3268
4925
|
}).option("X", {
|
|
3269
4926
|
type: "boolean",
|
|
3270
4927
|
describe: "Stop on merge conflict instead of re-spawning",
|
|
@@ -3288,6 +4945,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3288
4945
|
type: "number",
|
|
3289
4946
|
default: DEFAULT_CONCURRENCY,
|
|
3290
4947
|
describe: "Max agents running at once within a wave."
|
|
4948
|
+
}).option("review", {
|
|
4949
|
+
type: "boolean",
|
|
4950
|
+
default: true,
|
|
4951
|
+
describe: "Review the merged work and run one fix pass at the end. --no-review skips it."
|
|
3291
4952
|
}),
|
|
3292
4953
|
async (argv) => {
|
|
3293
4954
|
await runExecute({
|
|
@@ -3297,15 +4958,26 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3297
4958
|
dryRun: argv.dryRun,
|
|
3298
4959
|
timeout: argv.timeout,
|
|
3299
4960
|
testCmd: argv.testCmd,
|
|
4961
|
+
installCmd: argv.installCmd,
|
|
3300
4962
|
stopOnConflict: argv.X,
|
|
3301
4963
|
maxTurns: argv.maxTurns ?? argv.turns,
|
|
3302
4964
|
effort: argv.effort,
|
|
3303
4965
|
retries: argv.retries,
|
|
3304
4966
|
retryDelay: argv.retryDelay,
|
|
3305
|
-
concurrency: argv.concurrency
|
|
4967
|
+
concurrency: argv.concurrency,
|
|
4968
|
+
review: argv.review
|
|
3306
4969
|
});
|
|
3307
4970
|
}
|
|
3308
|
-
).strict().demandCommand(1, "Specify a command or provider").help().
|
|
4971
|
+
).strict().demandCommand(1, "Specify a command or provider").help().fail((msg, err, y) => {
|
|
4972
|
+
if (err && !msg) {
|
|
4973
|
+
console.error(formatErrorMessage(err));
|
|
4974
|
+
process.exit(2);
|
|
4975
|
+
}
|
|
4976
|
+
y.showHelp();
|
|
4977
|
+
console.error(`
|
|
4978
|
+
${msg}`);
|
|
4979
|
+
process.exit(1);
|
|
4980
|
+
}).parse();
|
|
3309
4981
|
function isGitRepo() {
|
|
3310
4982
|
try {
|
|
3311
4983
|
getRepoRoot();
|
|
@@ -3326,41 +4998,36 @@ function extractFeatureName(spec) {
|
|
|
3326
4998
|
return parts[parts.length - 2] ?? parts[parts.length - 1] ?? "feature";
|
|
3327
4999
|
}
|
|
3328
5000
|
__name(extractFeatureName, "extractFeatureName");
|
|
3329
|
-
|
|
3330
|
-
const selections = await resolveIssuesDir(
|
|
3331
|
-
argv.spec
|
|
3332
|
-
);
|
|
5001
|
+
function loadTickets(selections, log) {
|
|
3333
5002
|
const allTickets = [];
|
|
3334
5003
|
const specBodies = /* @__PURE__ */ new Map();
|
|
3335
5004
|
const progressPaths = /* @__PURE__ */ new Map();
|
|
5005
|
+
const issuesDirs = [];
|
|
3336
5006
|
let firstFeatureName = "";
|
|
3337
5007
|
let firstLogsDir = "";
|
|
5008
|
+
const missing = [];
|
|
3338
5009
|
for (const selection of selections) {
|
|
3339
5010
|
const { dir, spec, name } = selection;
|
|
3340
|
-
if (!
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
);
|
|
3344
|
-
process.exit(2);
|
|
5011
|
+
if (!existsSync7(dir)) {
|
|
5012
|
+
missing.push(`Directory not found: ${dir}`);
|
|
5013
|
+
continue;
|
|
3345
5014
|
}
|
|
3346
|
-
if (!
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
);
|
|
3350
|
-
process.exit(2);
|
|
5015
|
+
if (!existsSync7(spec)) {
|
|
5016
|
+
missing.push(`Spec file not found: ${spec}`);
|
|
5017
|
+
continue;
|
|
3351
5018
|
}
|
|
3352
5019
|
const specBody = stripSpecSection(
|
|
3353
|
-
|
|
5020
|
+
readFileSync7(spec, "utf8"),
|
|
3354
5021
|
"User Stories"
|
|
3355
5022
|
);
|
|
3356
5023
|
const tickets = scanTickets(dir, name);
|
|
3357
5024
|
if (tickets.length === 0) {
|
|
3358
|
-
|
|
5025
|
+
log(
|
|
3359
5026
|
`No ticket files found for ${name}. Skipping.`
|
|
3360
5027
|
);
|
|
3361
5028
|
continue;
|
|
3362
5029
|
}
|
|
3363
|
-
|
|
5030
|
+
log(
|
|
3364
5031
|
`[${name}] Found ${tickets.length} tickets`
|
|
3365
5032
|
);
|
|
3366
5033
|
const knownNums = new Set(
|
|
@@ -3371,7 +5038,7 @@ async function runExecute(argv) {
|
|
|
3371
5038
|
(b) => !knownNums.has(b)
|
|
3372
5039
|
);
|
|
3373
5040
|
if (unknown.length > 0) {
|
|
3374
|
-
|
|
5041
|
+
log(
|
|
3375
5042
|
`${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
|
|
3376
5043
|
);
|
|
3377
5044
|
}
|
|
@@ -3382,24 +5049,128 @@ async function runExecute(argv) {
|
|
|
3382
5049
|
specBodies.set(name, specBody);
|
|
3383
5050
|
progressPaths.set(
|
|
3384
5051
|
name,
|
|
3385
|
-
|
|
5052
|
+
join9(dirname6(dir), "progress.md")
|
|
3386
5053
|
);
|
|
5054
|
+
issuesDirs.push(dir);
|
|
3387
5055
|
allTickets.push(...tickets);
|
|
3388
5056
|
if (!firstFeatureName) {
|
|
3389
5057
|
firstFeatureName = extractFeatureName(spec);
|
|
3390
|
-
firstLogsDir =
|
|
5058
|
+
firstLogsDir = join9(dirname6(dir), "logs");
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
return {
|
|
5062
|
+
allTickets,
|
|
5063
|
+
specBodies,
|
|
5064
|
+
progressPaths,
|
|
5065
|
+
issuesDirs,
|
|
5066
|
+
firstFeatureName,
|
|
5067
|
+
firstLogsDir,
|
|
5068
|
+
missing
|
|
5069
|
+
};
|
|
5070
|
+
}
|
|
5071
|
+
__name(loadTickets, "loadTickets");
|
|
5072
|
+
function loadTicketsOrExit(selections) {
|
|
5073
|
+
const loaded = loadTickets(selections, console.log);
|
|
5074
|
+
if (loaded.missing.length > 0) {
|
|
5075
|
+
for (const msg of loaded.missing) console.error(msg);
|
|
5076
|
+
process.exit(2);
|
|
5077
|
+
}
|
|
5078
|
+
return loaded;
|
|
5079
|
+
}
|
|
5080
|
+
__name(loadTicketsOrExit, "loadTicketsOrExit");
|
|
5081
|
+
function failStaleResume(plan, problems) {
|
|
5082
|
+
try {
|
|
5083
|
+
execFileSync2(
|
|
5084
|
+
"git",
|
|
5085
|
+
["checkout", "--quiet", plan.launchBranch],
|
|
5086
|
+
{ cwd: getRepoRoot(), stdio: "ignore" }
|
|
5087
|
+
);
|
|
5088
|
+
} catch {
|
|
5089
|
+
}
|
|
5090
|
+
const n = plan.behind;
|
|
5091
|
+
console.error(
|
|
5092
|
+
`Cannot resume ${plan.branch}: ${problems.join("; ")}. It is ${n} commit${n === 1 ? "" : "s"} behind ${plan.launchBranch}. Merge ${plan.launchBranch} into it, or delete it with \`git branch -D ${plan.branch}\`. Back on ${plan.launchBranch}.`
|
|
5093
|
+
);
|
|
5094
|
+
process.exit(2);
|
|
5095
|
+
}
|
|
5096
|
+
__name(failStaleResume, "failStaleResume");
|
|
5097
|
+
function reviewPathFor(issuesDirs) {
|
|
5098
|
+
const featureDir = dirname6(issuesDirs[0]);
|
|
5099
|
+
return issuesDirs.length === 1 ? join9(featureDir, "review.md") : join9(dirname6(featureDir), "review.md");
|
|
5100
|
+
}
|
|
5101
|
+
__name(reviewPathFor, "reviewPathFor");
|
|
5102
|
+
async function runExecute(argv) {
|
|
5103
|
+
const selections = await resolveIssuesDir(
|
|
5104
|
+
argv.spec
|
|
5105
|
+
);
|
|
5106
|
+
let loaded = loadTicketsOrExit(selections);
|
|
5107
|
+
if (loaded.allTickets.length === 0) return;
|
|
5108
|
+
const providerPath = resolveExecutable(argv.provider);
|
|
5109
|
+
const providerIsPath = argv.provider.includes("/");
|
|
5110
|
+
const providerMissing = providerIsPath ? "not found or not executable" : "not found on PATH";
|
|
5111
|
+
if (providerPath === null && !argv.dryRun) {
|
|
5112
|
+
console.error(
|
|
5113
|
+
`Provider '${argv.provider}' ${providerMissing}`
|
|
5114
|
+
);
|
|
5115
|
+
process.exit(2);
|
|
5116
|
+
}
|
|
5117
|
+
const agentCmd = providerIsPath ? providerPath ?? argv.provider : argv.provider;
|
|
5118
|
+
const isolated = isGitRepo();
|
|
5119
|
+
const featureNames = [...loaded.specBodies.keys()];
|
|
5120
|
+
let plan = null;
|
|
5121
|
+
if (isolated && !argv.dryRun) {
|
|
5122
|
+
try {
|
|
5123
|
+
plan = prepareIntegrationBranch(
|
|
5124
|
+
getRepoRoot(),
|
|
5125
|
+
featureNames
|
|
5126
|
+
);
|
|
5127
|
+
} catch (err) {
|
|
5128
|
+
console.error(formatErrorMessage(err));
|
|
5129
|
+
process.exit(2);
|
|
5130
|
+
}
|
|
5131
|
+
if (plan.created) {
|
|
5132
|
+
console.log(
|
|
5133
|
+
`Created branch ${plan.branch} from ${plan.launchBranch}`
|
|
5134
|
+
);
|
|
5135
|
+
} else if (plan.resumed) {
|
|
5136
|
+
console.log(
|
|
5137
|
+
`Resumed ${plan.branch}` + (plan.fastForwarded ? ` (fast-forwarded to ${plan.launchBranch})` : "") + (plan.behind > 0 ? ` (${plan.launchBranch} is ${plan.behind} commit${plan.behind === 1 ? "" : "s"} ahead)` : "")
|
|
5138
|
+
);
|
|
5139
|
+
loaded = loadTickets(selections, () => {
|
|
5140
|
+
});
|
|
5141
|
+
if (loaded.missing.length > 0) {
|
|
5142
|
+
failStaleResume(plan, loaded.missing);
|
|
5143
|
+
}
|
|
5144
|
+
if (loaded.allTickets.length === 0) {
|
|
5145
|
+
failStaleResume(plan, ["no tickets"]);
|
|
5146
|
+
}
|
|
3391
5147
|
}
|
|
3392
5148
|
}
|
|
3393
|
-
|
|
5149
|
+
const {
|
|
5150
|
+
allTickets,
|
|
5151
|
+
specBodies,
|
|
5152
|
+
progressPaths,
|
|
5153
|
+
firstFeatureName,
|
|
5154
|
+
firstLogsDir
|
|
5155
|
+
} = loaded;
|
|
3394
5156
|
const timeout = argv.timeout ?? 900;
|
|
3395
|
-
const testCmd = argv.testCmd
|
|
3396
|
-
|
|
3397
|
-
)
|
|
5157
|
+
const testCmd = argv.testCmd ?? detectTestCmd(
|
|
5158
|
+
isolated ? getRepoRoot() : process.cwd()
|
|
5159
|
+
) ?? "";
|
|
5160
|
+
if (argv.testCmd === void 0 && testCmd === "") {
|
|
5161
|
+
console.log(
|
|
5162
|
+
"No test command detected; running without a test gate. Pass --test-cmd to add one."
|
|
5163
|
+
);
|
|
5164
|
+
}
|
|
5165
|
+
const installCmd = argv.installCmd ?? (isolated ? detectInstallCmd(getRepoRoot()) ?? void 0 : void 0);
|
|
3398
5166
|
const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
|
|
3399
5167
|
const logsDir = firstLogsDir;
|
|
3400
5168
|
const fallbackSpec = specBodies.values().next().value;
|
|
3401
5169
|
if (argv.dryRun) {
|
|
3402
|
-
|
|
5170
|
+
console.log(
|
|
5171
|
+
`Provider: ${providerPath ?? providerMissing}`
|
|
5172
|
+
);
|
|
5173
|
+
if (isolated) {
|
|
3403
5174
|
const repoName = getRepoName();
|
|
3404
5175
|
dryRunIsolated({
|
|
3405
5176
|
tickets: allTickets,
|
|
@@ -3408,11 +5179,20 @@ async function runExecute(argv) {
|
|
|
3408
5179
|
conflictStrategy,
|
|
3409
5180
|
progressPath: progressPaths.values().next().value,
|
|
3410
5181
|
testCmd,
|
|
5182
|
+
installCmd,
|
|
3411
5183
|
effort: argv.effort,
|
|
3412
5184
|
retries: argv.retries,
|
|
3413
5185
|
retryDelay: argv.retryDelay,
|
|
3414
5186
|
concurrency: argv.concurrency
|
|
3415
5187
|
});
|
|
5188
|
+
console.log(
|
|
5189
|
+
describeIntegrationBranch(
|
|
5190
|
+
getRepoRoot(),
|
|
5191
|
+
featureNames
|
|
5192
|
+
)
|
|
5193
|
+
);
|
|
5194
|
+
const reviewState = argv.review === false ? "off" : "on";
|
|
5195
|
+
console.log(`Review: ${reviewState}`);
|
|
3416
5196
|
} else {
|
|
3417
5197
|
dryRun(allTickets, fallbackSpec, testCmd);
|
|
3418
5198
|
}
|
|
@@ -3428,12 +5208,12 @@ async function runExecute(argv) {
|
|
|
3428
5208
|
process.on("SIGINT", () => onSignal(130));
|
|
3429
5209
|
process.on("SIGTERM", () => onSignal(143));
|
|
3430
5210
|
let result;
|
|
3431
|
-
if (
|
|
5211
|
+
if (isolated) {
|
|
3432
5212
|
result = await runWavesIsolated({
|
|
3433
5213
|
tickets: allTickets,
|
|
3434
5214
|
specBody: fallbackSpec,
|
|
3435
5215
|
specBodies,
|
|
3436
|
-
agentCmd
|
|
5216
|
+
agentCmd,
|
|
3437
5217
|
model: argv.model ?? null,
|
|
3438
5218
|
timeout,
|
|
3439
5219
|
logsDir,
|
|
@@ -3441,18 +5221,23 @@ async function runExecute(argv) {
|
|
|
3441
5221
|
conflictStrategy,
|
|
3442
5222
|
display,
|
|
3443
5223
|
testCmd,
|
|
5224
|
+
installCmd,
|
|
3444
5225
|
maxTurns: argv.maxTurns,
|
|
3445
5226
|
effort: argv.effort,
|
|
3446
5227
|
retries: argv.retries,
|
|
3447
5228
|
retryDelay: argv.retryDelay,
|
|
3448
5229
|
concurrency: argv.concurrency,
|
|
3449
|
-
progressPaths
|
|
5230
|
+
progressPaths,
|
|
5231
|
+
review: argv.review === false ? void 0 : {
|
|
5232
|
+
reviewPath: reviewPathFor(loaded.issuesDirs),
|
|
5233
|
+
forkedFrom: plan?.forkedFrom ?? void 0
|
|
5234
|
+
}
|
|
3450
5235
|
});
|
|
3451
5236
|
} else {
|
|
3452
5237
|
result = await runWaves(
|
|
3453
5238
|
allTickets,
|
|
3454
5239
|
fallbackSpec,
|
|
3455
|
-
|
|
5240
|
+
agentCmd,
|
|
3456
5241
|
argv.model ?? null,
|
|
3457
5242
|
timeout,
|
|
3458
5243
|
logsDir,
|