nightralph 0.0.53 → 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 +82 -9
- package/dist/index.js +1947 -230
- package/dist/index.js.map +4 -4
- package/dist/learnings.js +356 -0
- package/dist/learnings.js.map +7 -0
- package/dist/meta.json +232 -12
- package/dist/orchestrator.js +442 -109
- 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/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 +14 -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 +4 -3
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;
|
|
@@ -1141,8 +1336,20 @@ function detectTestCmd(projectDir) {
|
|
|
1141
1336
|
}
|
|
1142
1337
|
}
|
|
1143
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");
|
|
1144
1351
|
function runTestCmd(opts) {
|
|
1145
|
-
return new Promise((
|
|
1352
|
+
return new Promise((resolve3) => {
|
|
1146
1353
|
let settled = false;
|
|
1147
1354
|
const child = spawn("sh", ["-c", opts.cmd], {
|
|
1148
1355
|
cwd: opts.cwd,
|
|
@@ -1174,7 +1381,7 @@ function runTestCmd(opts) {
|
|
|
1174
1381
|
opts.registry.delete(child.pid);
|
|
1175
1382
|
}
|
|
1176
1383
|
chunks.push(String(err));
|
|
1177
|
-
|
|
1384
|
+
resolve3({
|
|
1178
1385
|
exitCode: 1,
|
|
1179
1386
|
output: chunks.join("")
|
|
1180
1387
|
});
|
|
@@ -1186,7 +1393,7 @@ function runTestCmd(opts) {
|
|
|
1186
1393
|
if (child.pid !== void 0 && opts.registry) {
|
|
1187
1394
|
opts.registry.delete(child.pid);
|
|
1188
1395
|
}
|
|
1189
|
-
|
|
1396
|
+
resolve3({
|
|
1190
1397
|
exitCode: code ?? 1,
|
|
1191
1398
|
output: chunks.join("")
|
|
1192
1399
|
});
|
|
@@ -1195,6 +1402,1061 @@ function runTestCmd(opts) {
|
|
|
1195
1402
|
}
|
|
1196
1403
|
__name(runTestCmd, "runTestCmd");
|
|
1197
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
|
+
|
|
1198
2460
|
// src/orchestrator.ts
|
|
1199
2461
|
var execFileAsync4 = promisify4(execFile4);
|
|
1200
2462
|
function formatErrorMessage(error) {
|
|
@@ -1287,8 +2549,8 @@ function scanTickets(dir, feature = "") {
|
|
|
1287
2549
|
));
|
|
1288
2550
|
return files.map((filename) => {
|
|
1289
2551
|
const parsed = parseTicketFilename(filename);
|
|
1290
|
-
const filepath =
|
|
1291
|
-
const body =
|
|
2552
|
+
const filepath = join6(dir, filename);
|
|
2553
|
+
const body = readFileSync6(filepath, "utf8");
|
|
1292
2554
|
return {
|
|
1293
2555
|
num: parsed.num,
|
|
1294
2556
|
slug: parsed.slug,
|
|
@@ -1399,7 +2661,7 @@ function checkItems(ticket, completedItems) {
|
|
|
1399
2661
|
if (completedItems.length === 0) return;
|
|
1400
2662
|
let body;
|
|
1401
2663
|
try {
|
|
1402
|
-
body =
|
|
2664
|
+
body = readFileSync6(ticket.filepath, "utf8");
|
|
1403
2665
|
} catch {
|
|
1404
2666
|
body = ticket.body;
|
|
1405
2667
|
}
|
|
@@ -1423,7 +2685,7 @@ function checkItems(ticket, completedItems) {
|
|
|
1423
2685
|
}
|
|
1424
2686
|
if (!changed) return;
|
|
1425
2687
|
body = lines.join("\n");
|
|
1426
|
-
|
|
2688
|
+
writeFileSync5(ticket.filepath, body);
|
|
1427
2689
|
ticket.body = body;
|
|
1428
2690
|
}
|
|
1429
2691
|
__name(checkItems, "checkItems");
|
|
@@ -1447,7 +2709,7 @@ __name(checkAllItems, "checkAllItems");
|
|
|
1447
2709
|
function markDone(ticket) {
|
|
1448
2710
|
let body;
|
|
1449
2711
|
try {
|
|
1450
|
-
body =
|
|
2712
|
+
body = readFileSync6(ticket.filepath, "utf8");
|
|
1451
2713
|
} catch {
|
|
1452
2714
|
body = ticket.body;
|
|
1453
2715
|
}
|
|
@@ -1456,7 +2718,7 @@ function markDone(ticket) {
|
|
|
1456
2718
|
/\*\*Status:\*\*\s*.+/i,
|
|
1457
2719
|
"**Status:** done"
|
|
1458
2720
|
);
|
|
1459
|
-
|
|
2721
|
+
writeFileSync5(ticket.filepath, updated);
|
|
1460
2722
|
ticket.status = "done";
|
|
1461
2723
|
ticket.body = updated;
|
|
1462
2724
|
}
|
|
@@ -1489,11 +2751,23 @@ function stripSpecSection(specBody, heading) {
|
|
|
1489
2751
|
return out.join("\n");
|
|
1490
2752
|
}
|
|
1491
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");
|
|
1492
2761
|
function lookupSpec(specBodies, fallback, feature) {
|
|
1493
2762
|
if (!feature || !specBodies) return fallback;
|
|
1494
2763
|
return specBodies.get(feature) ?? fallback;
|
|
1495
2764
|
}
|
|
1496
2765
|
__name(lookupSpec, "lookupSpec");
|
|
2766
|
+
function historyBlock(history) {
|
|
2767
|
+
if (!history) return [];
|
|
2768
|
+
return [history, ""];
|
|
2769
|
+
}
|
|
2770
|
+
__name(historyBlock, "historyBlock");
|
|
1497
2771
|
function renderPrompt(specBody, ticket, opts) {
|
|
1498
2772
|
return [
|
|
1499
2773
|
"---- SPEC CONTEXT ----",
|
|
@@ -1503,27 +2777,29 @@ function renderPrompt(specBody, ticket, opts) {
|
|
|
1503
2777
|
ticket.filename,
|
|
1504
2778
|
ticket.body,
|
|
1505
2779
|
"",
|
|
2780
|
+
...historyBlock(opts.history),
|
|
1506
2781
|
"---- AGENT INSTRUCTIONS ----",
|
|
1507
2782
|
"You are an autonomous coding agent. Implement the ticket using /tdd.",
|
|
1508
2783
|
"",
|
|
1509
2784
|
"Follow these rules:",
|
|
1510
|
-
"
|
|
2785
|
+
readDocsRule("ticket"),
|
|
1511
2786
|
"- Work ONLY on this ticket. Do not fix unrelated problems you notice.",
|
|
1512
2787
|
"- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
"
|
|
1518
|
-
|
|
1519
|
-
"
|
|
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"),
|
|
1520
2796
|
"- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
|
|
1521
|
-
"
|
|
2797
|
+
agentsMdRule("ticket"),
|
|
1522
2798
|
""
|
|
1523
2799
|
].join("\n");
|
|
1524
2800
|
}
|
|
1525
2801
|
__name(renderPrompt, "renderPrompt");
|
|
1526
|
-
function renderMergePrompt(specBody, ticket) {
|
|
2802
|
+
function renderMergePrompt(specBody, ticket, opts = {}) {
|
|
1527
2803
|
return [
|
|
1528
2804
|
"---- SPEC CONTEXT ----",
|
|
1529
2805
|
specBody,
|
|
@@ -1544,6 +2820,7 @@ function renderMergePrompt(specBody, ticket) {
|
|
|
1544
2820
|
"4. Commit the resolved files.",
|
|
1545
2821
|
"",
|
|
1546
2822
|
"Do NOT re-implement the ticket from scratch.",
|
|
2823
|
+
...installRule(opts.installCmd),
|
|
1547
2824
|
"Do NOT read or modify files outside your current directory (the worktree).",
|
|
1548
2825
|
"Do NOT run git push."
|
|
1549
2826
|
].join("\n");
|
|
@@ -1640,59 +2917,23 @@ var PROVIDER_ARGS = {
|
|
|
1640
2917
|
],
|
|
1641
2918
|
// codex removed --full-auto; this is the counterpart
|
|
1642
2919
|
// of claude's --dangerously-skip-permissions, and the
|
|
1643
|
-
// workspace-write sandbox would block commits because
|
|
1644
|
-
// a worktree's .git link points outside the tree
|
|
1645
|
-
codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
|
|
1646
|
-
pi: [
|
|
1647
|
-
"-p",
|
|
1648
|
-
"--verbose",
|
|
1649
|
-
"--approve",
|
|
1650
|
-
"--no-session",
|
|
1651
|
-
"--mode",
|
|
1652
|
-
"json"
|
|
1653
|
-
]
|
|
1654
|
-
};
|
|
1655
|
-
function getProviderArgs(cmd) {
|
|
1656
|
-
const name = basename2(cmd);
|
|
1657
|
-
return PROVIDER_ARGS[name] ?? [];
|
|
1658
|
-
}
|
|
1659
|
-
__name(getProviderArgs, "getProviderArgs");
|
|
1660
|
-
var EFFORT_LADDERS = {
|
|
1661
|
-
pi: [
|
|
1662
|
-
"off",
|
|
1663
|
-
"minimal",
|
|
1664
|
-
"low",
|
|
1665
|
-
"medium",
|
|
1666
|
-
"high",
|
|
1667
|
-
"xhigh",
|
|
1668
|
-
"max"
|
|
1669
|
-
],
|
|
1670
|
-
claude: [
|
|
1671
|
-
"low",
|
|
1672
|
-
"medium",
|
|
1673
|
-
"high",
|
|
1674
|
-
"xhigh",
|
|
1675
|
-
"max"
|
|
1676
|
-
],
|
|
1677
|
-
codex: ["low", "medium", "high"]
|
|
2920
|
+
// workspace-write sandbox would block commits because
|
|
2921
|
+
// a worktree's .git link points outside the tree
|
|
2922
|
+
codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
|
|
2923
|
+
pi: [
|
|
2924
|
+
"-p",
|
|
2925
|
+
"--verbose",
|
|
2926
|
+
"--approve",
|
|
2927
|
+
"--no-session",
|
|
2928
|
+
"--mode",
|
|
2929
|
+
"json"
|
|
2930
|
+
]
|
|
1678
2931
|
};
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
"low",
|
|
1683
|
-
"medium",
|
|
1684
|
-
"high",
|
|
1685
|
-
"xhigh",
|
|
1686
|
-
"max"
|
|
1687
|
-
];
|
|
1688
|
-
function escalateEffort(provider, level) {
|
|
1689
|
-
if (!level) return "high";
|
|
1690
|
-
const ladder = EFFORT_LADDERS[provider];
|
|
1691
|
-
const i = ladder.indexOf(level);
|
|
1692
|
-
if (i === -1) return level;
|
|
1693
|
-
return ladder[Math.min(i + 1, ladder.length - 1)];
|
|
2932
|
+
function getProviderArgs(cmd) {
|
|
2933
|
+
const name = basename5(cmd);
|
|
2934
|
+
return PROVIDER_ARGS[name] ?? [];
|
|
1694
2935
|
}
|
|
1695
|
-
__name(
|
|
2936
|
+
__name(getProviderArgs, "getProviderArgs");
|
|
1696
2937
|
function formatStreamLine(line) {
|
|
1697
2938
|
let obj;
|
|
1698
2939
|
try {
|
|
@@ -1793,8 +3034,8 @@ function createPiStreamFormatter() {
|
|
|
1793
3034
|
__name(createPiStreamFormatter, "createPiStreamFormatter");
|
|
1794
3035
|
function writePromptFile(prompt) {
|
|
1795
3036
|
const name = `nightralph-${Date.now()}-${Math.random().toString(36).slice(2)}.md`;
|
|
1796
|
-
const filePath =
|
|
1797
|
-
|
|
3037
|
+
const filePath = join6(tmpdir(), name);
|
|
3038
|
+
writeFileSync5(filePath, prompt);
|
|
1798
3039
|
return filePath;
|
|
1799
3040
|
}
|
|
1800
3041
|
__name(writePromptFile, "writePromptFile");
|
|
@@ -1835,7 +3076,7 @@ function spawnAgent(opts) {
|
|
|
1835
3076
|
if (opts.model) {
|
|
1836
3077
|
args.push("--model", opts.model);
|
|
1837
3078
|
}
|
|
1838
|
-
const providerName =
|
|
3079
|
+
const providerName = basename5(opts.agentCmd);
|
|
1839
3080
|
const isClaude = providerName === "claude";
|
|
1840
3081
|
const isPi = providerName === "pi";
|
|
1841
3082
|
if (opts.maxTurns) {
|
|
@@ -1969,7 +3210,7 @@ function spawnAgent(opts) {
|
|
|
1969
3210
|
proc.stdin.write(opts.prompt);
|
|
1970
3211
|
}
|
|
1971
3212
|
proc.stdin.end();
|
|
1972
|
-
return new Promise((
|
|
3213
|
+
return new Promise((resolve3) => {
|
|
1973
3214
|
let settled = false;
|
|
1974
3215
|
let graceTimer;
|
|
1975
3216
|
function finish(exitCode) {
|
|
@@ -1995,7 +3236,7 @@ function spawnAgent(opts) {
|
|
|
1995
3236
|
rlErr.close();
|
|
1996
3237
|
logStream.end();
|
|
1997
3238
|
if (promptFile) cleanupPromptFile(promptFile);
|
|
1998
|
-
|
|
3239
|
+
resolve3({ exitCode, completedItems, timedOut });
|
|
1999
3240
|
}
|
|
2000
3241
|
__name(finish, "finish");
|
|
2001
3242
|
proc.on("exit", (code) => {
|
|
@@ -2019,16 +3260,21 @@ function spawnAgent(opts) {
|
|
|
2019
3260
|
}
|
|
2020
3261
|
rl.close();
|
|
2021
3262
|
rlErr.close();
|
|
2022
|
-
|
|
3263
|
+
const msg = "agent process error: " + formatErrorMessage(err);
|
|
3264
|
+
logStream.write(`nightralph: ${msg}
|
|
3265
|
+
`);
|
|
2023
3266
|
if (promptFile) cleanupPromptFile(promptFile);
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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
|
+
});
|
|
2032
3278
|
});
|
|
2033
3279
|
});
|
|
2034
3280
|
});
|
|
@@ -2048,6 +3294,8 @@ function dryRun(tickets, specBody, testCmd) {
|
|
|
2048
3294
|
"\nDeadlocked tickets: " + stuck.map((t) => t.filename).join(", ")
|
|
2049
3295
|
);
|
|
2050
3296
|
}
|
|
3297
|
+
console.log(`
|
|
3298
|
+
Test command: ${testCmd || "(none)"}`);
|
|
2051
3299
|
const firstReady = findReadyTickets(tickets);
|
|
2052
3300
|
if (firstReady.length > 0) {
|
|
2053
3301
|
console.log(
|
|
@@ -2103,14 +3351,20 @@ Prompt for first ready ticket (${firstReady[0].filename}):
|
|
|
2103
3351
|
renderPrompt(
|
|
2104
3352
|
opts.specBody,
|
|
2105
3353
|
firstReady[0],
|
|
2106
|
-
{
|
|
3354
|
+
{
|
|
3355
|
+
testCmd: opts.testCmd,
|
|
3356
|
+
installCmd: opts.installCmd
|
|
3357
|
+
}
|
|
2107
3358
|
)
|
|
2108
3359
|
);
|
|
2109
3360
|
}
|
|
2110
3361
|
const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
|
|
2111
3362
|
console.log(
|
|
2112
3363
|
`
|
|
2113
|
-
Test command: ${opts.testCmd}`
|
|
3364
|
+
Test command: ${opts.testCmd || "(none)"}`
|
|
3365
|
+
);
|
|
3366
|
+
console.log(
|
|
3367
|
+
`Install: ${opts.installCmd || "(none)"}`
|
|
2114
3368
|
);
|
|
2115
3369
|
console.log(
|
|
2116
3370
|
`Effort: ${opts.effort ?? "(provider default)"}`
|
|
@@ -2131,7 +3385,34 @@ Merge strategy: ${strategyDesc}`
|
|
|
2131
3385
|
console.log(`Progress file: ${opts.progressPath}`);
|
|
2132
3386
|
}
|
|
2133
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");
|
|
2134
3411
|
async function runTestGate(opts) {
|
|
3412
|
+
if (opts.testCmd === "") {
|
|
3413
|
+
opts.log(` ${opts.ticket.filename}: no test gate`);
|
|
3414
|
+
return true;
|
|
3415
|
+
}
|
|
2135
3416
|
const result = await runTestCmd({
|
|
2136
3417
|
cmd: opts.testCmd,
|
|
2137
3418
|
cwd: opts.worktreePath,
|
|
@@ -2144,7 +3425,7 @@ async function runTestGate(opts) {
|
|
|
2144
3425
|
opts.attempt ?? 1,
|
|
2145
3426
|
".test.log"
|
|
2146
3427
|
);
|
|
2147
|
-
|
|
3428
|
+
writeFileSync5(testLogPath, result.output);
|
|
2148
3429
|
if (result.exitCode !== 0) {
|
|
2149
3430
|
opts.log(
|
|
2150
3431
|
` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
|
|
@@ -2173,11 +3454,24 @@ async function respawnAndRetryMerge(opts) {
|
|
|
2173
3454
|
} else {
|
|
2174
3455
|
console.log(msg1);
|
|
2175
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
|
+
}
|
|
2176
3469
|
const prompt = renderMergePrompt(
|
|
2177
3470
|
opts.specBody,
|
|
2178
|
-
opts.ticket
|
|
3471
|
+
opts.ticket,
|
|
3472
|
+
{ installCmd: opts.installCmd }
|
|
2179
3473
|
);
|
|
2180
|
-
const
|
|
3474
|
+
const logPath2 = join6(
|
|
2181
3475
|
opts.logsDir,
|
|
2182
3476
|
opts.ticket.filename.replace(/\.md$/, ".respawn.log")
|
|
2183
3477
|
);
|
|
@@ -2190,7 +3484,7 @@ async function respawnAndRetryMerge(opts) {
|
|
|
2190
3484
|
agentCmd: opts.agentCmd,
|
|
2191
3485
|
model: opts.model,
|
|
2192
3486
|
timeout: opts.timeout,
|
|
2193
|
-
logPath,
|
|
3487
|
+
logPath: logPath2,
|
|
2194
3488
|
cwd: opts.worktreePath,
|
|
2195
3489
|
label,
|
|
2196
3490
|
maxTurns: opts.maxTurns,
|
|
@@ -2274,20 +3568,31 @@ async function runPool(items, limit, fn) {
|
|
|
2274
3568
|
return results;
|
|
2275
3569
|
}
|
|
2276
3570
|
__name(runPool, "runPool");
|
|
2277
|
-
function retryDelayFor(base, attempt) {
|
|
2278
|
-
return base * 2 ** (attempt - 2);
|
|
2279
|
-
}
|
|
2280
|
-
__name(retryDelayFor, "retryDelayFor");
|
|
2281
|
-
function sleep(ms) {
|
|
2282
|
-
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
2283
|
-
}
|
|
2284
|
-
__name(sleep, "sleep");
|
|
2285
3571
|
function attemptLogPath(logsDir, ticket, attempt, ext) {
|
|
2286
3572
|
const stem = ticket.filename.replace(/\.md$/, "");
|
|
2287
3573
|
const suffix = attempt > 1 ? `.attempt${attempt}` : "";
|
|
2288
|
-
return
|
|
3574
|
+
return join6(logsDir, `${stem}${suffix}${ext}`);
|
|
2289
3575
|
}
|
|
2290
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");
|
|
2291
3596
|
async function runTicketAttempt(opts) {
|
|
2292
3597
|
const { ticket, d } = opts;
|
|
2293
3598
|
let worktree;
|
|
@@ -2306,6 +3611,38 @@ async function runTicketAttempt(opts) {
|
|
|
2306
3611
|
"in-progress",
|
|
2307
3612
|
ticket.feature
|
|
2308
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
|
+
}
|
|
2309
3646
|
const ticketSpec = lookupSpec(
|
|
2310
3647
|
opts.specBodies,
|
|
2311
3648
|
opts.specBody,
|
|
@@ -2315,7 +3652,17 @@ async function runTicketAttempt(opts) {
|
|
|
2315
3652
|
prompt: renderPrompt(
|
|
2316
3653
|
ticketSpec,
|
|
2317
3654
|
ticket,
|
|
2318
|
-
{
|
|
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
|
+
}
|
|
2319
3666
|
),
|
|
2320
3667
|
agentCmd: opts.agentCmd,
|
|
2321
3668
|
model: opts.model,
|
|
@@ -2332,6 +3679,20 @@ async function runTicketAttempt(opts) {
|
|
|
2332
3679
|
effort: opts.effort,
|
|
2333
3680
|
onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
|
|
2334
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
|
+
};
|
|
2335
3696
|
if (agentResult.timedOut) {
|
|
2336
3697
|
d.log(
|
|
2337
3698
|
` ${ticket.filename}: timed out; keeping the work it committed`
|
|
@@ -2346,7 +3707,8 @@ async function runTicketAttempt(opts) {
|
|
|
2346
3707
|
reason: "exit",
|
|
2347
3708
|
branchName: worktree.branchName,
|
|
2348
3709
|
exitCode: agentResult.exitCode,
|
|
2349
|
-
completedItems: agentResult.completedItems
|
|
3710
|
+
completedItems: agentResult.completedItems,
|
|
3711
|
+
agentRun
|
|
2350
3712
|
};
|
|
2351
3713
|
}
|
|
2352
3714
|
if (isDirty(worktree.worktreePath)) {
|
|
@@ -2369,7 +3731,8 @@ async function runTicketAttempt(opts) {
|
|
|
2369
3731
|
return {
|
|
2370
3732
|
kind: "no-commits",
|
|
2371
3733
|
worktreePath: worktree.worktreePath,
|
|
2372
|
-
branchName: worktree.branchName
|
|
3734
|
+
branchName: worktree.branchName,
|
|
3735
|
+
agentRun
|
|
2373
3736
|
};
|
|
2374
3737
|
}
|
|
2375
3738
|
const passed = await runTestGate({
|
|
@@ -2392,44 +3755,130 @@ async function runTicketAttempt(opts) {
|
|
|
2392
3755
|
reason: "tests",
|
|
2393
3756
|
branchName: worktree.branchName,
|
|
2394
3757
|
exitCode: 1,
|
|
2395
|
-
completedItems: agentResult.completedItems
|
|
3758
|
+
completedItems: agentResult.completedItems,
|
|
3759
|
+
agentRun
|
|
2396
3760
|
};
|
|
2397
3761
|
}
|
|
2398
3762
|
return {
|
|
2399
3763
|
kind: "ready",
|
|
2400
3764
|
worktreePath: worktree.worktreePath,
|
|
2401
3765
|
branchName: worktree.branchName,
|
|
2402
|
-
completedItems: agentResult.completedItems
|
|
3766
|
+
completedItems: agentResult.completedItems,
|
|
3767
|
+
agentRun
|
|
2403
3768
|
};
|
|
2404
3769
|
}
|
|
2405
3770
|
__name(runTicketAttempt, "runTicketAttempt");
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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()
|
|
2416
3786
|
};
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
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
|
+
}
|
|
2426
3826
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
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;
|
|
2432
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;
|
|
2433
3882
|
try {
|
|
2434
3883
|
outcome = await runTicketAttempt({
|
|
2435
3884
|
...opts,
|
|
@@ -2440,25 +3889,46 @@ async function runTicketAttempts(opts) {
|
|
|
2440
3889
|
d.log(
|
|
2441
3890
|
` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
|
|
2442
3891
|
);
|
|
2443
|
-
|
|
3892
|
+
const errorOutcome = { kind: "error", error };
|
|
3893
|
+
record(errorOutcome);
|
|
3894
|
+
return { result: errorOutcome, stop: true };
|
|
2444
3895
|
}
|
|
3896
|
+
record(outcome);
|
|
2445
3897
|
if (outcome.kind === "ready") {
|
|
2446
3898
|
d.setTicketStatus(
|
|
2447
3899
|
ticket.num,
|
|
2448
3900
|
"awaiting-merge",
|
|
2449
3901
|
ticket.feature
|
|
2450
3902
|
);
|
|
2451
|
-
return outcome;
|
|
3903
|
+
return { result: outcome, stop: true };
|
|
2452
3904
|
}
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
3905
|
+
return {
|
|
3906
|
+
result: outcome,
|
|
3907
|
+
stop: outcome.kind === "worktree-error"
|
|
3908
|
+
};
|
|
3909
|
+
});
|
|
2456
3910
|
}
|
|
2457
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");
|
|
2458
3926
|
async function saveProgress(repoRoot, progressPath, progress, message, d) {
|
|
2459
3927
|
writeProgress(progressPath, progress);
|
|
3928
|
+
const learningsPath = learningsPathFor(progressPath);
|
|
3929
|
+
const paths = isRegularFile(learningsPath) ? [progressPath, learningsPath] : progressPath;
|
|
2460
3930
|
try {
|
|
2461
|
-
await commitProgress(repoRoot,
|
|
3931
|
+
await commitProgress(repoRoot, paths, message);
|
|
2462
3932
|
} catch (err) {
|
|
2463
3933
|
d.log(
|
|
2464
3934
|
" Failed to commit progress: " + formatErrorMessage(err)
|
|
@@ -2559,12 +4029,12 @@ function runProgress(tickets) {
|
|
|
2559
4029
|
}
|
|
2560
4030
|
__name(runProgress, "runProgress");
|
|
2561
4031
|
async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, testCmd, display, maxTurns, effort, retries, retryDelay, concurrency) {
|
|
2562
|
-
|
|
4032
|
+
mkdirSync3(logsDir, { recursive: true });
|
|
2563
4033
|
const d = display ?? createDisplay();
|
|
2564
4034
|
const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
|
|
2565
4035
|
const delayBase = retryDelay ?? 0;
|
|
2566
4036
|
const poolSize = concurrency ?? DEFAULT_CONCURRENCY;
|
|
2567
|
-
const providerName =
|
|
4037
|
+
const providerName = basename5(agentCmd);
|
|
2568
4038
|
const isKnown = providerName in EFFORT_LADDERS;
|
|
2569
4039
|
let totalWaves = 0;
|
|
2570
4040
|
for (const _ of simulateWaves(tickets)) totalWaves++;
|
|
@@ -2719,7 +4189,7 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
|
|
|
2719
4189
|
}
|
|
2720
4190
|
__name(runWaves, "runWaves");
|
|
2721
4191
|
async function runWavesIsolated(opts) {
|
|
2722
|
-
|
|
4192
|
+
mkdirSync3(opts.logsDir, { recursive: true });
|
|
2723
4193
|
const d = opts.display ?? createDisplay();
|
|
2724
4194
|
const retries = opts.retries ?? DEFAULT_RETRIES;
|
|
2725
4195
|
const retryDelay = opts.retryDelay ?? 0;
|
|
@@ -2734,12 +4204,13 @@ async function runWavesIsolated(opts) {
|
|
|
2734
4204
|
"Working tree is dirty; commit or stash changes before running"
|
|
2735
4205
|
);
|
|
2736
4206
|
}
|
|
4207
|
+
const launchCommit = headCommit(repoRoot);
|
|
2737
4208
|
const features = new Set(
|
|
2738
4209
|
opts.tickets.map((t) => t.feature).filter(Boolean)
|
|
2739
4210
|
);
|
|
2740
4211
|
const multiFeature = features.size > 1 && opts.progressPaths !== void 0;
|
|
2741
|
-
const fallbackProgressPath =
|
|
2742
|
-
|
|
4212
|
+
const fallbackProgressPath = join6(
|
|
4213
|
+
dirname4(opts.logsDir),
|
|
2743
4214
|
"progress.md"
|
|
2744
4215
|
);
|
|
2745
4216
|
let tracker;
|
|
@@ -2837,13 +4308,34 @@ async function runWavesIsolated(opts) {
|
|
|
2837
4308
|
timeout: opts.timeout,
|
|
2838
4309
|
logsDir: opts.logsDir,
|
|
2839
4310
|
testCmd: opts.testCmd,
|
|
4311
|
+
installCmd: opts.installCmd,
|
|
2840
4312
|
maxTurns: opts.maxTurns,
|
|
2841
4313
|
effort: opts.effort,
|
|
4314
|
+
learningsPath: learningsPathFor(
|
|
4315
|
+
tracker.pathFor(t.feature)
|
|
4316
|
+
),
|
|
2842
4317
|
retries,
|
|
2843
4318
|
retryDelay,
|
|
2844
4319
|
d
|
|
2845
4320
|
})
|
|
2846
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
|
+
}
|
|
2847
4339
|
const outcomes = settled.map(
|
|
2848
4340
|
(r) => r.status === "fulfilled" ? r.value : { kind: "error", error: r.reason }
|
|
2849
4341
|
);
|
|
@@ -2947,7 +4439,7 @@ async function runWavesIsolated(opts) {
|
|
|
2947
4439
|
repoRoot,
|
|
2948
4440
|
tracker.pathFor(ticket.feature),
|
|
2949
4441
|
tracker.dataFor(ticket.feature),
|
|
2950
|
-
`nightralph: ${ticket.filename} ` + (outcome.reason
|
|
4442
|
+
`nightralph: ${ticket.filename} ` + failureNote(outcome.reason),
|
|
2951
4443
|
d
|
|
2952
4444
|
);
|
|
2953
4445
|
continue;
|
|
@@ -2983,6 +4475,7 @@ async function runWavesIsolated(opts) {
|
|
|
2983
4475
|
branches: successfulBranches,
|
|
2984
4476
|
strategy: opts.conflictStrategy
|
|
2985
4477
|
});
|
|
4478
|
+
const doneThisWave = [];
|
|
2986
4479
|
for (const branchResult of mergeResult.merged) {
|
|
2987
4480
|
const bk = branchToKey.get(
|
|
2988
4481
|
branchResult.branch
|
|
@@ -2991,6 +4484,7 @@ async function runWavesIsolated(opts) {
|
|
|
2991
4484
|
if (!ticket) continue;
|
|
2992
4485
|
if (branchResult.merged) {
|
|
2993
4486
|
markDone(ticket);
|
|
4487
|
+
doneThisWave.push(ticket);
|
|
2994
4488
|
waveCompleted++;
|
|
2995
4489
|
totalCompleted++;
|
|
2996
4490
|
d.setTicketStatus(
|
|
@@ -3040,11 +4534,13 @@ async function runWavesIsolated(opts) {
|
|
|
3040
4534
|
timeout: opts.timeout,
|
|
3041
4535
|
logsDir: opts.logsDir,
|
|
3042
4536
|
testCmd: opts.testCmd,
|
|
4537
|
+
installCmd: opts.installCmd,
|
|
3043
4538
|
maxTurns: opts.maxTurns,
|
|
3044
4539
|
onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
|
|
3045
4540
|
}) : false;
|
|
3046
4541
|
if (recovered) {
|
|
3047
4542
|
markDone(ticket);
|
|
4543
|
+
doneThisWave.push(ticket);
|
|
3048
4544
|
waveCompleted++;
|
|
3049
4545
|
totalCompleted++;
|
|
3050
4546
|
d.setTicketStatus(
|
|
@@ -3100,6 +4596,28 @@ async function runWavesIsolated(opts) {
|
|
|
3100
4596
|
exitCode: 0,
|
|
3101
4597
|
done: false
|
|
3102
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
|
+
}
|
|
3103
4621
|
await saveProgress(
|
|
3104
4622
|
repoRoot,
|
|
3105
4623
|
tracker.pathFor(ticket.feature),
|
|
@@ -3110,6 +4628,7 @@ async function runWavesIsolated(opts) {
|
|
|
3110
4628
|
}
|
|
3111
4629
|
}
|
|
3112
4630
|
}
|
|
4631
|
+
await commitTicketFiles(repoRoot, doneThisWave, d);
|
|
3113
4632
|
if (mergeResult.stoppedAt) {
|
|
3114
4633
|
d.log(
|
|
3115
4634
|
`
|
|
@@ -3152,6 +4671,41 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
|
|
|
3152
4671
|
}
|
|
3153
4672
|
}
|
|
3154
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)
|
|
4703
|
+
);
|
|
4704
|
+
} catch (err) {
|
|
4705
|
+
d.log(`
|
|
4706
|
+
Review failed: ${formatErrorMessage(err)}`);
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
3155
4709
|
const remaining = opts.tickets.filter(
|
|
3156
4710
|
(t) => t.status === "ready-for-agent"
|
|
3157
4711
|
);
|
|
@@ -3160,23 +4714,55 @@ No tickets completed this wave. ${remaining2.length} ready ticket${remaining2.le
|
|
|
3160
4714
|
totalCompleted,
|
|
3161
4715
|
failedKeys
|
|
3162
4716
|
));
|
|
4717
|
+
if (reviewLine) d.log(reviewLine);
|
|
4718
|
+
d.log(`Work is on ${baseBranch}`);
|
|
3163
4719
|
d.cleanup();
|
|
3164
|
-
return { allDone: remaining.length === 0 };
|
|
4720
|
+
return { allDone: remaining.length === 0, branch: baseBranch };
|
|
3165
4721
|
}
|
|
3166
4722
|
__name(runWavesIsolated, "runWavesIsolated");
|
|
3167
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
|
+
|
|
3168
4754
|
// src/resolve.ts
|
|
3169
|
-
import { existsSync as
|
|
3170
|
-
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";
|
|
3171
4757
|
import { fileURLToPath } from "node:url";
|
|
3172
4758
|
import { checkbox, Separator } from "@inquirer/prompts";
|
|
3173
4759
|
function getScriptDir(metaUrl) {
|
|
3174
|
-
return
|
|
4760
|
+
return dirname5(fileURLToPath(metaUrl));
|
|
3175
4761
|
}
|
|
3176
4762
|
__name(getScriptDir, "getScriptDir");
|
|
3177
4763
|
function findFeatureDirs(cwd) {
|
|
3178
|
-
const scratchDir =
|
|
3179
|
-
if (!
|
|
4764
|
+
const scratchDir = join8(cwd, ".scratch");
|
|
4765
|
+
if (!existsSync6(scratchDir)) return [];
|
|
3180
4766
|
const entries = readdirSync3(
|
|
3181
4767
|
scratchDir,
|
|
3182
4768
|
{ withFileTypes: true }
|
|
@@ -3184,18 +4770,18 @@ function findFeatureDirs(cwd) {
|
|
|
3184
4770
|
const results = [];
|
|
3185
4771
|
for (const entry of entries) {
|
|
3186
4772
|
if (!entry.isDirectory()) continue;
|
|
3187
|
-
const issuesDir =
|
|
4773
|
+
const issuesDir = join8(
|
|
3188
4774
|
scratchDir,
|
|
3189
4775
|
entry.name,
|
|
3190
4776
|
"issues"
|
|
3191
4777
|
);
|
|
3192
|
-
const specFile =
|
|
4778
|
+
const specFile = join8(
|
|
3193
4779
|
scratchDir,
|
|
3194
4780
|
entry.name,
|
|
3195
4781
|
"spec.md"
|
|
3196
4782
|
);
|
|
3197
|
-
if (!
|
|
3198
|
-
const progressFile =
|
|
4783
|
+
if (!existsSync6(issuesDir)) continue;
|
|
4784
|
+
const progressFile = join8(
|
|
3199
4785
|
scratchDir,
|
|
3200
4786
|
entry.name,
|
|
3201
4787
|
"progress.md"
|
|
@@ -3214,10 +4800,10 @@ __name(findFeatureDirs, "findFeatureDirs");
|
|
|
3214
4800
|
var ALL_VALUE = "__all__";
|
|
3215
4801
|
async function resolveIssuesDir(specName, cwd = process.cwd()) {
|
|
3216
4802
|
if (specName) {
|
|
3217
|
-
const featureDir =
|
|
4803
|
+
const featureDir = join8(cwd, ".scratch", specName);
|
|
3218
4804
|
return [{
|
|
3219
|
-
dir:
|
|
3220
|
-
spec:
|
|
4805
|
+
dir: join8(featureDir, "issues"),
|
|
4806
|
+
spec: join8(featureDir, "spec.md"),
|
|
3221
4807
|
name: specName
|
|
3222
4808
|
}];
|
|
3223
4809
|
}
|
|
@@ -3296,8 +4882,8 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3296
4882
|
}
|
|
3297
4883
|
}
|
|
3298
4884
|
setup({
|
|
3299
|
-
bundledSkillsDir:
|
|
3300
|
-
docsTemplatesDir:
|
|
4885
|
+
bundledSkillsDir: join9(scriptDir, "skills"),
|
|
4886
|
+
docsTemplatesDir: join9(
|
|
3301
4887
|
scriptDir,
|
|
3302
4888
|
"docs-templates"
|
|
3303
4889
|
),
|
|
@@ -3332,7 +4918,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3332
4918
|
default: 3600
|
|
3333
4919
|
}).option("test-cmd", {
|
|
3334
4920
|
type: "string",
|
|
3335
|
-
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.`
|
|
3336
4925
|
}).option("X", {
|
|
3337
4926
|
type: "boolean",
|
|
3338
4927
|
describe: "Stop on merge conflict instead of re-spawning",
|
|
@@ -3356,6 +4945,10 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3356
4945
|
type: "number",
|
|
3357
4946
|
default: DEFAULT_CONCURRENCY,
|
|
3358
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."
|
|
3359
4952
|
}),
|
|
3360
4953
|
async (argv) => {
|
|
3361
4954
|
await runExecute({
|
|
@@ -3365,15 +4958,26 @@ yargs(hideBin(process.argv)).scriptName("nightralph").usage("$0 <command>").comm
|
|
|
3365
4958
|
dryRun: argv.dryRun,
|
|
3366
4959
|
timeout: argv.timeout,
|
|
3367
4960
|
testCmd: argv.testCmd,
|
|
4961
|
+
installCmd: argv.installCmd,
|
|
3368
4962
|
stopOnConflict: argv.X,
|
|
3369
4963
|
maxTurns: argv.maxTurns ?? argv.turns,
|
|
3370
4964
|
effort: argv.effort,
|
|
3371
4965
|
retries: argv.retries,
|
|
3372
4966
|
retryDelay: argv.retryDelay,
|
|
3373
|
-
concurrency: argv.concurrency
|
|
4967
|
+
concurrency: argv.concurrency,
|
|
4968
|
+
review: argv.review
|
|
3374
4969
|
});
|
|
3375
4970
|
}
|
|
3376
|
-
).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();
|
|
3377
4981
|
function isGitRepo() {
|
|
3378
4982
|
try {
|
|
3379
4983
|
getRepoRoot();
|
|
@@ -3394,41 +4998,36 @@ function extractFeatureName(spec) {
|
|
|
3394
4998
|
return parts[parts.length - 2] ?? parts[parts.length - 1] ?? "feature";
|
|
3395
4999
|
}
|
|
3396
5000
|
__name(extractFeatureName, "extractFeatureName");
|
|
3397
|
-
|
|
3398
|
-
const selections = await resolveIssuesDir(
|
|
3399
|
-
argv.spec
|
|
3400
|
-
);
|
|
5001
|
+
function loadTickets(selections, log) {
|
|
3401
5002
|
const allTickets = [];
|
|
3402
5003
|
const specBodies = /* @__PURE__ */ new Map();
|
|
3403
5004
|
const progressPaths = /* @__PURE__ */ new Map();
|
|
5005
|
+
const issuesDirs = [];
|
|
3404
5006
|
let firstFeatureName = "";
|
|
3405
5007
|
let firstLogsDir = "";
|
|
5008
|
+
const missing = [];
|
|
3406
5009
|
for (const selection of selections) {
|
|
3407
5010
|
const { dir, spec, name } = selection;
|
|
3408
|
-
if (!
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
);
|
|
3412
|
-
process.exit(2);
|
|
5011
|
+
if (!existsSync7(dir)) {
|
|
5012
|
+
missing.push(`Directory not found: ${dir}`);
|
|
5013
|
+
continue;
|
|
3413
5014
|
}
|
|
3414
|
-
if (!
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
);
|
|
3418
|
-
process.exit(2);
|
|
5015
|
+
if (!existsSync7(spec)) {
|
|
5016
|
+
missing.push(`Spec file not found: ${spec}`);
|
|
5017
|
+
continue;
|
|
3419
5018
|
}
|
|
3420
5019
|
const specBody = stripSpecSection(
|
|
3421
|
-
|
|
5020
|
+
readFileSync7(spec, "utf8"),
|
|
3422
5021
|
"User Stories"
|
|
3423
5022
|
);
|
|
3424
5023
|
const tickets = scanTickets(dir, name);
|
|
3425
5024
|
if (tickets.length === 0) {
|
|
3426
|
-
|
|
5025
|
+
log(
|
|
3427
5026
|
`No ticket files found for ${name}. Skipping.`
|
|
3428
5027
|
);
|
|
3429
5028
|
continue;
|
|
3430
5029
|
}
|
|
3431
|
-
|
|
5030
|
+
log(
|
|
3432
5031
|
`[${name}] Found ${tickets.length} tickets`
|
|
3433
5032
|
);
|
|
3434
5033
|
const knownNums = new Set(
|
|
@@ -3439,7 +5038,7 @@ async function runExecute(argv) {
|
|
|
3439
5038
|
(b) => !knownNums.has(b)
|
|
3440
5039
|
);
|
|
3441
5040
|
if (unknown.length > 0) {
|
|
3442
|
-
|
|
5041
|
+
log(
|
|
3443
5042
|
`${t.filename}: blockers [${unknown.join(", ")}] not found in directory`
|
|
3444
5043
|
);
|
|
3445
5044
|
}
|
|
@@ -3450,24 +5049,128 @@ async function runExecute(argv) {
|
|
|
3450
5049
|
specBodies.set(name, specBody);
|
|
3451
5050
|
progressPaths.set(
|
|
3452
5051
|
name,
|
|
3453
|
-
|
|
5052
|
+
join9(dirname6(dir), "progress.md")
|
|
3454
5053
|
);
|
|
5054
|
+
issuesDirs.push(dir);
|
|
3455
5055
|
allTickets.push(...tickets);
|
|
3456
5056
|
if (!firstFeatureName) {
|
|
3457
5057
|
firstFeatureName = extractFeatureName(spec);
|
|
3458
|
-
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
|
+
}
|
|
3459
5147
|
}
|
|
3460
5148
|
}
|
|
3461
|
-
|
|
5149
|
+
const {
|
|
5150
|
+
allTickets,
|
|
5151
|
+
specBodies,
|
|
5152
|
+
progressPaths,
|
|
5153
|
+
firstFeatureName,
|
|
5154
|
+
firstLogsDir
|
|
5155
|
+
} = loaded;
|
|
3462
5156
|
const timeout = argv.timeout ?? 900;
|
|
3463
|
-
const testCmd = argv.testCmd
|
|
3464
|
-
|
|
3465
|
-
)
|
|
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);
|
|
3466
5166
|
const conflictStrategy = argv.stopOnConflict ? "stop" : "respawn";
|
|
3467
5167
|
const logsDir = firstLogsDir;
|
|
3468
5168
|
const fallbackSpec = specBodies.values().next().value;
|
|
3469
5169
|
if (argv.dryRun) {
|
|
3470
|
-
|
|
5170
|
+
console.log(
|
|
5171
|
+
`Provider: ${providerPath ?? providerMissing}`
|
|
5172
|
+
);
|
|
5173
|
+
if (isolated) {
|
|
3471
5174
|
const repoName = getRepoName();
|
|
3472
5175
|
dryRunIsolated({
|
|
3473
5176
|
tickets: allTickets,
|
|
@@ -3476,11 +5179,20 @@ async function runExecute(argv) {
|
|
|
3476
5179
|
conflictStrategy,
|
|
3477
5180
|
progressPath: progressPaths.values().next().value,
|
|
3478
5181
|
testCmd,
|
|
5182
|
+
installCmd,
|
|
3479
5183
|
effort: argv.effort,
|
|
3480
5184
|
retries: argv.retries,
|
|
3481
5185
|
retryDelay: argv.retryDelay,
|
|
3482
5186
|
concurrency: argv.concurrency
|
|
3483
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}`);
|
|
3484
5196
|
} else {
|
|
3485
5197
|
dryRun(allTickets, fallbackSpec, testCmd);
|
|
3486
5198
|
}
|
|
@@ -3496,12 +5208,12 @@ async function runExecute(argv) {
|
|
|
3496
5208
|
process.on("SIGINT", () => onSignal(130));
|
|
3497
5209
|
process.on("SIGTERM", () => onSignal(143));
|
|
3498
5210
|
let result;
|
|
3499
|
-
if (
|
|
5211
|
+
if (isolated) {
|
|
3500
5212
|
result = await runWavesIsolated({
|
|
3501
5213
|
tickets: allTickets,
|
|
3502
5214
|
specBody: fallbackSpec,
|
|
3503
5215
|
specBodies,
|
|
3504
|
-
agentCmd
|
|
5216
|
+
agentCmd,
|
|
3505
5217
|
model: argv.model ?? null,
|
|
3506
5218
|
timeout,
|
|
3507
5219
|
logsDir,
|
|
@@ -3509,18 +5221,23 @@ async function runExecute(argv) {
|
|
|
3509
5221
|
conflictStrategy,
|
|
3510
5222
|
display,
|
|
3511
5223
|
testCmd,
|
|
5224
|
+
installCmd,
|
|
3512
5225
|
maxTurns: argv.maxTurns,
|
|
3513
5226
|
effort: argv.effort,
|
|
3514
5227
|
retries: argv.retries,
|
|
3515
5228
|
retryDelay: argv.retryDelay,
|
|
3516
5229
|
concurrency: argv.concurrency,
|
|
3517
|
-
progressPaths
|
|
5230
|
+
progressPaths,
|
|
5231
|
+
review: argv.review === false ? void 0 : {
|
|
5232
|
+
reviewPath: reviewPathFor(loaded.issuesDirs),
|
|
5233
|
+
forkedFrom: plan?.forkedFrom ?? void 0
|
|
5234
|
+
}
|
|
3518
5235
|
});
|
|
3519
5236
|
} else {
|
|
3520
5237
|
result = await runWaves(
|
|
3521
5238
|
allTickets,
|
|
3522
5239
|
fallbackSpec,
|
|
3523
|
-
|
|
5240
|
+
agentCmd,
|
|
3524
5241
|
argv.model ?? null,
|
|
3525
5242
|
timeout,
|
|
3526
5243
|
logsDir,
|