forgedock 1.19.0 → 1.21.0
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 +2 -0
- package/bin/engine/phases.mjs +4 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +40 -1
- package/bin/engine.mjs +48 -3
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/effects.mjs +51 -0
- package/bin/lean/lean.mjs +352 -0
- package/bin/lean/lean.test.mjs +525 -0
- package/bin/lean/prompts.mjs +62 -0
- package/bin/lean/risk.mjs +49 -0
- package/bin/runner.mjs +99 -6
- package/commands/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr.md +7 -6
- package/commands/work-on/build/architect.md +1 -1
- package/commands/work-on/build/implement.md +9 -1
- package/commands/work-on/build/validate.md +14 -7
- package/commands/work-on/build.md +118 -8
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/remediate.md +11 -4
- package/commands/work-on/review.md +12 -4
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +8 -1
- package/docs/RELEASE-NOTES.md +21 -0
- package/docs/TELEMETRY.md +2 -2
- package/docs/WORK-ON-RUNTIME.md +11 -3
- package/package.json +2 -2
- package/scripts/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/ci-local/lib.mjs +11 -3
- package/scripts/ci-local/run.mjs +155 -49
- package/scripts/ci-local/scoping.mjs +27 -4
- package/scripts/ci-local.sh +3 -2
- package/scripts/ci-local.test.sh +189 -0
- package/scripts/classify-finding.sh +1 -1
- package/scripts/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- package/scripts/lib/telemetry.sh +2 -0
- package/scripts/review-finalize.sh +101 -12
- package/scripts/review-finalize.test.sh +136 -5
- package/scripts/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +8 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/verify-phase-trail.test.sh +6 -0
package/README.md
CHANGED
|
@@ -303,6 +303,8 @@ If you're carrying an older `--global` habit in scripts or CI, it's harmless to
|
|
|
303
303
|
|
|
304
304
|
</details>
|
|
305
305
|
|
|
306
|
+
**After upgrading**: run `npx forgedock labels setup` once per repo. Repos created before the `workflow:remediating` state (forge#3541) lack that label; the pipeline now creates it on demand (best-effort), but the setup command is the supported way to sync all labels.
|
|
307
|
+
|
|
306
308
|
---
|
|
307
309
|
|
|
308
310
|
## For companies
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -913,12 +913,14 @@ export const PHASES = [
|
|
|
913
913
|
// forge#3542: only a full-line marker in a trusted comment counts.
|
|
914
914
|
// forge#4021: an in-PR fix re-armed by the re-gate (the newest trail after the newest marker reports
|
|
915
915
|
// INPR-FIX-REQUESTED) needs a FRESH marker, or M0's per-kind guard sees the first trail as this dispatch's
|
|
916
|
-
// and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm)
|
|
916
|
+
// and returns ALREADY_DONE. The marker count is also the bound (detectOutcome allows one re-arm); it is
|
|
917
|
+
// enforced here too, where the marker is posted, so a crash-resume after round 2 never posts marker 3.
|
|
917
918
|
const sentinel = `FORGE:${boundMarker}: pr=${pr}`;
|
|
918
919
|
let markerAt = -1;
|
|
919
920
|
comments.forEach((b, i) => { if (hasSentinelLine(b, sentinel)) markerAt = i; });
|
|
920
921
|
const trailAfter = markerAt < 0 ? null : latestMarked(comments.slice(markerAt + 1), PHASE_MARKERS.remediate.header, PHASE_MARKERS.remediate.completionMarker);
|
|
921
|
-
const
|
|
922
|
+
const priorMarkers = comments.filter((b) => hasSentinelLine(b, sentinel)).length;
|
|
923
|
+
const rearmed = kind === "inpr-fix" && trailAfter !== null && INPR_REQUESTED_RE.test(trailAfter) && priorMarkers < 2;
|
|
922
924
|
if (markerAt < 0 || rearmed) {
|
|
923
925
|
await io.gh(["issue", "comment", String(state.issue), "--body",
|
|
924
926
|
`${marker}\nReview handed PR #${pr} to remediation (${kind}); the engine is dispatching it once.`]);
|
|
@@ -28,7 +28,7 @@ export function mapRecord(rec, wallMs) {
|
|
|
28
28
|
if (!rec || typeof rec !== "object") return null;
|
|
29
29
|
if (rec.event === "PHASE_ATTEMPT_USAGE") {
|
|
30
30
|
return { event: "phase_attempt", phase: rec.phase, payload: compact({
|
|
31
|
-
attempt: rec.attempt, model: rec.model, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
31
|
+
attempt: rec.attempt, model: rec.model, stages: rec.stages, turns: rec.turns, duration_ms: rec.duration_ms,
|
|
32
32
|
cost_usd: Number.isFinite(rec.cost_usd) ? Math.round(rec.cost_usd * 1e6) / 1e6 : undefined,
|
|
33
33
|
input_tokens: tok(rec.usage, "input_tokens"), output_tokens: tok(rec.usage, "output_tokens"),
|
|
34
34
|
cache_read_tokens: tok(rec.usage, "cache_read_input_tokens"), cache_creation_tokens: tok(rec.usage, "cache_creation_input_tokens"),
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -85,6 +85,21 @@ export function readCiPendingMaxMinutes(cwd = process.cwd()) {
|
|
|
85
85
|
} catch { return undefined; }
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** forge.yaml `engine.path` (full|lean|auto) from the cwd checkout; undefined when absent or invalid. */
|
|
89
|
+
export function readEnginePath(cwd = process.cwd()) {
|
|
90
|
+
try {
|
|
91
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.path ?? "").trim();
|
|
92
|
+
return ENGINE_PATHS.includes(v) ? v : undefined;
|
|
93
|
+
} catch { return undefined; }
|
|
94
|
+
}
|
|
95
|
+
function readLeanSetting(key, cwd = process.cwd()) {
|
|
96
|
+
try {
|
|
97
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
98
|
+
return /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : undefined;
|
|
99
|
+
} catch { return undefined; }
|
|
100
|
+
}
|
|
101
|
+
export const ENGINE_PATHS = ["full", "lean", "auto"];
|
|
102
|
+
|
|
88
103
|
export function runDir() { return join(homedir(), ".forge", "runs"); }
|
|
89
104
|
|
|
90
105
|
/**
|
|
@@ -380,6 +395,7 @@ export function lastLocalRun(dir) {
|
|
|
380
395
|
* run's lease, wait (bounded by the lease TTL) until it
|
|
381
396
|
* expires or its same-host pid is dead, then continue.
|
|
382
397
|
* Without it, a held lease exits `deferred` (code 75).
|
|
398
|
+
* --path <full|lean|auto> Optional (#4081). Engine path; overrides forge.yaml engine.path. Default full.
|
|
383
399
|
* --retry Optional (forge#3511). Reopen a run that ended `engine-error`
|
|
384
400
|
* (e.g. a session-limit hit) and resume from its last committed
|
|
385
401
|
* phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
|
|
@@ -396,10 +412,17 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
396
412
|
const backend = flag(argv, "--backend");
|
|
397
413
|
const model = flag(argv, "--model");
|
|
398
414
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
399
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
415
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path"].includes(argv[i - 1]));
|
|
400
416
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
401
417
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
402
418
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
419
|
+
const pathFlag = flag(argv, "--path");
|
|
420
|
+
if (argv.includes("--path") && (!pathFlag || pathFlag.startsWith("--"))) throw new Error(`--path requires a value: ${ENGINE_PATHS.join("|")}`);
|
|
421
|
+
if (pathFlag && !ENGINE_PATHS.includes(pathFlag)) throw new Error(`--path must be one of ${ENGINE_PATHS.join("|")}`);
|
|
422
|
+
const enginePath = pathFlag ?? readEnginePath() ?? "full";
|
|
423
|
+
if (enginePath !== "full" && (retry || waitLease)) {
|
|
424
|
+
throw new Error("--retry and --wait-lease are not supported with the lean path (a rerun resumes from the open PR or remote branch automatically); use --path full for them");
|
|
425
|
+
}
|
|
403
426
|
const io = deps.io ?? makeIo();
|
|
404
427
|
await assertRepoMatchesCwd(io, repo);
|
|
405
428
|
const runIssueFn = deps.runIssue ?? runIssue;
|
|
@@ -427,6 +450,22 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
427
450
|
// from "phase actually failed".
|
|
428
451
|
else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
|
|
429
452
|
};
|
|
453
|
+
if (enginePath !== "full") {
|
|
454
|
+
// #4081: lean path. `auto` escalates to the full pipeline only when the change is risky AND large.
|
|
455
|
+
const leanRepo = repo ?? (await resolveDefaultRepo(io));
|
|
456
|
+
if (!leanRepo) throw new Error("lean path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
|
|
457
|
+
const { runLean } = deps.leanModule ?? (await import("./lean/lean.mjs"));
|
|
458
|
+
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
|
|
459
|
+
const lres = await runLean({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
460
|
+
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
461
|
+
...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
462
|
+
if (lres.terminalReason !== "route-full") {
|
|
463
|
+
console.log(`issue #${issue} → ${lres.terminalReason}`);
|
|
464
|
+
if (lres.terminalReason !== "merged") console.log(formatTerminalDiagnostics(dir, issue));
|
|
465
|
+
return lres;
|
|
466
|
+
}
|
|
467
|
+
console.log(`issue #${issue} → routing to the full pipeline (${lres.detail})`);
|
|
468
|
+
}
|
|
430
469
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
431
470
|
...(repo ? { repo } : {}),
|
|
432
471
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
package/bin/engine.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* GitHub state (phase.detectOutcome). All effects are injected → fully testable.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { readFileSync, rmSync } from "node:fs";
|
|
7
|
+
import { readFileSync, rmSync, readdirSync } from "node:fs";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
8
9
|
import { createHash, scryptSync } from "node:crypto";
|
|
9
10
|
import { hostname as osHostname, homedir } from "node:os";
|
|
10
11
|
import { appendEvent, readLog, deriveState, rewriteLog } from "./engine/runlog.mjs";
|
|
@@ -268,6 +269,46 @@ export function isLeaseHolderDead({ by, host = osHostname(), killFn = process.ki
|
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/**
|
|
273
|
+
* forge#3724: a dead lease-holder pid proves the engine is dead, not that its phase child is. The runner
|
|
274
|
+
* stamps every phase child with `FORGE_LEASE_HOLDER=<by>` (inherited by grandchildren), so a surviving
|
|
275
|
+
* orphan is found by scanning process environments. Returns FALSE only when the probe affirmatively finds
|
|
276
|
+
* no live process carrying the marker; unsupported platform, unreadable /proc, a failed `ps`, or any thrown
|
|
277
|
+
* error returns TRUE (fail-safe: the caller keeps waiting for `lease.until`). Per-process EACCES/ENOENT is
|
|
278
|
+
* skipped (exited, or another uid). Residual: a child that scrubs its env (`env -i`) is not detected.
|
|
279
|
+
*/
|
|
280
|
+
export function leaseChildAlive({ by, platform = process.platform, procRoot = "/proc", ownPid = process.pid,
|
|
281
|
+
readdirFn = readdirSync, readFileFn = readFileSync, psFn } = {}) {
|
|
282
|
+
try {
|
|
283
|
+
const marker = `FORGE_LEASE_HOLDER=${by}`;
|
|
284
|
+
if (!parseLeaseHolder(by)) return true;
|
|
285
|
+
if (platform === "linux") {
|
|
286
|
+
for (const name of readdirFn(procRoot)) {
|
|
287
|
+
if (!/^[0-9]+$/.test(name) || Number(name) === ownPid) continue;
|
|
288
|
+
let env;
|
|
289
|
+
try { env = readFileFn(`${procRoot}/${name}/environ`, "latin1"); }
|
|
290
|
+
catch (e) { if (e?.code === "EACCES" || e?.code === "ENOENT" || e?.code === "ESRCH" || e?.code === "EPERM") continue; return true; }
|
|
291
|
+
if (String(env).split("\0").includes(marker)) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
if (platform === "darwin") {
|
|
296
|
+
const run = psFn || (() => execFileSync("ps", ["eww", "-Ao", "pid,command"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 10000 }));
|
|
297
|
+
const out = run();
|
|
298
|
+
if (typeof out !== "string" || !out.trim()) return true;
|
|
299
|
+
for (const line of out.split("\n")) {
|
|
300
|
+
const m = /^\s*(\d+)\s/.exec(line);
|
|
301
|
+
if (!m || Number(m[1]) === ownPid) continue;
|
|
302
|
+
if (line.split(/\s+/).includes(marker)) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
} catch {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
271
312
|
/** Max startup wait for a persisted rate-limit reset (guards a corrupt far-future resetAt). */
|
|
272
313
|
export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
|
|
273
314
|
|
|
@@ -330,7 +371,8 @@ export const MAX_RESUME_RATE_LIMIT_WAIT_MS = 6 * 60 * 60 * 1000;
|
|
|
330
371
|
* the lease, poll (via `sleep`, bounded by `leaseTtlMs`) until it expires or
|
|
331
372
|
* its holder is reclaimable, then continue; `deferred` on timeout.
|
|
332
373
|
* @param {string} [opts.hostname] - injectable host name (tests).
|
|
333
|
-
|
|
374
|
+
* @param {Function} [opts.killFn] - injectable `process.kill` (tests).
|
|
375
|
+
* @param {Function} [opts.childAliveFn] - forge#3724: injectable `leaseChildAlive` (orphaned phase-child probe; tests).
|
|
334
376
|
*/
|
|
335
377
|
// forge#3824: stamped on RUN_START for per-release cost attribution; null when unreadable.
|
|
336
378
|
export const ENGINE_VERSION = (() => {
|
|
@@ -368,6 +410,8 @@ export async function runIssue(opts) {
|
|
|
368
410
|
waitLease = false,
|
|
369
411
|
hostname = osHostname(),
|
|
370
412
|
killFn = process.kill.bind(process),
|
|
413
|
+
// forge#3724: injectable orphaned-phase-child probe (tests).
|
|
414
|
+
childAliveFn = leaseChildAlive,
|
|
371
415
|
onProgress = () => {},
|
|
372
416
|
// forge#3696: per-attempt telemetry seam (FORGE:TELEMETRY emitter, #3834). No-op by
|
|
373
417
|
// default; failures are swallowed so telemetry can never fail a phase.
|
|
@@ -400,7 +444,7 @@ export async function runIssue(opts) {
|
|
|
400
444
|
let remote = await projector.readState(issue);
|
|
401
445
|
let { state, action } = reconcileState(local, remote);
|
|
402
446
|
const leaseHeld = (r) => !!(r?.lease && r.lease.until > now() && r.lease.by !== agentId &&
|
|
403
|
-
!isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }));
|
|
447
|
+
!(isLeaseHolderDead({ by: r.lease.by, host: hostname, killFn }) && !childAliveFn({ by: r.lease.by })));
|
|
404
448
|
const deferredResult = (r) => ({
|
|
405
449
|
terminalReason: "deferred",
|
|
406
450
|
code: OUTCOME_CODES.LEASE_HELD,
|
|
@@ -1233,6 +1277,7 @@ async function runPhaseWithRetry(phase, state, ctx) {
|
|
|
1233
1277
|
try {
|
|
1234
1278
|
result = await runner({
|
|
1235
1279
|
commandsDir, commandName: phase.command, args: phaseArgs,
|
|
1280
|
+
leaseHolder: agentId,
|
|
1236
1281
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
1237
1282
|
// runner.mjs's existing default ("auto" backend / DEFAULT_MODEL).
|
|
1238
1283
|
...(backend ? { backend } : {}),
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3207,7 +3207,7 @@ function help() {
|
|
|
3207
3207
|
["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
|
|
3208
3208
|
["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
|
|
3209
3209
|
["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
|
|
3210
|
-
["npx forgedock run-issue <issue> --lane <lane> [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3210
|
+
["npx forgedock run-issue <issue> --lane <lane> [--path full|lean|auto] [--retry] [--wait-lease]", "Drive one issue through the durable engine (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
|
|
3211
3211
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
3212
3212
|
["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
|
|
3213
3213
|
["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** Lean path (#4081): the real effects behind runLean's injected `io`. Argv only, no shell. */
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { runCliBackend, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
|
|
10
|
+
import { makeIo } from "../engine-cli.mjs";
|
|
11
|
+
import { makeProjector } from "../engine/projector.mjs";
|
|
12
|
+
|
|
13
|
+
const pexec = promisify(execFile);
|
|
14
|
+
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Claude CLI session runner: fresh session, or `--resume <id>` for the warm builder. Resolves usage + session_id. */
|
|
17
|
+
export function makeLeanRunner({ runCli = runCliBackend } = {}) {
|
|
18
|
+
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false }) => {
|
|
19
|
+
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
20
|
+
const caps = getClaudeCliCapabilities(cwd);
|
|
21
|
+
const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
|
|
22
|
+
const r = runCli({ spec: { name: `lean-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
23
|
+
args: [String(issue)], cwd, bin, model, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
24
|
+
logger: { log() {} } });
|
|
25
|
+
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A missing or unparseable CI_LOCAL_RESULT means the gate itself is broken (BROKEN); only an explicit DEGRADED passes. */
|
|
30
|
+
export function parseCiLocalResult(out) {
|
|
31
|
+
return /CI_LOCAL_RESULT:\s*(PASS|FAIL|DEGRADED)\b/.exec(String(out))?.[1] ?? "BROKEN";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function makeLeanIo({ repo }) {
|
|
35
|
+
const base = makeIo();
|
|
36
|
+
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
37
|
+
return {
|
|
38
|
+
gh: run("gh"), git: run("git"), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
39
|
+
async ciLocal({ cwd, base: baseRef }) {
|
|
40
|
+
let out = "";
|
|
41
|
+
try { out = (await pexec("bash", [script("ci-local.sh"), "run", "--repo", cwd, "--base", baseRef], { maxBuffer: 50 * 1024 * 1024, timeout: 3600000 })).stdout; }
|
|
42
|
+
catch (e) { out = String(e.stdout ?? "") + String(e.stderr ?? ""); }
|
|
43
|
+
return { result: parseCiLocalResult(out), tail: out.split("\n").slice(-60).join("\n") };
|
|
44
|
+
},
|
|
45
|
+
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
46
|
+
fs: {
|
|
47
|
+
read: async (p) => readFileSync(p, "utf8"),
|
|
48
|
+
append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Lean path (#4081): validate -> architect -> implement in ONE warm builder session, deterministic gates,
|
|
5
|
+
* one independent reviewer (plus a security reviewer on risky paths), then deterministic merge and close.
|
|
6
|
+
* Every stage is posted to the issue as a structured FORGE:* comment. All effects are injected through `io`
|
|
7
|
+
* (gh, git, runner, ciLocal, waitCiGreen, transitionLabel, fs, sleep) so the module is fully unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { appendEvent } from "../engine/runlog.mjs";
|
|
11
|
+
import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
|
|
12
|
+
import { ciPollDelayMs, ENGINE_VERSION } from "../engine.mjs";
|
|
13
|
+
import { openDependency } from "../engine/phases.mjs";
|
|
14
|
+
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
15
|
+
import { riskTier, isDocsOnly, isTestFile, isLargeDiff, isBlocking, parseFindings } from "./risk.mjs";
|
|
16
|
+
import { BUILDER_SYSTEM, REVIEWER_SYSTEM, SECURITY_REVIEWER_SYSTEM, LEAN_DIR, builderPrompt, resumeGatePrompt, resumeReviewPrompt, reviewerPrompt } from "./prompts.mjs";
|
|
17
|
+
|
|
18
|
+
export const MAX_ROUNDS = 2;
|
|
19
|
+
export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
|
|
20
|
+
const NS = "lean";
|
|
21
|
+
|
|
22
|
+
const num = (v) => (Number.isFinite(v) ? v : null);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} o
|
|
26
|
+
* @param {number} o.issue
|
|
27
|
+
* @param {string} o.repo "owner/repo"
|
|
28
|
+
* @param {string} o.lane base branch (staging|main)
|
|
29
|
+
* @param {string} o.cwd main checkout (worktrees are added from here)
|
|
30
|
+
* @param {string} o.dir run-log dir
|
|
31
|
+
* @param {object} o.io injected effects
|
|
32
|
+
* @param {"lean"|"auto"} [o.path]
|
|
33
|
+
* @param {string} [o.builderModel] default: CLI default
|
|
34
|
+
* @param {string} [o.reviewerModel] default claude-sonnet-5-5
|
|
35
|
+
* @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
|
|
36
|
+
*/
|
|
37
|
+
export async function runLean(o) {
|
|
38
|
+
const { issue, repo, lane, cwd, dir, io, path = "lean", builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
|
|
39
|
+
ciMaxMinutes = 360, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
40
|
+
const now = io.now ?? (() => Date.now());
|
|
41
|
+
const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
42
|
+
const base = `origin/${lane}`;
|
|
43
|
+
const branch = `feat/issue-${issue}-lean`;
|
|
44
|
+
const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
|
|
45
|
+
const t0 = now();
|
|
46
|
+
const links = {};
|
|
47
|
+
let sessionId = null;
|
|
48
|
+
const usageTotal = { cost_usd: 0 };
|
|
49
|
+
|
|
50
|
+
const ev = (e) => appendEvent(dir, issue, e);
|
|
51
|
+
const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
|
|
52
|
+
const ghc = (args) => io.gh(args);
|
|
53
|
+
const git = (args) => io.git(args, { cwd: wt });
|
|
54
|
+
const file = (n) => `${wt}/${LEAN_DIR}/${n}`;
|
|
55
|
+
|
|
56
|
+
async function attempt(phase, n, r, extra = {}) {
|
|
57
|
+
const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
|
|
58
|
+
cost_usd: num(r?.costUsd), model: r?.model ?? null, turns: num(r?.turns), ...extra };
|
|
59
|
+
ev(rec); await emit(rec);
|
|
60
|
+
usageTotal.cost_usd += r?.costUsd ?? 0;
|
|
61
|
+
}
|
|
62
|
+
async function commit(phase) { ev({ event: "PHASE_COMMIT", phase }); onProgress({ event: "phase_exit", phase, status: "committed" }); }
|
|
63
|
+
|
|
64
|
+
async function post(marker, title, body, attrs = "") {
|
|
65
|
+
const text = `<!-- ${marker} ${NS} v1${attrs} -->\n## ${marker}\n${title ? `${title}\n\n` : ""}${body}`;
|
|
66
|
+
const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", text])).trim();
|
|
67
|
+
const url = /https?:\/\/\S+/.exec(out)?.[0] ?? null;
|
|
68
|
+
if (url) (links[marker] ??= []).push(url);
|
|
69
|
+
return url;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let done = null, wtCreated = false;
|
|
73
|
+
const WORKFLOW = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
|
|
74
|
+
async function finish(reason, detail = "", extra = {}) {
|
|
75
|
+
if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
|
|
76
|
+
const code = codeForTerminalReason(reason);
|
|
77
|
+
ev({ event: "RUN_TERMINAL", reason, code });
|
|
78
|
+
await emit({ event: "RUN_TERMINAL", reason, code });
|
|
79
|
+
// A run that did not reach a success/handoff state must not leave a stale in-progress label behind.
|
|
80
|
+
if (["needs-human", "engine-error"].includes(reason)) {
|
|
81
|
+
try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", WORKFLOW]); } catch { /* best effort */ }
|
|
82
|
+
}
|
|
83
|
+
await releaseLease(reason);
|
|
84
|
+
// The worktree is scratch; the branch stays when a PR (or a human) may need it.
|
|
85
|
+
if (wtCreated) {
|
|
86
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
|
|
87
|
+
if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
|
|
88
|
+
}
|
|
89
|
+
done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usageTotal.cost_usd, wall_ms: now() - t0, ...extra };
|
|
90
|
+
return done;
|
|
91
|
+
}
|
|
92
|
+
async function needsHuman(why) {
|
|
93
|
+
try { await post("FORGE:NEEDS_HUMAN", "", why); await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { /* best effort */ }
|
|
94
|
+
return finish("needs-human", why);
|
|
95
|
+
}
|
|
96
|
+
async function label(state) { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } }
|
|
97
|
+
|
|
98
|
+
async function builder(phase, n, prompt, resume) {
|
|
99
|
+
onProgress({ event: "phase_enter", phase });
|
|
100
|
+
await renew();
|
|
101
|
+
const r = await io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume,
|
|
102
|
+
system: resume ? "" : BUILDER_SYSTEM, prompt });
|
|
103
|
+
await renew();
|
|
104
|
+
if (r?.sessionId) sessionId = r.sessionId;
|
|
105
|
+
return r;
|
|
106
|
+
}
|
|
107
|
+
const readJson = async (n) => { try { return JSON.parse(await io.fs.read(file(n))); } catch { return null; } };
|
|
108
|
+
|
|
109
|
+
// Same issue lease the full path uses (FORGE:STATE lease via the projector): two runs cannot race.
|
|
110
|
+
const agentId = o.agentId ?? `lean_${process.pid}`;
|
|
111
|
+
// 25 min: below the projector's 30 min honour cap. The CLI is spawned synchronously (no timer can fire), so the
|
|
112
|
+
// lease is also renewed immediately before and after every builder/reviewer/ci-local call and each CI poll.
|
|
113
|
+
const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000;
|
|
114
|
+
let leaseState = null, leaseTimer = null;
|
|
115
|
+
const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
|
|
116
|
+
async function releaseLease(reason) {
|
|
117
|
+
if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
|
|
118
|
+
if (!io.projector || !leaseState) return;
|
|
119
|
+
const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
|
|
120
|
+
try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
|
|
121
|
+
}
|
|
122
|
+
const renew = async () => { if (io.projector && leaseState) { try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ } } };
|
|
123
|
+
// Same Depends-on admission as the full path, before any state write or worktree.
|
|
124
|
+
{
|
|
125
|
+
let dep = null;
|
|
126
|
+
try { dep = await openDependency(issue, io); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
|
|
127
|
+
if (dep?.unreadable) return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
|
|
128
|
+
if (dep?.open?.length) {
|
|
129
|
+
return { terminalReason: "dependency-open", code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: dep.open,
|
|
130
|
+
detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (io.projector) {
|
|
134
|
+
let st = null;
|
|
135
|
+
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed, the write below is best effort */ }
|
|
136
|
+
if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
|
|
137
|
+
return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
|
|
138
|
+
}
|
|
139
|
+
leaseState = st ?? { v: 1, run: `lean_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminal: false, terminalReason: null };
|
|
140
|
+
leaseState = { ...leaseState, terminal: false };
|
|
141
|
+
try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ }
|
|
142
|
+
leaseTimer = setInterval(() => { writeLease({ by: agentId, until: now() + leaseTtlMs }).catch(() => {}); }, o.leaseRenewMs ?? 120000);
|
|
143
|
+
leaseTimer.unref?.();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ev({ event: "RUN_START", run: `lean_${issue}_${t0}`, issue, lane, path: "lean", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
|
|
147
|
+
let iss;
|
|
148
|
+
try {
|
|
149
|
+
iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
|
|
150
|
+
} catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
|
|
151
|
+
if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await label("investigating");
|
|
155
|
+
try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
|
|
156
|
+
// Reruns resume: an open PR or a remote branch for this issue means earlier work exists (e.g. ci-pending).
|
|
157
|
+
let resumed = null;
|
|
158
|
+
try {
|
|
159
|
+
const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid"])) || "[]");
|
|
160
|
+
const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
|
|
161
|
+
if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null };
|
|
162
|
+
} catch { /* no resume info: start fresh */ }
|
|
163
|
+
try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
|
|
164
|
+
try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
|
|
165
|
+
if (resumed) {
|
|
166
|
+
await io.git(["fetch", "origin", branch], { cwd });
|
|
167
|
+
await io.git(["worktree", "add", "-B", branch, wt, `origin/${branch}`], { cwd });
|
|
168
|
+
} else {
|
|
169
|
+
await io.git(["worktree", "add", "-B", branch, wt, base], { cwd });
|
|
170
|
+
}
|
|
171
|
+
wtCreated = true;
|
|
172
|
+
const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
173
|
+
await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
|
|
174
|
+
|
|
175
|
+
let planMd = null, validate = null, implRound = 1, gateRounds = 0;
|
|
176
|
+
const postImpl = async () => {
|
|
177
|
+
const impl = await readJson("implement.json");
|
|
178
|
+
await post("FORGE:IMPLEMENT", impl ? `**Round:** ${implRound}` : "**implement.json missing**", "```json\n" + JSON.stringify(impl ?? {}, null, 2) + "\n```", ` round=${implRound}`);
|
|
179
|
+
return impl;
|
|
180
|
+
};
|
|
181
|
+
const diffStats = async () => {
|
|
182
|
+
const files = String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
183
|
+
let lines = 0;
|
|
184
|
+
for (const l of String(await git(["diff", "--numstat", `${base}...HEAD`])).split("\n")) {
|
|
185
|
+
const [a, d] = l.split("\t"); lines += (Number(a) || 0) + (Number(d) || 0);
|
|
186
|
+
}
|
|
187
|
+
return { files, lines };
|
|
188
|
+
};
|
|
189
|
+
const gateFailure = async () => {
|
|
190
|
+
const { files } = await diffStats();
|
|
191
|
+
if (files.length === 0) return { kind: "no-commit", tail: "No commit on the branch relative to the base. Commit your change." };
|
|
192
|
+
if (!(await readJson("implement.json"))) return { kind: "implement-json", tail: `${LEAN_DIR}/implement.json is missing or invalid JSON.` };
|
|
193
|
+
if (!isDocsOnly(files) && !files.some(isTestFile)) {
|
|
194
|
+
return { kind: "missing-test", tail: "No test file changed. Add a regression test that fails without the fix (and passes with it)." };
|
|
195
|
+
}
|
|
196
|
+
await renew();
|
|
197
|
+
const ci = await io.ciLocal({ cwd: wt, base });
|
|
198
|
+
await renew();
|
|
199
|
+
if (ci.result === "BROKEN") return { kind: "ci-local-broken", fatal: true, tail: ci.tail ?? "ci-local reported no result" };
|
|
200
|
+
if (ci.result === "FAIL") return { kind: "ci-local", tail: ci.tail ?? "" };
|
|
201
|
+
return null;
|
|
202
|
+
};
|
|
203
|
+
const runGates = async () => {
|
|
204
|
+
for (;;) {
|
|
205
|
+
const f = await gateFailure();
|
|
206
|
+
if (!f) return null;
|
|
207
|
+
if (f.fatal) return needsHuman(`gate ${f.kind}: the local CI gate could not report a result, so the change is unverified.\n\n${String(f.tail).slice(-2000)}`);
|
|
208
|
+
if (gateRounds >= MAX_ROUNDS) return needsHuman(`gate ${f.kind} still failing after ${MAX_ROUNDS} resume rounds:\n\n${String(f.tail).slice(-3000)}`);
|
|
209
|
+
gateRounds++; implRound++;
|
|
210
|
+
if (!sessionId) return needsHuman(`gate ${f.kind} failed and the builder session id is unknown, cannot resume`);
|
|
211
|
+
const rr = await builder("implement", implRound, resumeGatePrompt(f), sessionId);
|
|
212
|
+
await attempt("implement", implRound, rr, {});
|
|
213
|
+
await postImpl();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
let approvedHead = null;
|
|
218
|
+
if (resumed) {
|
|
219
|
+
// Resume from the knowledge graph: plan and last review verdict come from the issue comments.
|
|
220
|
+
const bodies = (JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "comments"])) || "{}").comments ?? []).map((c) => String(c.body ?? ""));
|
|
221
|
+
const lastOf = (m) => bodies.filter((b) => b.includes(`<!-- ${m} ${NS}`)).pop();
|
|
222
|
+
const planC = lastOf("FORGE:PLAN");
|
|
223
|
+
planMd = planC ? planC.replace(/^[\s\S]*?## FORGE:PLAN\n/, "") : "";
|
|
224
|
+
const rev = lastOf("FORGE:REVIEW");
|
|
225
|
+
const m = rev && /\*\*Verdict:\*\* APPROVED[^\n]*reviewed ([0-9a-f]{7,40})/.exec(rev);
|
|
226
|
+
const curHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
227
|
+
if (m && curHead.startsWith(m[1])) approvedHead = curHead;
|
|
228
|
+
ev({ event: "RUN_RESUMED", pr: resumed.pr, approved: Boolean(approvedHead) });
|
|
229
|
+
} else {
|
|
230
|
+
// ---- builder: validate + architect + implement in one session ----
|
|
231
|
+
const r = await builder("validate", 1, builderPrompt({ issue: iss, base: lane, branch }), null);
|
|
232
|
+
validate = await readJson("validate.json");
|
|
233
|
+
await attempt("implement", 1, r, { stages: "validate,architect,implement" });
|
|
234
|
+
if (!validate) return await needsHuman("builder did not write validate.json");
|
|
235
|
+
await post("FORGE:VALIDATE", `**Status:** ${validate.status}`, "```json\n" + JSON.stringify(validate, null, 2) + "\n```");
|
|
236
|
+
if (validate.status !== "valid") {
|
|
237
|
+
const evidence = (Array.isArray(validate.evidence) ? validate.evidence : []).filter((x) => typeof x === "string" && x.trim());
|
|
238
|
+
if (!["invalid", "already-fixed"].includes(validate.status) || evidence.length === 0) {
|
|
239
|
+
return await needsHuman(`validate returned status "${validate.status}" ${evidence.length ? "" : "without evidence "}so the issue is not closed automatically.\n\nBuilder reasoning: ${validate.summary ?? "(none)"}`);
|
|
240
|
+
}
|
|
241
|
+
await commit("validate");
|
|
242
|
+
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
243
|
+
await label("invalid");
|
|
244
|
+
return await finish("invalid", validate.summary ?? validate.status);
|
|
245
|
+
}
|
|
246
|
+
await commit("validate");
|
|
247
|
+
await label("building");
|
|
248
|
+
|
|
249
|
+
planMd = await io.fs.read(file("plan.md")).catch(() => null);
|
|
250
|
+
if (!planMd) return await needsHuman("builder did not write plan.md");
|
|
251
|
+
await post("FORGE:PLAN", "", planMd);
|
|
252
|
+
await commit("architect");
|
|
253
|
+
|
|
254
|
+
await postImpl();
|
|
255
|
+
await commit("implement");
|
|
256
|
+
|
|
257
|
+
const g0 = await runGates();
|
|
258
|
+
if (g0) return g0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- auto path: risky AND large escalates to the full pipeline ----
|
|
262
|
+
const st = await diffStats();
|
|
263
|
+
const risk = riskTier(st.files, `${iss.title}\n${iss.body ?? ""}`);
|
|
264
|
+
if (!resumed && path === "auto" && risk.tier === "high" && isLargeDiff({ files: st.files.length, lines: st.lines })) {
|
|
265
|
+
await post("FORGE:ROUTE", "", `Risk tier high (${risk.reasons.slice(0, 5).join(", ")}) and diff large (${st.files.length} files, ${st.lines} lines): routing to the full pipeline.`);
|
|
266
|
+
return await finish("route-full", "risk high and diff large");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- review loop ----
|
|
270
|
+
await label("in-review");
|
|
271
|
+
let reviewRound = 0, reviewedHead = null, prior = null, reviewAttempt = 0;
|
|
272
|
+
const review = async (role, system, delta) => {
|
|
273
|
+
for (let tryN = 0; tryN < 2; tryN++) {
|
|
274
|
+
const diff = String(await git(["diff", delta ? `${reviewedHead}..HEAD` : `${base}...HEAD`]));
|
|
275
|
+
await renew();
|
|
276
|
+
const rr = await io.runner({ role, issue, cwd: wt, model: reviewerModel, resume: null, system, readOnly: true,
|
|
277
|
+
prompt: reviewerPrompt({ issue: iss, plan: planMd, diff, prior, delta }) });
|
|
278
|
+
await renew();
|
|
279
|
+
await attempt("review", ++reviewAttempt, rr);
|
|
280
|
+
const f = parseFindings(rr?.text);
|
|
281
|
+
if (f) return f.map((x) => ({ ...x, reviewer: role }));
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
};
|
|
285
|
+
for (;!approvedHead;) {
|
|
286
|
+
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
287
|
+
const delta = reviewedHead !== null;
|
|
288
|
+
const roles = [["reviewer", REVIEWER_SYSTEM]];
|
|
289
|
+
if (risk.tier === "high") roles.push(["security-reviewer", SECURITY_REVIEWER_SYSTEM]);
|
|
290
|
+
const all = [];
|
|
291
|
+
for (const [role, system] of roles) {
|
|
292
|
+
const f = await review(role, system, delta);
|
|
293
|
+
if (f === null) return await needsHuman(`${role} returned no parseable findings twice`);
|
|
294
|
+
all.push(...f);
|
|
295
|
+
}
|
|
296
|
+
reviewedHead = head;
|
|
297
|
+
const blocking = all.filter(isBlocking);
|
|
298
|
+
const notes = all.filter((x) => !isBlocking(x));
|
|
299
|
+
const row = (x) => `- **${x.severity}/${x.confidence}** ${x.file ?? "?"}:${x.line ?? "?"} (${x.scope ?? "introduced"}, ${x.reviewer}) ${x.title ?? ""}${x.failure_scenario ? ` — ${x.failure_scenario}` : ""}`;
|
|
300
|
+
await post("FORGE:REVIEW", `**Verdict:** ${blocking.length ? "BLOCKING" : "APPROVED"} | round ${reviewRound + 1} | reviewed ${head.slice(0, 12)}${delta ? " (delta)" : ""} | risk ${risk.tier}`,
|
|
301
|
+
`### Blocking (${blocking.length})\n${blocking.map(row).join("\n") || "none"}\n\n### Notes (${notes.length}, not filed as issues)\n${notes.map(row).join("\n") || "none"}\n\n\`\`\`json\n${JSON.stringify(all, null, 2)}\n\`\`\``);
|
|
302
|
+
await commit("review");
|
|
303
|
+
if (!blocking.length) break;
|
|
304
|
+
if (reviewRound >= MAX_ROUNDS) return await needsHuman(`${blocking.length} blocking finding(s) after ${MAX_ROUNDS} fix rounds`);
|
|
305
|
+
if (!sessionId) return await needsHuman("blocking findings and the builder session id is unknown, cannot resume");
|
|
306
|
+
reviewRound++; implRound++; prior = blocking;
|
|
307
|
+
const rr = await builder("implement", implRound, resumeReviewPrompt({ findings: blocking }), sessionId);
|
|
308
|
+
await attempt("implement", implRound, rr);
|
|
309
|
+
await postImpl();
|
|
310
|
+
const g1 = await runGates();
|
|
311
|
+
if (g1) return g1;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- merge and close (no model) ----
|
|
315
|
+
await git(["push", "-u", "origin", branch]);
|
|
316
|
+
const final = await diffStats();
|
|
317
|
+
const linkLines = Object.entries(links).map(([k, v]) => `- ${k}: ${v.join(", ")}`).join("\n");
|
|
318
|
+
const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghc(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
|
|
319
|
+
"--title", `${(validate?.title ?? iss.title)} (#${issue})`,
|
|
320
|
+
"--body", `Closes #${issue}\n\nLean path run. Knowledge graph:\n${linkLines}\n\nFiles: ${final.files.length}, lines: ${final.lines}`])).trim();
|
|
321
|
+
const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
|
|
322
|
+
if (!Number.isInteger(pr)) return await needsHuman(`could not parse PR number from: ${prOut.slice(0, 200)}`);
|
|
323
|
+
ev({ event: "PR_OPENED", pr });
|
|
324
|
+
|
|
325
|
+
const startedAt = now();
|
|
326
|
+
let pollIndex = 0, gateHead = null;
|
|
327
|
+
for (;;) {
|
|
328
|
+
await renew();
|
|
329
|
+
const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
|
|
330
|
+
if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
|
|
331
|
+
if (w.rc === 1) return await needsHuman(`remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`);
|
|
332
|
+
if (now() - startedAt >= ciMaxMinutes * 60000) {
|
|
333
|
+
// Slow CI is never a needs-human: leave the PR open and resumable.
|
|
334
|
+
ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
|
|
335
|
+
return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
|
|
336
|
+
}
|
|
337
|
+
onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
|
|
338
|
+
await sleep(ciPollDelayMs(pollIndex++, random));
|
|
339
|
+
}
|
|
340
|
+
const localHead = String(await git(["rev-parse", "HEAD"])).trim();
|
|
341
|
+
if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
|
|
342
|
+
return await needsHuman(`CI gate passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`);
|
|
343
|
+
}
|
|
344
|
+
await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
|
|
345
|
+
await post("FORGE:CLOSE", "", `Merged PR #${pr} at ${localHead.slice(0, 12)}.\n\n- Review rounds: ${reviewRound}\n- Gate rounds: ${gateRounds}\n- Cost: $${usageTotal.cost_usd.toFixed(4)}\n- Wall: ${Math.round((now() - t0) / 1000)}s\n${linkLines}`);
|
|
346
|
+
await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
|
|
347
|
+
await label("merged");
|
|
348
|
+
return await finish("merged", `PR #${pr}`, { pr });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
return finish("engine-error", e?.message ?? String(e));
|
|
351
|
+
}
|
|
352
|
+
}
|