forgedock 1.21.0 → 1.22.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/bin/lean/lean.mjs CHANGED
@@ -1,24 +1,20 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
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.
4
+ * Core work-on: ONE plain builder session does the work, ONE read-only reviewer answers two questions, and
5
+ * ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
6
+ * severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
8
7
  */
9
8
  import { join } from "node:path";
10
9
  import { appendEvent } from "../engine/runlog.mjs";
11
- import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
10
+ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
11
  import { ciPollDelayMs, ENGINE_VERSION } from "../engine.mjs";
13
12
  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";
13
+ import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody } from "./render.mjs";
14
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
17
15
 
18
- export const MAX_ROUNDS = 2;
19
16
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
20
- const NS = "lean";
21
-
17
+ export const REGISTER_MARKER = "FORGE:WORK-ON";
22
18
  const num = (v) => (Number.isFinite(v) ? v : null);
23
19
 
24
20
  /**
@@ -28,99 +24,96 @@ const num = (v) => (Number.isFinite(v) ? v : null);
28
24
  * @param {string} o.lane base branch (staging|main)
29
25
  * @param {string} o.cwd main checkout (worktrees are added from here)
30
26
  * @param {string} o.dir run-log dir
31
- * @param {object} o.io injected effects
32
- * @param {"lean"|"auto"} [o.path]
27
+ * @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
33
28
  * @param {string} [o.builderModel] default: CLI default
34
29
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
35
30
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
36
31
  */
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;
32
+ export async function runCore(o) {
33
+ const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
34
+ ciMaxMinutes = 360, mergeWait = "required", onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
40
35
  const now = io.now ?? (() => Date.now());
41
36
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
42
37
  const base = `origin/${lane}`;
43
38
  const branch = `feat/issue-${issue}-lean`;
44
39
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
45
40
  const t0 = now();
46
- const links = {};
41
+ const usage = { cost_usd: 0, input: 0, output: 0, cache: 0 };
47
42
  let sessionId = null;
48
- const usageTotal = { cost_usd: 0 };
49
43
 
50
44
  const ev = (e) => appendEvent(dir, issue, e);
51
45
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
52
46
  const ghc = (args) => io.gh(args);
53
47
  const git = (args) => io.git(args, { cwd: wt });
54
- const file = (n) => `${wt}/${LEAN_DIR}/${n}`;
55
48
 
56
- async function attempt(phase, n, r, extra = {}) {
49
+ async function attempt(phase, n, r) {
57
50
  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 };
51
+ cost_usd: num(r?.costUsd), model: r?.model ?? null, turns: num(r?.turns) };
59
52
  ev(rec); await emit(rec);
60
- usageTotal.cost_usd += r?.costUsd ?? 0;
53
+ const u = r?.usage ?? {};
54
+ usage.cost_usd += r?.costUsd ?? 0;
55
+ usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
56
+ usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
61
57
  }
62
- async function commit(phase) { ev({ event: "PHASE_COMMIT", phase }); onProgress({ event: "phase_exit", phase, status: "committed" }); }
58
+ const post = async (marker, body) => {
59
+ const out = String(await ghc(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- ${marker} -->\n${body}`])).trim();
60
+ return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
61
+ };
62
+ const label = async (state) => { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } };
63
+ const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
63
64
 
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;
65
+ // ---- lease (same FORGE:STATE lease the full path uses) ----
66
+ const agentId = o.agentId ?? `core_${process.pid}`;
67
+ const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
68
+ let leaseState = null, leaseTimer = null;
69
+ const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
70
+ const renew = async () => { if (io.projector && leaseState) { try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ } } };
71
+ async function releaseLease(reason) {
72
+ if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
73
+ if (!io.projector || !leaseState) return;
74
+ const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
75
+ try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
70
76
  }
71
77
 
72
78
  let done = null, wtCreated = false;
73
- const WORKFLOW = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
74
79
  async function finish(reason, detail = "", extra = {}) {
75
80
  if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
76
81
  const code = codeForTerminalReason(reason);
77
82
  ev({ event: "RUN_TERMINAL", reason, code });
78
83
  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
84
  if (["needs-human", "engine-error"].includes(reason)) {
81
- try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", WORKFLOW]); } catch { /* best effort */ }
85
+ const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
86
+ try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
82
87
  }
83
88
  await releaseLease(reason);
84
- // The worktree is scratch; the branch stays when a PR (or a human) may need it.
85
89
  if (wtCreated) {
86
90
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
87
91
  if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
88
92
  }
89
- done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usageTotal.cost_usd, wall_ms: now() - t0, ...extra };
93
+ done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, ...extra };
90
94
  return done;
91
95
  }
92
96
  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 */ }
97
+ try {
98
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
99
+ await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
100
+ } catch { /* best effort */ }
94
101
  return finish("needs-human", why);
95
102
  }
96
- async function label(state) { try { await io.transitionLabel(issue, state); } catch { /* labels are best effort */ } }
97
103
 
98
- async function builder(phase, n, prompt, resume) {
99
- onProgress({ event: "phase_enter", phase });
104
+ async function builder(n, prompt, resume) {
105
+ onProgress({ event: "phase_enter", phase: "build" });
100
106
  await renew();
101
- const r = await io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume,
102
- system: resume ? "" : BUILDER_SYSTEM, prompt });
107
+ const r = await io.runner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt });
103
108
  await renew();
104
109
  if (r?.sessionId) sessionId = r.sessionId;
110
+ await attempt("implement", n, r);
111
+ onProgress({ event: "phase_exit", phase: "build", status: "committed" });
105
112
  return r;
106
113
  }
107
- const readJson = async (n) => { try { return JSON.parse(await io.fs.read(file(n))); } catch { return null; } };
114
+ const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
108
115
 
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.
116
+ // ---- admission: Depends-on refusal, then lease ----
124
117
  {
125
118
  let dep = null;
126
119
  try { dep = await openDependency(issue, io); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
@@ -132,18 +125,17 @@ export async function runLean(o) {
132
125
  }
133
126
  if (io.projector) {
134
127
  let st = null;
135
- try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed, the write below is best effort */ }
128
+ try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
136
129
  if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
137
130
  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
131
  }
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 };
132
+ leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
141
133
  try { await writeLease({ by: agentId, until: now() + leaseTtlMs }); } catch { /* best effort */ }
142
134
  leaseTimer = setInterval(() => { writeLease({ by: agentId, until: now() + leaseTtlMs }).catch(() => {}); }, o.leaseRenewMs ?? 120000);
143
135
  leaseTimer.unref?.();
144
136
  }
145
137
 
146
- ev({ event: "RUN_START", run: `lean_${issue}_${t0}`, issue, lane, path: "lean", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
138
+ ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
147
139
  let iss;
148
140
  try {
149
141
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
@@ -151,9 +143,12 @@ export async function runLean(o) {
151
143
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
152
144
 
153
145
  try {
146
+ // ---- 1. claim ----
154
147
  await label("investigating");
148
+ await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
149
+
150
+ // ---- 2. worktree (resume an open PR / remote branch) ----
155
151
  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
152
  let resumed = null;
158
153
  try {
159
154
  const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid"])) || "[]");
@@ -171,178 +166,113 @@ export async function runLean(o) {
171
166
  wtCreated = true;
172
167
  const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
173
168
  await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
169
+ const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
174
170
 
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)." };
171
+ // ---- 3. builder ----
172
+ let result = null, attemptN = 0, validateUrl = null, planUrl = null, reviewUrl = null;
173
+ if (!(resumed && (await changedFiles()).length > 0)) {
174
+ await label("building");
175
+ await builder(++attemptN, builderPrompt(issue), null);
176
+ result = await readResult();
177
+ const files0 = await changedFiles();
178
+ const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
179
+ const verdict = result ? (result.status === "fixed" ? "valid" : result.status) : null;
180
+ const closing = ["invalid", "already-fixed"].includes(result?.status);
181
+ if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`);
182
+ // The validation comment (no model call): verdict, evidence, one-line summary. For invalid/already-fixed it IS the evidence comment.
183
+ if (verdict) validateUrl = await post("FORGE:VALIDATE", renderValidate(result, verdict));
184
+ if (closing) {
185
+ await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
186
+ await label("invalid");
187
+ return await finish("invalid", result.summary ?? result.status);
195
188
  }
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");
189
+ if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`);
190
+ if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`);
191
+ planUrl = await post("FORGE:PLAN", renderPlan(result, files0));
267
192
  }
268
193
 
269
- // ---- review loop ----
194
+ // ---- 4. review (one retry through the same builder session) ----
270
195
  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`]));
196
+ let review = null, reviewN = 0;
197
+ const doReview = async () => {
198
+ for (let t = 0; t < 2; t++) {
199
+ const diff = String(await git(["diff", `${base}...HEAD`]));
275
200
  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 }) });
201
+ const rr = await io.runner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
202
+ prompt: reviewerPrompt({ issue: iss, diff }) });
278
203
  await renew();
279
- await attempt("review", ++reviewAttempt, rr);
280
- const f = parseFindings(rr?.text);
281
- if (f) return f.map((x) => ({ ...x, reviewer: role }));
204
+ await attempt("review", ++reviewN, rr);
205
+ const v = parseVerdict(rr?.text);
206
+ if (v) return v;
282
207
  }
283
208
  return null;
284
209
  };
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;
210
+ review = await doReview();
211
+ if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.");
212
+ if (review.fail) {
213
+ if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`);
214
+ await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
215
+ result = (await readResult()) ?? result;
216
+ review = await doReview();
217
+ if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).");
218
+ if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`);
312
219
  }
220
+ reviewUrl = await post("FORGE:REVIEW", renderReview(review));
221
+ ev({ event: "PHASE_COMMIT", phase: "review" });
313
222
 
314
- // ---- merge and close (no model) ----
223
+ // ---- 5. push + PR ----
224
+ const files = await changedFiles();
315
225
  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
226
  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();
227
+ "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, result, gitFiles: files })])).trim();
321
228
  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)}`);
229
+ if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
323
230
  ev({ event: "PR_OPENED", pr });
231
+ const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
232
+ const localHead = String(await git(["rev-parse", "HEAD"])).trim();
324
233
 
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));
234
+ // ---- 6. the register comment ----
235
+ // merge policy: required (default) | all | none
236
+ let policy = mergeWait, required = [];
237
+ if (mergeWait === "required") {
238
+ try { required = (await io.requiredChecks?.(lane)) ?? []; } catch { required = []; }
339
239
  }
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.`);
240
+ const policyText = mergeWait === "required" ? (required.length ? `required (${required.join(", ")})` : "required (no required checks: merged right after review)") : mergeWait;
241
+ await post(REGISTER_MARKER, renderRegister({ result, gitFiles: files, review, links: { validate: validateUrl, plan: planUrl, review: reviewUrl }, prUrl, usage: usageLine(), policy: policyText }));
242
+
243
+ // ---- 7. wait per policy, merge at the reviewed head ----
244
+ if (policy === "none" || (policy === "required" && !required.length)) {
245
+ // merge right after review
246
+ } else {
247
+ const startedAt = now();
248
+ let pollIndex = 0, gateHead = null;
249
+ for (;;) {
250
+ await renew();
251
+ if (policy === "all") {
252
+ const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
253
+ if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
254
+ if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`);
255
+ } else {
256
+ let states = [];
257
+ try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
258
+ const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
259
+ const failed = required.filter((n) => st(n) === "failure");
260
+ if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`);
261
+ if (required.every((n) => st(n) === "success")) break;
262
+ }
263
+ if (now() - startedAt >= ciMaxMinutes * 60000) {
264
+ // Slow CI is never a needs-human: leave the PR open and resumable.
265
+ ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
266
+ return await finish("ci-pending", `PR #${pr} still waiting on CI after ${ciMaxMinutes} min`, { pr });
267
+ }
268
+ onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
269
+ await sleep(ciPollDelayMs(pollIndex++, random));
270
+ }
271
+ if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
272
+ return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`);
273
+ }
343
274
  }
344
275
  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
276
  await ghc(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
347
277
  await label("merged");
348
278
  return await finish("merged", `PR #${pr}`, { pr });
@@ -350,3 +280,5 @@ export async function runLean(o) {
350
280
  return finish("engine-error", e?.message ?? String(e));
351
281
  }
352
282
  }
283
+
284
+ export const runLean = runCore;