forgedock 1.28.0 → 1.29.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.
Files changed (53) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +71 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +333 -0
  7. package/bin/lean/context.test.mjs +279 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +171 -35
  10. package/bin/lean/lean.test.mjs +413 -7
  11. package/bin/lean/orchestrate.mjs +80 -3
  12. package/bin/lean/prefilter.mjs +2 -2
  13. package/bin/lean/prefilter.test.mjs +3 -0
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +230 -0
  16. package/bin/lean/render.mjs +25 -5
  17. package/bin/lean/testmap.mjs +84 -0
  18. package/bin/lean/testmap.test.mjs +58 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/orchestrate.md +52 -6
  26. package/docs/CONFIG.md +15 -0
  27. package/docs/OUTCOME-CODES.md +14 -0
  28. package/docs/RELEASE-NOTES.md +9 -0
  29. package/package.json +1 -1
  30. package/scripts/check-spec-markers.sh +1 -0
  31. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  32. package/scripts/fixtures/test-map/go/Makefile +5 -0
  33. package/scripts/fixtures/test-map/go/README.md +1 -0
  34. package/scripts/fixtures/test-map/go/go.mod +3 -0
  35. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  36. package/scripts/fixtures/test-map/go/main.go +3 -0
  37. package/scripts/fixtures/test-map/node/package.json +10 -0
  38. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  39. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  40. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  41. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  42. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  43. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  44. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  45. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  46. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  47. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  48. package/scripts/repo-facts.sh +146 -0
  49. package/scripts/repo-facts.test.sh +106 -0
  50. package/scripts/test-map.sh +211 -0
  51. package/scripts/test-map.test.sh +165 -0
  52. package/scripts/wait-ci-green.sh +10 -4
  53. package/scripts/wait-ci-green.test.sh +9 -0
@@ -3,7 +3,7 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
6
+ import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, renameSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -14,6 +14,7 @@ import { makeProjector } from "../engine/projector.mjs";
14
14
  import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
15
15
  import { makeDepCache } from "./depcache.mjs";
16
16
  import { makeHeavyIo } from "./heavy.mjs";
17
+ import { gatherContextFacts } from "./context.mjs";
17
18
 
18
19
  const pexec = promisify(execFile);
19
20
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -87,6 +88,8 @@ export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {})
87
88
  }
88
89
 
89
90
  export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
91
+ /** Timeout of the Checks API POST that reports the forgedock/verify check run (#4270). */
92
+ export const CHECK_RUN_TIMEOUT_MS = 60000;
90
93
  export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
91
94
  const OWNER = ".forge-run-owner";
92
95
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e?.code !== "ESRCH"; } };
@@ -251,6 +254,16 @@ export function makeLeanIo({ repo }) {
251
254
  return { name: c.name, state, url: c.url ?? null };
252
255
  });
253
256
  },
257
+ /**
258
+ * #4270 verify.check_run: one completed check run on `sha` (`gh api -X POST repos/<repo>/check-runs`, JSON on stdin).
259
+ * Needs a token allowed to create check runs (GitHub App, checks:write). Resolves {id, html_url}; throws on failure.
260
+ */
261
+ async createCheckRun({ sha, name, conclusion, title, summary, text }) {
262
+ const body = JSON.stringify({ name, head_sha: sha, status: "completed", conclusion, output: { title, summary, text } });
263
+ const out = await runWithInput("gh", ["api", "-X", "POST", `repos/${repo}/check-runs`, "--input", "-"], body, CHECK_RUN_TIMEOUT_MS);
264
+ const j = JSON.parse(out || "{}");
265
+ return { id: j.id ?? null, html_url: j.html_url ?? null };
266
+ },
254
267
  /** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
255
268
  async failedLogs(runId) {
256
269
  try { const out = String(await run("gh")(["run", "view", String(runId), "-R", repo, "--log-failed"])); return out.length > 64000 ? out.slice(-64000) : out; } catch { return ""; }
@@ -286,6 +299,8 @@ export function makeLeanIo({ repo }) {
286
299
  async structureDelta({ cwd, base }) {
287
300
  try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
288
301
  },
302
+ /** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
303
+ contextFacts: (args) => gatherContextFacts({ ...args, io: { gh: run("gh"), git: run("git"), exists: async (p) => existsSync(p) } }),
289
304
  /** forge#4195: scripts/hotspots.sh facts for `paths` in cwd (growth since base when given); null on any failure. */
290
305
  async hotspots({ cwd, base = null, paths = [] }) {
291
306
  const args = [script("hotspots.sh"), ...(base ? ["--base", base] : []), "--", ...paths];
@@ -324,6 +339,9 @@ export function makeLeanIo({ repo }) {
324
339
  stat: async (p) => { try { return { mtimeMs: statSync(p).mtimeMs }; } catch { return null; } },
325
340
  append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
326
341
  write: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); },
342
+ rename: async (from, to) => renameSync(from, to),
343
+ list: async (d) => { try { return readdirSync(d); } catch { return []; } },
344
+ rm: async (p) => rmSync(p, { force: true }),
327
345
  },
328
346
  };
329
347
  }
package/bin/lean/lean.mjs CHANGED
@@ -12,24 +12,36 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
14
14
  import { createHash } from "node:crypto";
15
- import { TRACKED, isBugIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
15
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
16
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
17
  import { namedPaths } from "./prefilter.mjs";
18
18
  import { resolveLane, isDefaultLane } from "./lane.mjs";
19
19
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
20
20
  import { repoKey } from "../runner.mjs";
21
21
  import { prepareToolchain } from "./toolchain.mjs";
22
+ import { readTestMap, readTestMapAtRef, selectCommands, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
22
23
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
23
24
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
24
25
  import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
25
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
26
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
26
27
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
27
- import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES } from "./verify.mjs";
28
+ import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
28
29
  import { laneBackendId } from "./limits.mjs";
29
30
  import { SPLIT_LABEL } from "./refactor.mjs";
30
31
  import { writeHeavyShims, sumWaits } from "./heavy.mjs";
31
32
  import { priorityFromLabels } from "./sched.mjs";
33
+ import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts } from "./context.mjs";
32
34
 
35
+ /**
36
+ * #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
37
+ * with RECOVERABLE_CODES (bin/engine/outcome-codes.mjs); recovery.test.mjs checks it (#4298).
38
+ */
39
+ export const RECOVER_HOW = Object.freeze({
40
+ [OUTCOME_CODES.CI_RED]: "the PR's required checks failed. Read the failed checks' logs (`gh run view <run-id> --log-failed`, or the repo's declared check commands), fix the causes, re-run those checks locally and push new commits only (never rewrite history)",
41
+ [OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
42
+ [OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
43
+ [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
44
+ });
33
45
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
34
46
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
35
47
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
@@ -116,6 +128,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
116
128
  * @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
117
129
  * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
118
130
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
131
+ * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
132
+ * @param {object} [o.recover] {code, attempt}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
133
+ * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note
119
134
  */
120
135
  export async function runCore(o) {
121
136
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
@@ -128,6 +143,8 @@ export async function runCore(o) {
128
143
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
129
144
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
130
145
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
146
+ // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt}).
147
+ const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1 } : null;
131
148
  const branch = `feat/issue-${issue}-lean`;
132
149
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
133
150
  const t0 = now();
@@ -174,17 +191,23 @@ export async function runCore(o) {
174
191
  let leaseState = null, leaseTimer = null;
175
192
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
176
193
  const renew = async () => { if (io.projector && leaseState && !finishing) { /* never re-take a lease a finish (or signal) released */ try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
177
- async function releaseLease(reason) {
194
+ async function releaseLease(reason, code = null) {
178
195
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
179
196
  if (!io.projector || !leaseState) return;
180
197
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
181
- try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
198
+ // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
199
+ const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
200
+ try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
182
201
  }
183
202
 
184
203
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
185
204
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
205
+ const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
206
+ const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
207
+ let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
186
208
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
187
209
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
210
+ let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
188
211
  const runId = `${issue}-${t0}`;
189
212
  let runTmp = null;
190
213
  let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
@@ -293,9 +316,19 @@ export async function runCore(o) {
293
316
  const code = codeOverride ?? codeForTerminalReason(reason);
294
317
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
295
318
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
296
- conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}) };
319
+ conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
320
+ context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
297
321
  ev(term);
298
322
  await emit(term);
323
+ // #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
324
+ // decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
325
+ // (orchestrate settle, relabelNeedsHuman) relabels those once they are final.
326
+ if (recoverUnlabelled && reason === "ci-pending") {
327
+ try {
328
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${recoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
329
+ await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
330
+ } catch { /* best effort */ }
331
+ }
299
332
  if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
300
333
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
301
334
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
@@ -306,17 +339,19 @@ export async function runCore(o) {
306
339
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
307
340
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
308
341
  }
309
- await releaseLease(reason);
342
+ await releaseLease(reason, code);
310
343
  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,
311
344
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
312
345
  return done;
313
346
  }
314
- async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR) {
347
+ /** #4297: what a recovery lane that did not merge tried and how it ended; the issue stays needs-human with this. */
348
+ const recoveryEvidence = (reason, code, detail) => `Recovery pass for \`${recover.code}\` (attempt ${recover.attempt}) did not merge: it ended \`${reason}\`${code ? ` (\`${code}\`)` : ""}${String(detail ?? "").trim() ? `: ${String(detail).trim().split("\n")[0].slice(0, 300)}` : ""}. The issue stays \`needs-human\`.`;
349
+ async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
315
350
  try {
316
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
351
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${recoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
317
352
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
318
353
  } catch { /* best effort */ }
319
- return finish("needs-human", why, { code }, code);
354
+ return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
320
355
  }
321
356
 
322
357
  // ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
@@ -435,6 +470,7 @@ export async function runCore(o) {
435
470
  } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
436
471
  }
437
472
  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.";
473
+ const recoverNote = () => (recover ? `\n\nRecovery pass (${recover.code}): ${(RECOVER_HOW[recover.code] ?? "an earlier run ended needs-human for a mechanical reason; resolve it").replaceAll("BASE", lane)}.` : "");
438
474
  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.";
439
475
  const SALVAGE_NOTE = "\n\nAn earlier run on this issue stopped with uncommitted work, saved at `REF` (not on the branch). Inspect it with `git diff HEAD REF --stat` and reuse what fits the contract (`git checkout REF -- <path>`) instead of rewriting it.";
440
476
  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, ${vcfg.mode === "ci" ? "re-run the light checks (the engine verifies on CI)" : "re-run every validate command"} and write the result file. Do not start over.`;
@@ -465,7 +501,8 @@ export async function runCore(o) {
465
501
  }
466
502
 
467
503
  /** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
468
- const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse });
504
+ let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
505
+ const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
469
506
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
470
507
  const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
471
508
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
@@ -654,7 +691,7 @@ export async function runCore(o) {
654
691
  }
655
692
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
656
693
  try {
657
- iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
694
+ iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
658
695
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
659
696
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
660
697
 
@@ -662,6 +699,16 @@ export async function runCore(o) {
662
699
  // ---- 1. claim ----
663
700
  await label("investigating");
664
701
  await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
702
+ if (recover) {
703
+ // #4267: the batch decided to recover this issue itself; the label goes so the issue is live again.
704
+ try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", "needs-human"]); } catch { /* best effort */ }
705
+ recoverUnlabelled = true;
706
+ try {
707
+ await post("FORGE:DECISION", `## Decision: recovery pass for \`${recover.code}\` (attempt ${recover.attempt})\n**What**: removed \`needs-human\` and resumed the open PR / branch on \`${lane}\` (${(RECOVER_HOW[recover.code] ?? "rerun").split(".")[0]}).\n**Why**: \`${recover.code}\` is a mechanical outcome an agent can fix without operator input.\n**Alternative**: leave the issue \`needs-human\` and skip its dependents until the operator relaunches it.`);
708
+ } catch { /* best effort */ }
709
+ T.decisions.push(`Recovery pass for ${recover.code} (attempt ${recover.attempt}): needs-human removed, PR resumed`);
710
+ ev({ event: "RECOVERY_START", code: recover.code, attempt: recover.attempt });
711
+ }
665
712
 
666
713
  // ---- 2. worktree (resume an open PR / remote branch) ----
667
714
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
@@ -832,18 +879,45 @@ export async function runCore(o) {
832
879
  const login = String(c?.author?.login ?? c?.user?.login ?? "");
833
880
  return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
834
881
  });
835
- const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => c?.body ?? "")].join("\n\n");
836
- let trustedComments = trustedOf(iss);
882
+ // #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
883
+ const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
884
+ const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
885
+ let trustedComments = trustedOf(iss), issueEdited = false;
837
886
  let issueText = textOf(iss, trustedComments);
887
+ let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
888
+ ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
889
+ /**
890
+ * forge#4275: compile (or read from the cache) the context pack, log CONTEXT_PACK and post FORGE:CONTEXT. Off, no
891
+ * io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
892
+ */
893
+ async function contextPackStep() {
894
+ if (ctxCfg.mode === "off" || !io.contextFacts) return null;
895
+ try {
896
+ const headSha = String(await git(["rev-parse", base])).trim();
897
+ const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { updatedAt: iss.updatedAt ?? null, attempts: priorAttempts(iss.body, trustedComments) },
898
+ gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
899
+ const p = r.pack;
900
+ const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
901
+ hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
902
+ ev(rec); await emit(rec);
903
+ const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
904
+ if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
905
+ T.decisions.push(`Context pack (${ctxCfg.mode}): ${p.items} item(s), ${p.bytes} bytes${p.truncated ? `, ${p.truncated} dropped` : ""}, ${ctxCfg.mode === "on" ? "given to the builder" : "recorded only"}`);
906
+ return p;
907
+ } catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
908
+ }
838
909
  /** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
839
910
  async function refreshIssue(stage) {
840
911
  let next;
841
- try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]))); } catch { return; }
912
+ try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]))); } catch { return; }
842
913
  if (!next || typeof next !== "object") return;
843
914
  const tc = trustedOf(next), text = textOf(next, tc);
844
- const old = contractHash(issueText), now = contractHash(text);
915
+ if (contractHash(issueText) === contractHash(text)) return;
916
+ // #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
917
+ const old = scopeHash, now = contractHash(scopeText(next, tc));
918
+ iss = next; trustedComments = tc; issueText = text; scopeHash = now;
845
919
  if (old === now) return;
846
- iss = next; trustedComments = tc; issueText = text;
920
+ issueEdited = true;
847
921
  ev({ event: "ISSUE_EDITED", old, new: now, stage });
848
922
  T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
849
923
  }
@@ -875,16 +949,21 @@ export async function runCore(o) {
875
949
  }
876
950
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
877
951
  }
878
- /** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
952
+ /**
953
+ * The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
954
+ * (#4265): a trusted comment that the engine did not post came after it.
955
+ */
879
956
  async function readFrozenContract() {
880
957
  let out = "";
881
958
  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; }
882
959
  let found = null;
883
960
  for (const l of out.split("\n")) {
884
961
  let c; try { c = JSON.parse(l); } catch { continue; }
885
- if (!String(c?.body ?? "").startsWith("<!-- FORGE:CONTRACT -->") || !isTrustedComment(c)) continue;
886
- const fc = parseContractComment(c.body);
887
- if (fc && contractHash(fc.text) === fc.hash) found = fc;
962
+ if (!isTrustedComment(c)) continue;
963
+ const b = String(c?.body ?? "");
964
+ if (!b.startsWith("<!-- FORGE:CONTRACT -->")) { if (found && b.trim() && !b.trimStart().startsWith("<!-- FORGE:")) found.humanAfter = true; continue; }
965
+ const fc = parseContractComment(b);
966
+ if (fc && contractHash(fc.text) === fc.hash) found = { ...fc, humanAfter: false };
888
967
  }
889
968
  return found;
890
969
  }
@@ -953,13 +1032,25 @@ export async function runCore(o) {
953
1032
  } else {
954
1033
  if (resumed) {
955
1034
  const fc = await readFrozenContract();
956
- if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
1035
+ if (fc) {
1036
+ // #4265: a contract planned from an older issue scope is stale: re-plan and freeze a new one instead of judging against it
1037
+ await refreshIssue("rerun contract check");
1038
+ const gone = (Array.isArray(fc.contract?.requirements) ? fc.contract.requirements : []).filter((r) => r?.disposition === "deliver" && !inIssue(r.quote, issueText)).map((r) => r.id);
1039
+ const stale = fc.scope ? (fc.scope !== scopeHash ? `issue scope ${fc.scope} -> ${scopeHash}` : null)
1040
+ : fc.humanAfter ? "a human comment was posted after it" : gone.length ? `quote(s) no longer in the issue: ${gone.join(", ")}` : null;
1041
+ if (stale) {
1042
+ ev({ event: "CONTRACT_STALE", hash: fc.hash, scope: fc.scope, now: scopeHash, reason: stale });
1043
+ T.decisions.push(`Rerun: frozen contract ${fc.hash} is stale (${stale}); re-planning`);
1044
+ } else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
1045
+ }
957
1046
  }
958
1047
  await label("building");
959
1048
  hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
960
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn());
1049
+ facts = await factsPath([wt, cwd], io.fs?.exists);
1050
+ const ctxPack = frozen ? null : await contextPackStep();
1051
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
961
1052
  if (!frozen) {
962
- await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
1053
+ await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
963
1054
  planned = true;
964
1055
  result = await readResult();
965
1056
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
@@ -987,11 +1078,11 @@ export async function runCore(o) {
987
1078
  vc = validateContract(contract, issueText, { repro: reproOn() });
988
1079
  }
989
1080
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
990
- 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);
1081
+ 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, issueEdited ? { issueEdited: true } : {});
991
1082
  frozen = contract;
992
1083
  hash = contractHash(contractJson(frozen));
993
- let body = renderContract(frozen, hash);
994
- if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
1084
+ let body = renderContract(frozen, hash, { scope: scopeHash });
1085
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
995
1086
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
996
1087
  const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
997
1088
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
@@ -1001,7 +1092,7 @@ export async function runCore(o) {
1001
1092
  }
1002
1093
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1003
1094
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1004
- const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
1095
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1005
1096
  liveKeys = ["implementation"];
1006
1097
  stage = "implement";
1007
1098
  await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
@@ -1022,17 +1113,28 @@ export async function runCore(o) {
1022
1113
  // the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
1023
1114
  // verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
1024
1115
  let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
1025
- const precheckCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1026
- if (precheckCommands.length && io.runCommand) {
1116
+ // #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
1117
+ const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1118
+ // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1119
+ const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1120
+ const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return m.length ? { commands: m, source: "test-map" } : { commands: learnedCommands, source: "learned" }; };
1121
+ let picked = pickChecks();
1122
+ // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1123
+ const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
1124
+ if (picked.source === "test-map") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.commands.length} of ${testMap.entries.length} entries`);
1125
+ if (picked.commands.length && io.runCommand) {
1027
1126
  for (let round = 0; ; round++) {
1028
1127
  await renew();
1128
+ if (round) picked = pickChecks();
1129
+ const precheckCommands = picked.commands;
1029
1130
  const results = [];
1030
1131
  for (const command of precheckCommands) {
1031
1132
  let r;
1032
1133
  try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1033
1134
  results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1034
1135
  }
1035
- ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
1136
+ ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source === "test-map" ? { source: "test-map" } : {}) });
1137
+ verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1036
1138
  const failed = results.filter((r) => r.rc !== 0);
1037
1139
  if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
1038
1140
  const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
@@ -1074,6 +1176,7 @@ export async function runCore(o) {
1074
1176
  structure = (await structureOf()) ?? structure;
1075
1177
  edges = undeclared();
1076
1178
  }
1179
+ verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
1077
1180
  if (edges.length) {
1078
1181
  return await needsHuman(`The change adds dependencies between workspace packages that the frozen contract does not name${sessionId ? ", after one builder resume" : ""}; nothing merged:\n${edges.slice(0, 15).map((e) => `- ${e.from} → ${e.to} (${e.kind}, \`${e.manifest}\`)`).join("\n")}\n\nAdd each to the contract with a reason, or remove it.`, OUTCOME_CODES.STRUCTURE_UNDECLARED_EDGE);
1079
1182
  }
@@ -1090,6 +1193,7 @@ export async function runCore(o) {
1090
1193
  result = (await readResult()) ?? result;
1091
1194
  docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
1092
1195
  }
1196
+ verifyFacts.docs = docs;
1093
1197
  if (docs && !docs.ok) {
1094
1198
  return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
1095
1199
  }
@@ -1211,6 +1315,7 @@ export async function runCore(o) {
1211
1315
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1212
1316
  const hotspots = await hotspotsOf({ base, paths: files });
1213
1317
  const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
1318
+ Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1214
1319
  const pre = [...pc.failures, ...amendProblems];
1215
1320
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
1216
1321
  if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
@@ -1238,6 +1343,29 @@ export async function runCore(o) {
1238
1343
  const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
1239
1344
  return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
1240
1345
  }
1346
+ const verifyReported = new Set();
1347
+ /**
1348
+ * #4270 verify.check_run: one `forgedock/verify` check run per pushed head, reporting the gates' last results (no gate
1349
+ * logic, never blocks). Off: no call, no event. A missing seam or a Checks API failure is an anomaly, never a lane failure.
1350
+ */
1351
+ async function reportVerify() {
1352
+ if (vcfg.checkRun !== true) return;
1353
+ let head = "";
1354
+ try { head = String(await git(["rev-parse", "HEAD"])).trim(); } catch { return; }
1355
+ if (!head || verifyReported.has(head)) return;
1356
+ verifyReported.add(head);
1357
+ const r = verifyReport({ head, ...verifyFacts, contract: effective, result });
1358
+ let posted = false, url = null, error = null;
1359
+ try {
1360
+ if (!io.createCheckRun) throw new Error("no Checks API seam");
1361
+ const c = await io.createCheckRun({ sha: head, name: VERIFY_CHECK_NAME, conclusion: r.conclusion, title: r.title, summary: r.summary, text: r.text });
1362
+ posted = true; url = c?.html_url ?? null;
1363
+ } catch (e) {
1364
+ error = String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 200);
1365
+ T.anomalies.push(`${VERIFY_CHECK_NAME} check run not posted for ${head.slice(0, 12)}: ${error}`);
1366
+ }
1367
+ ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
1368
+ }
1241
1369
  /** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
1242
1370
  const doReview = async () => {
1243
1371
  const d = await gate();
@@ -1254,8 +1382,10 @@ export async function runCore(o) {
1254
1382
  if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
1255
1383
  const outcome = await gate();
1256
1384
  verdict = outcome;
1385
+ for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
1257
1386
  if (outcome.state === "conforms") break;
1258
- const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1387
+ const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
1388
+ const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1259
1389
  if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
1260
1390
  if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
1261
1391
  degraded = { state: outcome.state, missing: outcome.missing ?? [] };
@@ -1266,8 +1396,10 @@ export async function runCore(o) {
1266
1396
  if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
1267
1397
  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);
1268
1398
  resumes++;
1269
- 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`);
1270
- await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
1399
+ T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")} (${explained.slice(0, 5).map((f) => String(f.detail).slice(0, 160)).join("; ")}); builder resumed once`);
1400
+ // A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
1401
+ if (outcome.failures.some((f) => f?.kind === "validate_failed")) { try { await io.git(["fetch", "origin", lane], { cwd: wt }); } catch { /* offline: the resume uses the local ref */ } }
1402
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
1271
1403
  await upsertStage("implementation"); // the published note follows the code after every resume
1272
1404
  }
1273
1405
  // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
@@ -1322,6 +1454,7 @@ export async function runCore(o) {
1322
1454
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
1323
1455
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
1324
1456
  let localHead = String(await git(["rev-parse", "HEAD"])).trim();
1457
+ await reportVerify();
1325
1458
 
1326
1459
  // ---- 6. merge policy: required (default) | all | none ----
1327
1460
  let policy = mergeWait, required = [];
@@ -1419,6 +1552,7 @@ export async function runCore(o) {
1419
1552
  rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
1420
1553
  }
1421
1554
  localHead = await gitOut(["rev-parse", "HEAD"]);
1555
+ await reportVerify();
1422
1556
  syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
1423
1557
  T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
1424
1558
  await post("FORGE:BASE_SYNC", `## Base Sync\n**Trigger:** ${trigger}\n**Base merged:** \`${base}\` at \`${baseSha}\`\n**Result:** ${clean ? "clean merge" : `conflicts in ${files.map((f) => `\`${f}\``).join(", ") || "unknown files"}`}\n**Resolution:** ${how}\n**Tests run:** ${tests}\n**Re-review:** ${rereview}\n**New head:** \`${localHead.slice(0, 12)}\``);
@@ -1451,6 +1585,7 @@ export async function runCore(o) {
1451
1585
  if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1452
1586
  await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
1453
1587
  localHead = await gitOut(["rev-parse", "HEAD"]);
1588
+ await reportVerify();
1454
1589
  return null;
1455
1590
  }
1456
1591
  /** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
@@ -1512,7 +1647,8 @@ export async function runCore(o) {
1512
1647
  // a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
1513
1648
  const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
1514
1649
  if (policy === "all") {
1515
- const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
1650
+ // #4270: the lane's own report-only check never gates its own merge
1651
+ const w = await io.waitCiGreen(pr, repo, 0, ...(vcfg.checkRun === true ? [{ ignore: [VERIFY_CHECK_NAME] }] : [])).catch(() => ({ rc: 2, text: "" }));
1516
1652
  if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
1517
1653
  if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
1518
1654
  } else {