forgedock 1.28.0 → 1.29.1

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 (60) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +77 -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 +356 -0
  7. package/bin/lean/context.test.mjs +307 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +178 -37
  10. package/bin/lean/lean.test.mjs +441 -7
  11. package/bin/lean/orchestrate.mjs +112 -5
  12. package/bin/lean/prefilter.mjs +81 -16
  13. package/bin/lean/prefilter.test.mjs +91 -2
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +419 -0
  16. package/bin/lean/render.mjs +38 -5
  17. package/bin/lean/testmap.mjs +101 -0
  18. package/bin/lean/testmap.test.mjs +73 -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/deploy-pr.md +5 -4
  26. package/commands/orchestrate.md +54 -7
  27. package/commands/review-pr-staging.md +6 -1
  28. package/docs/CONFIG.md +15 -0
  29. package/docs/OUTCOME-CODES.md +14 -0
  30. package/docs/RELEASE-NOTES.md +9 -0
  31. package/package.json +1 -1
  32. package/scripts/check-spec-markers.sh +1 -0
  33. package/scripts/code-index.sh +33 -33
  34. package/scripts/code-index.test.sh +21 -2
  35. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
  36. package/scripts/fixtures/test-map/go/Makefile +5 -0
  37. package/scripts/fixtures/test-map/go/README.md +1 -0
  38. package/scripts/fixtures/test-map/go/go.mod +3 -0
  39. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  40. package/scripts/fixtures/test-map/go/main.go +3 -0
  41. package/scripts/fixtures/test-map/node/package.json +10 -0
  42. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  43. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  44. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  45. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  46. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  47. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  48. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  49. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  50. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  51. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  52. package/scripts/repo-facts.sh +146 -0
  53. package/scripts/repo-facts.test.sh +106 -0
  54. package/scripts/review-prepare.sh +28 -6
  55. package/scripts/review-prepare.test.sh +22 -2
  56. package/scripts/test-map.sh +317 -0
  57. package/scripts/test-map.test.sh +353 -0
  58. package/scripts/verify-phase-trail.test.sh +44 -44
  59. package/scripts/wait-ci-green.sh +10 -4
  60. package/scripts/wait-ci-green.test.sh +13 -0
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, mergeChecks, 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
- import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
24
+ import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } 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, linkStateStamp } 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,10 @@ 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, final}: 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. `final`
134
+ * (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
119
135
  */
120
136
  export async function runCore(o) {
121
137
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
@@ -128,6 +144,8 @@ export async function runCore(o) {
128
144
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
129
145
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
130
146
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
147
+ // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
148
+ const recover = o.recover && typeof o.recover.code === "string" ? { code: o.recover.code, attempt: Number.isInteger(o.recover.attempt) ? o.recover.attempt : 1, final: o.recover.final !== false } : null;
131
149
  const branch = `feat/issue-${issue}-lean`;
132
150
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
133
151
  const t0 = now();
@@ -174,17 +192,23 @@ export async function runCore(o) {
174
192
  let leaseState = null, leaseTimer = null;
175
193
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
176
194
  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) {
195
+ async function releaseLease(reason, code = null) {
178
196
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
179
197
  if (!io.projector || !leaseState) return;
180
198
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
181
- try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason } : {}); } catch { /* best effort */ }
199
+ // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
200
+ const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
201
+ try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
182
202
  }
183
203
 
184
204
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
185
205
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
206
+ const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
207
+ const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
208
+ let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
186
209
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
187
210
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
211
+ let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
188
212
  const runId = `${issue}-${t0}`;
189
213
  let runTmp = null;
190
214
  let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
@@ -293,9 +317,20 @@ export async function runCore(o) {
293
317
  const code = codeOverride ?? codeForTerminalReason(reason);
294
318
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
295
319
  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 } : {}) };
320
+ conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
321
+ context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
297
322
  ev(term);
298
323
  await emit(term);
324
+ // #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
325
+ // decomposed and dependency-* are legitimate non-failure endings; deferred/engine-error may be requeued, so the batch runner
326
+ // (orchestrate settle, relabelNeedsHuman) relabels those once they are final. #4302: a non-final attempt never relabels; the runner
327
+ // either launches the next attempt or relabels in settle.
328
+ if (recoverUnlabelled && recover.final && reason === "ci-pending") {
329
+ try {
330
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${laneRecoveryEvidence(reason, code, detail)}\n\n_${usageLine()}_`);
331
+ await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
332
+ } catch { /* best effort */ }
333
+ }
299
334
  if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
300
335
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
301
336
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
@@ -306,17 +341,19 @@ export async function runCore(o) {
306
341
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
307
342
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
308
343
  }
309
- await releaseLease(reason);
344
+ await releaseLease(reason, code);
310
345
  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
346
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
312
347
  return done;
313
348
  }
314
- async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR) {
315
- try {
316
- await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}\n\n_${usageLine()}_`);
349
+ /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
350
+ const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
351
+ async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
352
+ if (!recover || recover.final) try {
353
+ await post("FORGE:NEEDS_HUMAN", `## Needs human\n${why}${recoverUnlabelled ? `\n\n${laneRecoveryEvidence("needs-human", code, "")}` : ""}\n\n_${usageLine()}_`);
317
354
  await ghc(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
318
355
  } catch { /* best effort */ }
319
- return finish("needs-human", why, { code }, code);
356
+ return finish("needs-human", why, { code, ...extra, ...(recover ? { recovery: true } : {}) }, code);
320
357
  }
321
358
 
322
359
  // ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
@@ -435,6 +472,7 @@ export async function runCore(o) {
435
472
  } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
436
473
  }
437
474
  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.";
475
+ 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
476
  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
477
  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
478
  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 +503,8 @@ export async function runCore(o) {
465
503
  }
466
504
 
467
505
  /** 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 });
506
+ let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
507
+ const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
469
508
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
470
509
  const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
471
510
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
@@ -654,7 +693,7 @@ export async function runCore(o) {
654
693
  }
655
694
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
656
695
  try {
657
- iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
696
+ iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
658
697
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
659
698
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
660
699
 
@@ -662,6 +701,16 @@ export async function runCore(o) {
662
701
  // ---- 1. claim ----
663
702
  await label("investigating");
664
703
  await post("FORGE:STARTED", `Started work on #${issue} (base \`${lane}\`).`);
704
+ if (recover) {
705
+ // #4267: the batch decided to recover this issue itself; the label goes so the issue is live again.
706
+ try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", "needs-human"]); } catch { /* best effort */ }
707
+ recoverUnlabelled = true;
708
+ try {
709
+ 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.`);
710
+ } catch { /* best effort */ }
711
+ T.decisions.push(`Recovery pass for ${recover.code} (attempt ${recover.attempt}): needs-human removed, PR resumed`);
712
+ ev({ event: "RECOVERY_START", code: recover.code, attempt: recover.attempt });
713
+ }
665
714
 
666
715
  // ---- 2. worktree (resume an open PR / remote branch) ----
667
716
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
@@ -832,18 +881,47 @@ export async function runCore(o) {
832
881
  const login = String(c?.author?.login ?? c?.user?.login ?? "");
833
882
  return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
834
883
  });
835
- const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => c?.body ?? "")].join("\n\n");
836
- let trustedComments = trustedOf(iss);
884
+ // #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
885
+ const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
886
+ const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
887
+ let trustedComments = trustedOf(iss), issueEdited = false;
837
888
  let issueText = textOf(iss, trustedComments);
889
+ let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
890
+ ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
891
+ /**
892
+ * forge#4275: compile (or read from the cache) the context pack, log CONTEXT_PACK and post FORGE:CONTEXT. Off, no
893
+ * io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
894
+ */
895
+ async function contextPackStep() {
896
+ if (ctxCfg.mode === "off" || !io.contextFacts) return null;
897
+ try {
898
+ const headSha = String(await git(["rev-parse", base])).trim();
899
+ // #4316: linked-issue states are part of the key; a failed lookup only drops them from it
900
+ const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
901
+ const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
902
+ gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
903
+ const p = r.pack;
904
+ 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,
905
+ hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
906
+ ev(rec); await emit(rec);
907
+ const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
908
+ if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
909
+ 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"}`);
910
+ return p;
911
+ } catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
912
+ }
838
913
  /** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
839
914
  async function refreshIssue(stage) {
840
915
  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; }
916
+ try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]))); } catch { return; }
842
917
  if (!next || typeof next !== "object") return;
843
918
  const tc = trustedOf(next), text = textOf(next, tc);
844
- const old = contractHash(issueText), now = contractHash(text);
919
+ if (contractHash(issueText) === contractHash(text)) return;
920
+ // #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
921
+ const old = scopeHash, now = contractHash(scopeText(next, tc));
922
+ iss = next; trustedComments = tc; issueText = text; scopeHash = now;
845
923
  if (old === now) return;
846
- iss = next; trustedComments = tc; issueText = text;
924
+ issueEdited = true;
847
925
  ev({ event: "ISSUE_EDITED", old, new: now, stage });
848
926
  T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
849
927
  }
@@ -875,16 +953,21 @@ export async function runCore(o) {
875
953
  }
876
954
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
877
955
  }
878
- /** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
956
+ /**
957
+ * The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
958
+ * (#4265): a trusted comment that the engine did not post came after it.
959
+ */
879
960
  async function readFrozenContract() {
880
961
  let out = "";
881
962
  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
963
  let found = null;
883
964
  for (const l of out.split("\n")) {
884
965
  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;
966
+ if (!isTrustedComment(c)) continue;
967
+ const b = String(c?.body ?? "");
968
+ if (!b.startsWith("<!-- FORGE:CONTRACT -->")) { if (found && b.trim() && !b.trimStart().startsWith("<!-- FORGE:")) found.humanAfter = true; continue; }
969
+ const fc = parseContractComment(b);
970
+ if (fc && contractHash(fc.text) === fc.hash) found = { ...fc, humanAfter: false };
888
971
  }
889
972
  return found;
890
973
  }
@@ -953,13 +1036,25 @@ export async function runCore(o) {
953
1036
  } else {
954
1037
  if (resumed) {
955
1038
  const fc = await readFrozenContract();
956
- if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
1039
+ if (fc) {
1040
+ // #4265: a contract planned from an older issue scope is stale: re-plan and freeze a new one instead of judging against it
1041
+ await refreshIssue("rerun contract check");
1042
+ const gone = (Array.isArray(fc.contract?.requirements) ? fc.contract.requirements : []).filter((r) => r?.disposition === "deliver" && !inIssue(r.quote, issueText)).map((r) => r.id);
1043
+ const stale = fc.scope ? (fc.scope !== scopeHash ? `issue scope ${fc.scope} -> ${scopeHash}` : null)
1044
+ : fc.humanAfter ? "a human comment was posted after it" : gone.length ? `quote(s) no longer in the issue: ${gone.join(", ")}` : null;
1045
+ if (stale) {
1046
+ ev({ event: "CONTRACT_STALE", hash: fc.hash, scope: fc.scope, now: scopeHash, reason: stale });
1047
+ T.decisions.push(`Rerun: frozen contract ${fc.hash} is stale (${stale}); re-planning`);
1048
+ } else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
1049
+ }
957
1050
  }
958
1051
  await label("building");
959
1052
  hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
960
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn());
1053
+ facts = await factsPath([wt, cwd], io.fs?.exists);
1054
+ const ctxPack = frozen ? null : await contextPackStep();
1055
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
961
1056
  if (!frozen) {
962
- await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
1057
+ await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
963
1058
  planned = true;
964
1059
  result = await readResult();
965
1060
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
@@ -987,11 +1082,11 @@ export async function runCore(o) {
987
1082
  vc = validateContract(contract, issueText, { repro: reproOn() });
988
1083
  }
989
1084
  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);
1085
+ 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
1086
  frozen = contract;
992
1087
  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
1088
+ let body = renderContract(frozen, hash, { scope: scopeHash });
1089
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
995
1090
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
996
1091
  const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
997
1092
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
@@ -1001,7 +1096,7 @@ export async function runCore(o) {
1001
1096
  }
1002
1097
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1003
1098
  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) : "");
1099
+ 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
1100
  liveKeys = ["implementation"];
1006
1101
  stage = "implement";
1007
1102
  await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
@@ -1022,17 +1117,29 @@ export async function runCore(o) {
1022
1117
  // the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
1023
1118
  // verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
1024
1119
  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) {
1120
+ // #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
1121
+ const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1122
+ // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1123
+ const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1124
+ // #4315: the map's selection is merged into the learned commands (deduped): a partial map never skips a learned suite
1125
+ const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return { ...mergeChecks(m, learnedCommands), selected: m.length }; };
1126
+ let picked = pickChecks();
1127
+ // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1128
+ const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
1129
+ if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
1130
+ if (picked.commands.length && io.runCommand) {
1027
1131
  for (let round = 0; ; round++) {
1028
1132
  await renew();
1133
+ if (round) picked = pickChecks();
1134
+ const precheckCommands = picked.commands;
1029
1135
  const results = [];
1030
1136
  for (const command of precheckCommands) {
1031
1137
  let r;
1032
1138
  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
1139
  results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1034
1140
  }
1035
- ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
1141
+ ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1142
+ verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1036
1143
  const failed = results.filter((r) => r.rc !== 0);
1037
1144
  if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
1038
1145
  const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
@@ -1074,6 +1181,7 @@ export async function runCore(o) {
1074
1181
  structure = (await structureOf()) ?? structure;
1075
1182
  edges = undeclared();
1076
1183
  }
1184
+ verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
1077
1185
  if (edges.length) {
1078
1186
  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
1187
  }
@@ -1090,6 +1198,7 @@ export async function runCore(o) {
1090
1198
  result = (await readResult()) ?? result;
1091
1199
  docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
1092
1200
  }
1201
+ verifyFacts.docs = docs;
1093
1202
  if (docs && !docs.ok) {
1094
1203
  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
1204
  }
@@ -1211,6 +1320,7 @@ export async function runCore(o) {
1211
1320
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1212
1321
  const hotspots = await hotspotsOf({ base, paths: files });
1213
1322
  const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
1323
+ Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1214
1324
  const pre = [...pc.failures, ...amendProblems];
1215
1325
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
1216
1326
  if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
@@ -1238,6 +1348,29 @@ export async function runCore(o) {
1238
1348
  const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
1239
1349
  return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
1240
1350
  }
1351
+ const verifyReported = new Set();
1352
+ /**
1353
+ * #4270 verify.check_run: one `forgedock/verify` check run per pushed head, reporting the gates' last results (no gate
1354
+ * logic, never blocks). Off: no call, no event. A missing seam or a Checks API failure is an anomaly, never a lane failure.
1355
+ */
1356
+ async function reportVerify() {
1357
+ if (vcfg.checkRun !== true) return;
1358
+ let head = "";
1359
+ try { head = String(await git(["rev-parse", "HEAD"])).trim(); } catch { return; }
1360
+ if (!head || verifyReported.has(head)) return;
1361
+ verifyReported.add(head);
1362
+ const r = verifyReport({ head, ...verifyFacts, contract: effective, result });
1363
+ let posted = false, url = null, error = null;
1364
+ try {
1365
+ if (!io.createCheckRun) throw new Error("no Checks API seam");
1366
+ const c = await io.createCheckRun({ sha: head, name: VERIFY_CHECK_NAME, conclusion: r.conclusion, title: r.title, summary: r.summary, text: r.text });
1367
+ posted = true; url = c?.html_url ?? null;
1368
+ } catch (e) {
1369
+ error = String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 200);
1370
+ T.anomalies.push(`${VERIFY_CHECK_NAME} check run not posted for ${head.slice(0, 12)}: ${error}`);
1371
+ }
1372
+ ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
1373
+ }
1241
1374
  /** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
1242
1375
  const doReview = async () => {
1243
1376
  const d = await gate();
@@ -1254,8 +1387,10 @@ export async function runCore(o) {
1254
1387
  if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
1255
1388
  const outcome = await gate();
1256
1389
  verdict = outcome;
1390
+ for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
1257
1391
  if (outcome.state === "conforms") break;
1258
- const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1392
+ const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
1393
+ const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1259
1394
  if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
1260
1395
  if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
1261
1396
  degraded = { state: outcome.state, missing: outcome.missing ?? [] };
@@ -1266,8 +1401,10 @@ export async function runCore(o) {
1266
1401
  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
1402
  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
1403
  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);
1404
+ 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`);
1405
+ // A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
1406
+ 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 */ } }
1407
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
1271
1408
  await upsertStage("implementation"); // the published note follows the code after every resume
1272
1409
  }
1273
1410
  // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
@@ -1322,6 +1459,7 @@ export async function runCore(o) {
1322
1459
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
1323
1460
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
1324
1461
  let localHead = String(await git(["rev-parse", "HEAD"])).trim();
1462
+ await reportVerify();
1325
1463
 
1326
1464
  // ---- 6. merge policy: required (default) | all | none ----
1327
1465
  let policy = mergeWait, required = [];
@@ -1419,6 +1557,7 @@ export async function runCore(o) {
1419
1557
  rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
1420
1558
  }
1421
1559
  localHead = await gitOut(["rev-parse", "HEAD"]);
1560
+ await reportVerify();
1422
1561
  syncRow.push(`${trigger}: ${clean ? "clean" : "conflicts"} (${baseSha})`);
1423
1562
  T.baseSync = [`${syncs} sync(s)`, syncRow.join("; ")];
1424
1563
  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 +1590,7 @@ export async function runCore(o) {
1451
1590
  if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1452
1591
  await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
1453
1592
  localHead = await gitOut(["rev-parse", "HEAD"]);
1593
+ await reportVerify();
1454
1594
  return null;
1455
1595
  }
1456
1596
  /** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
@@ -1512,7 +1652,8 @@ export async function runCore(o) {
1512
1652
  // a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
1513
1653
  const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
1514
1654
  if (policy === "all") {
1515
- const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
1655
+ // #4270: the lane's own report-only check never gates its own merge
1656
+ const w = await io.waitCiGreen(pr, repo, 0, ...(vcfg.checkRun === true ? [{ ignore: [VERIFY_CHECK_NAME] }] : [])).catch(() => ({ rc: 2, text: "" }));
1516
1657
  if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
1517
1658
  if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
1518
1659
  } else {