forgedock 1.25.0 → 1.26.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
@@ -10,17 +10,25 @@ import { hostname as osHostname } from "node:os";
10
10
  import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
- import { openDependency } from "../engine/phases.mjs";
13
+ import { openDependency, isTrustedComment } from "../engine/phases.mjs";
14
+ import { createHash } from "node:crypto";
15
+ import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking } from "./conformance.mjs";
14
16
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
17
  import { resolveLane, isDefaultLane } from "./lane.mjs";
16
18
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
17
- import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
18
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, parseVerdict, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
19
+ import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
20
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
19
21
 
20
22
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
23
+ /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
24
+ export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
25
+ /** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
26
+ const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
21
27
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
22
28
  const num = (v) => (Number.isFinite(v) ? v : null);
23
29
  export const MAX_SUBISSUES = 8;
30
+ /** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
31
+ export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
24
32
 
25
33
  /**
26
34
  * Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
@@ -60,10 +68,11 @@ export function classifyBlock(result, issue) {
60
68
  * @param {string} [o.builderModel] default: CLI default
61
69
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
62
70
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
71
+ * @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
63
72
  */
64
73
  export async function runCore(o) {
65
74
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
66
- ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
75
+ ciMaxMinutes = 360, conformance = "shadow", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
67
76
  const now = io.now ?? (() => Date.now());
68
77
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
69
78
  let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
@@ -160,7 +169,6 @@ export async function runCore(o) {
160
169
  // ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
161
170
  const STAGES = [
162
171
  { key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
163
- { key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
164
172
  { key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
165
173
  ];
166
174
  const posted = {}; // key -> comment url (or "" when posted without a link)
@@ -176,10 +184,25 @@ export async function runCore(o) {
176
184
  const url = await post(st.marker, st.render(md, { result, files }));
177
185
  posted[st.key] = url ?? "";
178
186
  }
187
+ /** Re-render a stage note that changed after a resume: edit the posted comment in place; post it when it was never posted or the edit fails. */
188
+ async function upsertStage(key) {
189
+ const st = STAGES.find((x) => x.key === key);
190
+ const id = /#issuecomment-(\d+)/.exec(posted[key] ?? "")?.[1];
191
+ const res = await readResult(), md = await readStage(st);
192
+ if (id) {
193
+ const files = key === "implementation" ? await stageFiles() : [];
194
+ const marks = st.marker.map((m) => `<!-- ${m} -->`).join("\n");
195
+ try { await ghw(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${id}`, "-f", `body=${capComment(`${marks}\n${st.render(usableMd(md) ? md : null, { result: res, files })}`)}`]); return; } catch { /* fall back to a new comment */ }
196
+ }
197
+ delete posted[key];
198
+ await flushStages(res, [key]);
199
+ }
179
200
  /** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
180
201
  const seen = {};
202
+ let liveKeys = ["investigation"]; // which stage notes the running builder call may publish live (plan: investigation; implement: implementation)
181
203
  async function livePoll() {
182
204
  for (const st of STAGES) {
205
+ if (!liveKeys.includes(st.key)) continue;
183
206
  if (st.key in posted) continue;
184
207
  let m = null;
185
208
  try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
@@ -244,6 +267,8 @@ export async function runCore(o) {
244
267
  } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); }
245
268
  }
246
269
  const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
270
+ const PLAN_RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD` to see what exists, but plan against the issue itself. This step is still planning only: do not edit code, do not commit, and do not write the result file unless the issue is invalid, already fixed or blocked.";
271
+ const RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD`, verify that work against the contract, complete what is missing, re-run every validate command and write the result file. Do not start over.";
247
272
 
248
273
  /** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
249
274
  function startGovernor(control) {
@@ -267,7 +292,9 @@ export async function runCore(o) {
267
292
  return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
268
293
  }
269
294
 
270
- async function builder(n, prompt, resume, live = false) {
295
+ /** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
296
+ const implementRestart = () => builderImplementPrompt() + CONTINUE_NOTE.replace("BASE", lane);
297
+ async function builder(n, prompt, resume, live = false, restart = implementRestart) {
271
298
  let timeoutRetries = 0, p = prompt;
272
299
  for (;;) {
273
300
  try { return await builderOnce(n, p, resume, live); } catch (e) {
@@ -279,7 +306,7 @@ export async function runCore(o) {
279
306
  if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
280
307
  T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
281
308
  T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
282
- p = builderPrompt(issue) + CONTINUE_NOTE.replace("BASE", base); resume = null;
309
+ p = restart(); resume = null;
283
310
  }
284
311
  }
285
312
  }
@@ -312,6 +339,7 @@ export async function runCore(o) {
312
339
  onProgress({ event: "phase_exit", phase: "build", status: "committed" });
313
340
  return r;
314
341
  }
342
+ const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
315
343
  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; } };
316
344
 
317
345
  /** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
@@ -415,7 +443,7 @@ export async function runCore(o) {
415
443
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
416
444
  let iss;
417
445
  try {
418
- iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
446
+ iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
419
447
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
420
448
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
421
449
 
@@ -490,14 +518,19 @@ export async function runCore(o) {
490
518
  await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
491
519
  const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
492
520
 
493
- // ---- 3. builder ----
521
+ // ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
494
522
  let result = null, attemptN = 0, reviewN = 0;
495
523
  const lastSubject = resumed ? String(await git(["log", "-1", "--format=%s"]).catch(() => "")).trim() : "";
496
524
  const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
497
- if (!(resumed && (await changedFiles()).length > 0) || fromCheckpoint) {
498
- await label("building");
499
- await builder(++attemptN, builderPrompt(issue) + (fromCheckpoint ? CONTINUE_NOTE.replace("BASE", base) : ""), null, true);
500
- result = await readResult();
525
+ const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
526
+ // Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
527
+ const trustedComments = (iss.comments ?? []).filter((c) => {
528
+ const login = String(c?.author?.login ?? c?.user?.login ?? "");
529
+ return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
530
+ });
531
+ const issueText = [iss.title, iss.body ?? "", ...trustedComments.map((c) => c?.body ?? "")].join("\n\n");
532
+ /** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
533
+ async function endIfBlocked() {
501
534
  let block = result?.status === "blocked" ? await resolveBlock(result) : null;
502
535
  if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
503
536
  T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
@@ -505,70 +538,204 @@ export async function runCore(o) {
505
538
  result = await readResult();
506
539
  block = result?.status === "blocked" ? await resolveBlock(result) : null;
507
540
  }
508
- const files0 = await changedFiles();
541
+ return block ? await endBlocked(block, result.summary) : null;
542
+ }
543
+ /** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
544
+ async function track(reqs, why) {
545
+ const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
546
+ if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
547
+ const items = followupItems(reqs);
548
+ let filed;
549
+ try { filed = await io.fileFollowups({ parent: issue, repo, items }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
550
+ for (const [i, r] of reqs.entries()) {
551
+ const n = Number(filed?.[items[i].key]);
552
+ if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
553
+ let st = null;
554
+ try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
555
+ if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
556
+ r.tracked = `#${n}`;
557
+ }
558
+ T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
559
+ }
560
+ /** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
561
+ async function readFrozenContract() {
562
+ let out = "";
563
+ try { out = String(await ghc(["api", "--paginate", `repos/${repo}/issues/${issue}/comments`, "--jq", ".[] | {body, author_association, user: {type: .user.type, login: .user.login}}"])); } catch { return null; }
564
+ let found = null;
565
+ for (const l of out.split("\n")) {
566
+ let c; try { c = JSON.parse(l); } catch { continue; }
567
+ if (!String(c?.body ?? "").startsWith("<!-- FORGE:CONTRACT -->") || !isTrustedComment(c)) continue;
568
+ const fc = parseContractComment(c.body);
569
+ if (fc && contractHash(fc.text) === fc.hash) found = fc;
570
+ }
571
+ return found;
572
+ }
573
+
574
+ let frozen = null, hash = null, planned = false;
575
+ if (resumed) {
576
+ const fc = await readFrozenContract();
577
+ if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
578
+ }
579
+ await label("building");
580
+ if (!frozen) {
581
+ await builder(++attemptN, builderPlanPrompt(issue) + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, () => builderPlanPrompt(issue));
582
+ planned = true;
583
+ result = await readResult();
509
584
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
510
- const closing = ["invalid", "already-fixed"].includes(result?.status);
511
- await flushStages(result, ["investigation"]); // first stage note: the evidence comment for invalid/already-fixed
512
- T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" : String(result.status).toUpperCase()) : "no result.json", `${evidence.length} evidence item(s)`];
513
- T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
514
- 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)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
515
- if (closing) {
585
+ const closes = ["invalid", "already-fixed"].includes(result?.status);
586
+ if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
587
+ T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
588
+ if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
589
+ if (closes) {
516
590
  T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
591
+ T.decisions.push(`Builder verdict: ${result.status}`);
517
592
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
518
593
  await label("invalid");
519
594
  return await finish("invalid", result.summary ?? result.status);
520
595
  }
521
- if (block) return await endBlocked(block, result.summary);
522
- if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
523
- await flushStages(result, ["plan", "implementation"]);
524
- T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
596
+ const blocked = await endIfBlocked();
597
+ if (blocked) return blocked;
598
+ let contract = await readContract();
599
+ let vc = validateContract(contract, issueText);
600
+ if (!vc.ok && sessionId) {
601
+ T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
602
+ await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
603
+ contract = await readContract();
604
+ vc = validateContract(contract, issueText);
605
+ }
606
+ await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
607
+ if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID);
608
+ frozen = contract;
609
+ hash = contractHash(contractJson(frozen));
610
+ let body = renderContract(frozen, hash);
611
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
612
+ await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
613
+ const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
614
+ ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
615
+ T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
525
616
  } else {
526
- T.validate = "resumed (existing branch)"; T.plan = "skipped (resumed)"; T.implement = ["existing commits", branch];
527
- T.decisions.push("Open PR or remote branch found: builder skipped, reviewing existing commits");
617
+ T.validate = "resumed (existing branch)";
618
+ }
619
+ T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
620
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
621
+ const implementPrompt = builderImplementPrompt() + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane));
622
+ liveKeys = ["implementation"];
623
+ await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
624
+ result = await readResult();
625
+ {
626
+ const blocked = await endIfBlocked();
627
+ if (blocked) return blocked;
528
628
  }
629
+ let files = await changedFiles();
630
+ if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
631
+ await flushStages(result, ["implementation"]);
632
+ T.implement = [`${files.length} file(s)`, branch];
633
+ T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
529
634
 
530
- // ---- 4. review (one retry through the same builder session) ----
635
+ // ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
636
+ // enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
637
+ // but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression).
531
638
  await label("in-review");
532
- let review = null;
533
- const doReview = async () => {
639
+ const shadow = conformance !== "enforce";
640
+ let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
641
+ async function amend() {
642
+ const a = applyAmendments(frozen, result?.amendments, issueText);
643
+ const sig = JSON.stringify(a.applied);
644
+ if (a.applied.length && sig !== lastAmend) {
645
+ lastAmend = sig;
646
+ await post("FORGE:AMENDMENT", renderAmendments(a.applied));
647
+ T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
648
+ }
649
+ effective = a.contract;
650
+ return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
651
+ }
652
+ const IGNORE = [`${LEAN_DIR}/`];
653
+ /** Up to two reviewer runs; a reply without usable rows (unparseable, incomplete, inconsistent) is re-asked, never charged to the builder. */
654
+ async function reviewOnce() {
655
+ const diff = String(await git(["diff", `${base}...HEAD`]));
656
+ const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
657
+ const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
658
+ const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
659
+ let d = null;
534
660
  for (let t = 0; t < 2; t++) {
535
- const diff = String(await git(["diff", `${base}...HEAD`]));
536
- // #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
537
- const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
538
661
  await renew();
539
662
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
540
- prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
663
+ prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base }) });
541
664
  await renew();
542
665
  await attempt("review", ++reviewN, rr);
543
- const v = parseVerdict(rr?.text);
544
- if (v) return v;
666
+ review = parseReview(rr?.text);
667
+ d = decide(review, ctx);
668
+ ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
669
+ if (!d.state.startsWith("review.")) return d;
545
670
  }
546
- return null;
547
- };
548
- review = await doReview();
549
- if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.", OUTCOME_CODES.REVIEW_NO_VERDICT);
550
- if (review.fail) {
551
- if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
552
- T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
553
- await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
671
+ return d;
672
+ }
673
+ /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
674
+ async function gate() {
554
675
  result = (await readResult()) ?? result;
555
- review = await doReview();
556
- if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).", OUTCOME_CODES.REVIEW_NO_VERDICT);
557
- if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
676
+ files = await changedFiles();
677
+ const amendProblems = await amend();
678
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE });
679
+ const pre = [...pc.failures, ...amendProblems];
680
+ if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
681
+ if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
682
+ const d = await reviewOnce();
683
+ if (!shadow) return { ...d, by: "review" };
684
+ // shadow: record what enforce would have done; block only on what the previous reviewer blocked on
685
+ const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
686
+ const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
687
+ const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
688
+ return { state: d.state === "review.unparseable" ? d.state : blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
689
+ }
690
+ /** Shape the base-sync path expects: null when no usable review, else {fail, verdict, reason, failures}. */
691
+ const doReview = async () => {
692
+ const d = await gate();
693
+ if (d.state.startsWith("review.")) return null;
694
+ return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
695
+ };
696
+ for (;;) {
697
+ const outcome = await gate();
698
+ verdict = outcome;
699
+ if (outcome.state === "conforms") break;
700
+ const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
701
+ if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
702
+ if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
703
+ if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
704
+ if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
705
+ resumes++;
706
+ T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
707
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures), sessionId);
708
+ await upsertStage("implementation"); // the published note follows the code after every resume
709
+ }
710
+ // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
711
+ const toFile = pendingTracking(effective);
712
+ if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
713
+ if (shadow) {
714
+ const wf = verdict.wouldFail ?? [];
715
+ ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
716
+ await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
717
+ T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
718
+ await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
719
+ } else {
720
+ T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
721
+ await post("FORGE:REVIEW", renderReview(review, effective));
558
722
  }
559
- T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
560
- await post("FORGE:REVIEW", renderReview(review));
561
723
  ev({ event: "PHASE_COMMIT", phase: "review" });
724
+ const closing = closePlan(shadow ? { requirements: [] } : effective);
562
725
 
563
726
  // ---- 5. push + PR ----
564
- const files = await changedFiles();
727
+ files = await changedFiles();
565
728
  await gitPush(["push", "-u", "origin", branch]);
566
729
  laneGuard(lane, "open a PR");
567
730
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
568
- "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
731
+ "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
569
732
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
570
733
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
571
734
  ev({ event: "PR_OPENED", pr });
735
+ if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
736
+ try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
737
+ catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
738
+ }
572
739
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
573
740
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
574
741
  let localHead = String(await git(["rev-parse", "HEAD"])).trim();
@@ -634,7 +801,7 @@ export async function runCore(o) {
634
801
  let rv = await doReview();
635
802
  if (rv?.fail) {
636
803
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
637
- await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
804
+ await builder(++attemptN, conformanceResumePrompt(rv.failures), sessionId ?? null);
638
805
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
639
806
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
640
807
  rv = await doReview();
@@ -709,6 +876,9 @@ export async function runCore(o) {
709
876
  }
710
877
  }
711
878
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
879
+ if (closing.tracked.length) {
880
+ try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
881
+ }
712
882
  await label("merged");
713
883
  return await finish("merged", `PR #${pr}`, { pr });
714
884
  } catch (e) {