forgedock 1.30.0 → 1.31.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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/lean.mjs CHANGED
@@ -8,32 +8,45 @@
8
8
  import { join, dirname, basename, delimiter } from "node:path";
9
9
  import { hostname as osHostname } from "node:os";
10
10
  import { appendEvent } from "../engine/runlog.mjs";
11
+ import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
11
12
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
13
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
14
  import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
14
15
  import { createHash } from "node:crypto";
15
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
17
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
- import { namedPaths } from "./prefilter.mjs";
18
- import { preflightProbes, preflightProblems } from "./preflight.mjs";
18
+ import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
19
+ import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
19
20
  import { resolveLane, isDefaultLane } from "./lane.mjs";
21
+ import { splitGate, rationaleLine } from "./decompose.mjs";
20
22
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
21
23
  import { repoKey } from "../runner.mjs";
22
24
  import { prepareToolchain } from "./toolchain.mjs";
23
- import { readTestMap, readTestMapAtRef, selectCommands, scopeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
24
- import { runChecks } from "./precheck.mjs";
25
+ import { toolStats } from "./toolstats.mjs";
26
+ import { readTestMap, readTestMapAtRef, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
27
+ import { runChecks, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
25
28
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
26
29
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
27
- import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
28
- 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, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
30
+ import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
31
+ import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
32
+ 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, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt } from "./prompts.mjs";
33
+ import { postMergeCommands } from "./postmerge.mjs";
34
+ import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
35
+ import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
36
+ import { makeTurnBudget, laneSize, wrapupGrant, budgetDigest } from "./budget.mjs";
37
+ import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog } from "./pitfalls.mjs";
29
38
  import { unjustified } from "./enumerating.mjs";
30
39
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
31
- import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
40
+ import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
41
+ import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
32
42
  import { laneBackendId } from "./limits.mjs";
33
43
  import { SPLIT_LABEL } from "./refactor.mjs";
34
- import { writeHeavyShims, sumWaits } from "./heavy.mjs";
44
+ import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
35
45
  import { priorityFromLabels } from "./sched.mjs";
36
- import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp } from "./context.mjs";
46
+ import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
47
+ import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
48
+ import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
49
+ import { buildResumeDigest } from "./resume.mjs";
37
50
 
38
51
  /**
39
52
  * #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
@@ -44,6 +57,7 @@ export const RECOVER_HOW = Object.freeze({
44
57
  [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",
45
58
  [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",
46
59
  [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
60
+ [OUTCOME_CODES.BUDGET_EXCEEDED]: "an earlier run spent the implement turn budget for this issue's size; its digest is in the earlier run's detail and the branch holds its checkpointed work. Continue from the checkpoint against the frozen contract without starting over; if the remaining work cannot fit the budget, stop with a typed split-required block naming the sub-issues instead of grinding",
47
61
  [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
48
62
  [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
49
63
  });
@@ -64,6 +78,8 @@ const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CO
64
78
  * over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
65
79
  */
66
80
  const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
81
+ /** #4395: cap on the engine.lane_instructions file; a larger one is not slim and falls back to CLAUDE.md. */
82
+ export const LANE_INSTRUCTIONS_MAX_BYTES = 16384;
67
83
  /** forge.yaml engine.review_incomplete (#4160): "needs-human" (default), or "degraded": in shadow, merge an incomplete review with a review-degraded label and a tracked follow-up. */
68
84
  export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
69
85
  export const REVIEW_DEGRADED_LABEL = "review-degraded";
@@ -76,6 +92,24 @@ export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
76
92
  /** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
77
93
  export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
78
94
 
95
+ /**
96
+ * #4385: siblings that declare a shared file (a barrel, union type, shared fixture, index doc) would conflict after the
97
+ * first merges; each later sub-issue gets an edge to every earlier one it shares a file with and does not already reach
98
+ * through its dependsOn. Mutates dependsOn; returns the added edges [{from, to, files}]. No `files`: no edge.
99
+ */
100
+ export function serializeShared(subs) {
101
+ const added = [], reach = [];
102
+ subs.forEach((s, j) => {
103
+ for (let i = j - 1; i >= 0; i--) { // nearest first: a chain, not a fan-in
104
+ const shared = (s.files ?? []).filter((f) => (subs[i].files ?? []).includes(f));
105
+ const r = new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]));
106
+ if (shared.length && !r.has(i)) { s.dependsOn.push(i); added.push({ from: j, to: i, files: shared }); }
107
+ }
108
+ reach[j] = [...new Set(s.dependsOn.flatMap((k) => [k, ...reach[k]]))];
109
+ });
110
+ return added;
111
+ }
112
+
79
113
  /**
80
114
  * Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
81
115
  * | {kind:"human", why} | {kind:"untyped", problem}. Only `human` with an allowed reason may end as needs-human.
@@ -91,8 +125,12 @@ export function classifyBlock(result, issue) {
91
125
  const subs = (Array.isArray(b.subissues) ? b.subissues : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string");
92
126
  if (!subs.length) return { kind: "untyped", problem: "the split-required block lists no sub-issues with a title and body" };
93
127
  if (subs.length > MAX_SUBISSUES) return { kind: "untyped", problem: `the split-required block lists ${subs.length} sub-issues (at most ${MAX_SUBISSUES})` };
94
- return { kind: "split", subissues: subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
95
- dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))] })) };
128
+ const gate = splitGate({ ...b, subissues: subs }); // #4396: disjoint parts or over the size budget; else one lane
129
+ if (!gate.ok) return { kind: "untyped", problem: gate.problem };
130
+ const subissues = subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
131
+ dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))],
132
+ ...(Array.isArray(x.files) ? { files: [...new Set(x.files.filter((f) => typeof f === "string").map((f) => f.trim().replace(/^\.?\/+/, "")).filter(Boolean))] } : {}) }));
133
+ return { kind: "split", rationale: gate.rationale, subissues, serialized: serializeShared(subissues) };
96
134
  }
97
135
  if (b.kind === "human") {
98
136
  return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
@@ -122,10 +160,14 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
122
160
  * @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
123
161
  * @param {string} [o.builderModel] default: CLI default
124
162
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
163
+ * @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
164
+ * @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
165
+ * @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
125
166
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
126
167
  * @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
127
168
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
128
169
  * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
170
+ * @param {object} [o.verifier] forge.yaml review.verifier/verifier_model (#4397): {mode: off (default)|shadow|enforce, model}
129
171
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
130
172
  * @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
131
173
  * maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
@@ -135,22 +177,30 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
135
177
  * @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
136
178
  * @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
137
179
  * @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
180
+ * @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
138
181
  * @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)
139
182
  * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
183
+ * @param {object} [o.explore] {mode: on|off, model}: forge.yaml lean.explore / lean.explore_model (#4391, default off): the explorer subagent
140
184
  * @param {object} [o.contextPack] {mode: shadow|on|off, sources, bytes, days}: forge.yaml engine.context_pack* (#4275, default shadow)
141
185
  * @param {object} [o.recover] {code, attempt, final}: a batch recovery lane (#4267) for a mechanical needs-human; removes the label,
142
186
  * posts FORGE:DECISION and resumes the open PR on the rerun path with a recovery note. `final`
143
187
  * (default true) is false when the runner may launch another attempt: the lane then never relabels (#4302)
144
188
  */
145
189
  export async function runCore(o) {
146
- const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
147
- toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
190
+ const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
191
+ toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
148
192
  const now = io.now ?? (() => Date.now());
149
193
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
194
+ const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
150
195
  if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
151
196
  const remoteVerify = vcfg.mode !== "local";
152
197
  const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
153
198
  const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
199
+ const verifierCfg = parseVerifierConfig(o.verifier ?? {});
200
+ const routingCfg = parseBuilderRouting(o.builderRouting ?? {});
201
+ // #4394: the build's model tier, routed from the frozen contract; a failed validate round on the mid tier escalates once
202
+ let route = null, escalated = false;
203
+ const laneModel = () => (route?.tier === "mid" && !escalated ? route.model : builderModel);
154
204
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
155
205
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
156
206
  // #4267: a batch recovery lane for a mechanical needs-human ({code, attempt, final}); #4302: only a final attempt relabels.
@@ -159,9 +209,10 @@ export async function runCore(o) {
159
209
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
160
210
  const t0 = now();
161
211
  const usage = { cost_usd: 0, input: 0, output: 0, cache: 0 };
212
+ const sessionCosts = new Map(); // #4407: session id -> last reported cumulative cost
162
213
  let sessionId = null;
163
214
 
164
- const ev = (e) => appendEvent(dir, issue, e);
215
+ const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
165
216
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
166
217
  const ghc = (args) => io.gh(args);
167
218
  const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -169,12 +220,20 @@ export async function runCore(o) {
169
220
  const ghw = (args) => retrying(() => io.gh(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
170
221
  const git = (args) => io.git(args, { cwd: wt });
171
222
 
172
- async function attempt(phase, n, r) {
173
- const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
174
- cost_usd: num(r?.costUsd), model: r?.model ?? null, turns: num(r?.turns) };
223
+ const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
224
+ const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
225
+ // #4392: implement turn budget for the lane's size, and its turn tally; off unless given (the CLI passes readCoreLimits' defaults)
226
+ const tb = makeTurnBudget(turnBudgets ?? { S: 0, M: 0, L: 0 });
227
+ /** `trigger` (#4362): why the attempt ran, one of fixround.mjs TRIGGERS; `resume` (#4407): the session it resumed. */
228
+ async function attempt(phase, n, r, mode = null, trigger = null, restartOf = null, resume = null) {
229
+ const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
230
+ const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
231
+ cost_usd: num(r?.costUsd), cost_usd_delta: c.delta, cost_usd_session: c.session, model: r?.model ?? null, turns: num(r?.turns), ...(mode ? { mode } : {}),
232
+ ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
233
+ if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
175
234
  ev(rec); await emit(rec);
176
235
  const u = r?.usage ?? {};
177
- usage.cost_usd += r?.costUsd ?? 0;
236
+ usage.cost_usd += c.delta ?? 0;
178
237
  usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
179
238
  usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
180
239
  }
@@ -215,18 +274,26 @@ export async function runCore(o) {
215
274
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
216
275
  const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
217
276
  const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
277
+ const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
278
+ if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
218
279
  let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
280
+ let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
281
+ let factsSeen = []; // #4401: trusted comment bodies on the issue, so an identical FORGE:FACTS is never posted twice
219
282
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
283
+ let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
220
284
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
221
285
  let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
222
286
  const runId = `${issue}-${t0}`;
223
287
  let runTmp = null;
224
288
  let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
289
+ // #4395: lane profile. full (FORGEDOCK_FULL_SESSION=1): unchanged sessions; memory-off: no auto-memory; lane: also no CLAUDE.md, the lane file appended
290
+ let laneProfile = { name: process.env.FORGEDOCK_FULL_SESSION === "1" ? "full" : "memory-off", text: null };
225
291
  let toolchain = null; // #4163: {env, summary} when the repo pins its toolchain (env only with a pm shim); null leaves runner env and prompts unchanged
226
292
  // #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
227
293
  // the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
228
294
  const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
229
295
  let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
296
+ let seqLease = null; // #4386: the host sequence lease, held from the merge-gate renumber through the merge
230
297
  const waits = {};
231
298
  let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
232
299
  const addWait = (phase, ms, queued = 0) => {
@@ -321,15 +388,19 @@ export async function runCore(o) {
321
388
  return finishing;
322
389
  }
323
390
  async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
391
+ try { seqLease?.release(); } catch { /* fail-open */ }
392
+ seqLease = null;
324
393
  if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
325
394
  try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
326
395
  }
396
+ await postLaneFacts(); // #4401: before teardown removes the worktree's result.json
327
397
  await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
328
398
  const code = codeOverride ?? codeForTerminalReason(reason);
329
399
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
330
400
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
331
- conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
332
- context_pack: ctxCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}) }; // forge#4275: the on/off A/B groups runs by these
401
+ conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
402
+ context_pack: ctxCfg.mode, explore: exploreCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
403
+ ...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
333
404
  ev(term);
334
405
  await emit(term);
335
406
  // #4297: only a final failure the runner never requeues puts needs-human back (needsHuman() relabels on its own path). invalid,
@@ -347,16 +418,32 @@ export async function runCore(o) {
347
418
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
348
419
  }
349
420
  if (["merged", "invalid", "needs-human"].includes(reason)) {
350
- if (reason === "merged") T.close = ["issue closed (completed)", "label workflow:merged"];
421
+ if (reason === "merged") T.close = [extra.close === "partial" ? "issue closed (partial)" : "issue closed (completed)", "label workflow:merged"];
351
422
  else if (reason === "invalid") T.close = ["issue closed (not planned)", "label workflow:invalid"];
352
423
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
353
424
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
354
425
  }
355
426
  await releaseLease(reason, code);
356
427
  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,
357
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}), ...extra };
428
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
429
+ ...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
430
+ ...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
431
+ ...(gateRan ? { conformance: { mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", would: shadowWould, risk: Boolean(riskEnforced) } } : {}), ...extra };
358
432
  return done;
359
433
  }
434
+ /** #4401: post the lane's FORGE:FACTS (result.json facts and validation, changed files, contract decisions); never fails a run. */
435
+ async function postLaneFacts() {
436
+ if (!base) return;
437
+ try {
438
+ const result = await readResult();
439
+ const rec = result ? normaliseFacts(result, { files: await stageFiles(), contract: await readContract() }) : null;
440
+ if (!rec) return;
441
+ const body = renderFactsRecord(rec);
442
+ if (factsSeen.some((b) => String(b ?? "").includes(body))) return;
443
+ await post(FACTS_MARKER, body);
444
+ T.decisions.push(`FORGE:FACTS: ${rec.rejected.length} rejected approach(es), ${rec.hotspots.length} hot spot(s) on ${rec.files.length} file(s)`);
445
+ } catch { /* best effort */ }
446
+ }
360
447
  /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
361
448
  const laneRecoveryEvidence = (reason, code, detail) => recoveryEvidence({ code: recover.code, attempt: recover.attempt, outcome: reason, resultCode: code, detail });
362
449
  async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR, extra = {}) {
@@ -454,7 +541,9 @@ export async function runCore(o) {
454
541
  const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
455
542
  // #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
456
543
  const heavy = heavyEnv ? { ...heavyEnv, FORGE_LANE_PHASE: args.role === "builder" ? stage : String(args.role ?? "step"), FORGE_LANE_ISSUE: String(issue), FORGE_LANE_PRIORITY: String(lanePriority()) } : {};
457
- try { return await io.runner({ ...args, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
544
+ const lp = laneProfile.name, profEnv = lp === "full" ? {} : { CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", ...(lp === "lane" ? { CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1" } : {}) };
545
+ const system = lp === "lane" && !args.resume && !args.noTools ? [args.system, laneProfile.text].filter(Boolean).join("\n\n") : args.system;
546
+ try { return await io.runner({ ...args, system, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy, ...profEnv } }); }
458
547
  catch (e) {
459
548
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
460
549
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -525,33 +614,68 @@ export async function runCore(o) {
525
614
  const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
526
615
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
527
616
  let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
528
- const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + CONTINUE_NOTE.replace("BASE", lane);
529
- async function builder(n, prompt, resume, live = false, restart = implementRestart) {
530
- let timeoutRetries = 0, p = prompt;
617
+ let resumeNote = ""; // #4400: the resume digest of the last run (resume.mjs), "" on a first run or with context_pack off
618
+ const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + CONTINUE_NOTE.replace("BASE", lane) + resumeNote;
619
+ /** #4390: builder calls stopped at the context ceiling restart fresh; planning (and its corrections) run uncapped. */
620
+ const UNCEILED = ["plan", "contract_correction", "block_correction"], CONTEXT_RESTARTS_MAX = 3;
621
+ async function builder(n, prompt, resume, live = false, restart = implementRestart, opts = {}) {
622
+ let timeoutRetries = 0, contextRestarts = 0, p = prompt;
623
+ opts = { ...opts };
624
+ const ceiled = contextCeilingTokens > 0 && !UNCEILED.includes(opts.trigger);
531
625
  for (;;) {
532
- try { return await builderOnce(n, p, resume, live); } catch (e) {
626
+ // after CONTEXT_RESTARTS_MAX restarts the call runs uncapped: a loop guard, never a refusal of work
627
+ opts.contextMax = ceiled && contextRestarts < CONTEXT_RESTARTS_MAX ? contextCeilingTokens : 0;
628
+ const budgeted = stage === "implement" && tb.on(); // #4392: the build and its restarts spend the implement turn budget
629
+ if (budgeted && tb.remaining() <= 0) return await budgetWrapup(n, opts);
630
+ opts.turnMax = budgeted ? tb.remaining() : 0;
631
+ try { return await builderOnce(n, p, resume, live, opts); } catch (e) {
632
+ if (e?.code === "CLI_TURNS") {
633
+ await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? builderModel ?? null }, opts.mode, opts.trigger, opts.restartOf);
634
+ return await budgetWrapup(n, opts);
635
+ }
636
+ if (e?.code === "CLI_CONTEXT") {
637
+ contextRestarts++;
638
+ ev({ event: "CONTEXT_CEILING", context_tokens: e.context ?? null, ceiling: contextCeilingTokens, turns: e.turns ?? null, trigger: opts.trigger ?? null, restart: contextRestarts });
639
+ await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? laneModel() ?? null }, opts.mode, opts.trigger, opts.restartOf);
640
+ await checkpoint("context ceiling");
641
+ T.decisions.push(`Builder session reached ${e.context ?? "?"} context tokens (ceiling ${contextCeilingTokens}): checkpointed, restarted fresh from the contract, diff and last validate result`);
642
+ p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
643
+ continue;
644
+ }
533
645
  if (!(e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
534
646
  const idle = e.code === "CLI_IDLE";
535
647
  const reason = idle ? "idle timeout" : "timeout";
536
- ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [] });
648
+ ev({ event: idle ? "CLI_IDLE_KILL" : "CLI_TIMEOUT_KILL", reason, lastTool: e.lastTool ?? null, children: e.children ?? [],
649
+ ...(idle ? { heavyQueue: e.heavyQueue ?? null, extended: e.extended === true } : {}) });
537
650
  await checkpoint(reason);
538
651
  if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
539
652
  T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
540
653
  T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
541
- p = restart(); resume = null;
654
+ p = restart(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "timeout_restart";
542
655
  }
543
656
  }
544
657
  }
545
658
 
546
- async function builderOnce(n, prompt, resume, live = false) {
659
+ /** #4405: a silent builder is busy (idle window extended) while a child is a heavy step or its heavy-queue entry is queued/running. */
660
+ function sessionBusy(children) {
661
+ const work = children.filter((c) => isHeavyProcess(c.cmd));
662
+ let heavyQueue = null;
663
+ try { heavyQueue = io.heavy?.lane?.(children.map((c) => c.pid)) ?? null; } catch { heavyQueue = null; }
664
+ return work.length || heavyQueue?.queued > 0 || heavyQueue?.running > 0 ? { children: work, heavyQueue } : null;
665
+ }
666
+
667
+ /** opts (#4363): {model, mode, keepSession, trigger and restartOf (#4362)}; a fresh fix session keeps the build session id for a fallback resume. */
668
+ async function builderOnce(n, prompt, resume, live = false, { model = null, mode = null, keepSession = false, trigger = null, restartOf = null, contextMax = 0, turnMax = 0 } = {}) {
547
669
  onProgress({ event: "phase_enter", phase: "build" });
548
670
  await renew();
549
671
  const control = {};
550
672
  const governor = startGovernor(control);
551
673
  let spawned = null;
552
674
  const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
553
- const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control, onSpawn,
554
- idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
675
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg),
676
+ idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
677
+ busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
678
+ onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
555
679
  call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
556
680
  let r;
557
681
  call.catch(() => governor.stop());
@@ -570,11 +694,45 @@ export async function runCore(o) {
570
694
  await governor.stop();
571
695
  await renew();
572
696
  await registerReported();
573
- if (r?.sessionId) sessionId = r.sessionId;
574
- await attempt("implement", n, r);
697
+ if (r?.sessionId && !keepSession) sessionId = r.sessionId;
698
+ await attempt("implement", n, r, mode, trigger, restartOf, resume);
575
699
  onProgress({ event: "phase_exit", phase: "build", status: "committed" });
576
700
  return r;
577
701
  }
702
+ /** #4390: the restart summary: `git diff --stat` and hunks against the lane base, and the last `forgedock check` record. */
703
+ async function restartFacts() {
704
+ const ex = [".", `:!${LEAN_DIR}`];
705
+ const stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => "")), diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
706
+ let check = "";
707
+ try { check = String(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)); } catch { /* none recorded */ }
708
+ return { stat, diff, base, check };
709
+ }
710
+ const contextRestartNote = async () => contextRestartPrompt({ ...await restartFacts(), ceiling: contextCeilingTokens });
711
+ /**
712
+ * #4392: the implement turn budget is spent. Checkpoint, then one fresh session of `wrapupGrant` turns either finishes
713
+ * (its result flows on as usual) or stops with a digest; a stop or an overrun ends needs-human `budget.exceeded`.
714
+ */
715
+ async function budgetWrapup(n, opts) {
716
+ const size = tb.size, budget = tb.budget(), used = tb.record().build, grant = wrapupGrant(budget);
717
+ tb.markExceeded();
718
+ ev({ event: "TURN_BUDGET_EXCEEDED", size, budget, turns: used, grant });
719
+ await checkpoint("turn budget");
720
+ T.decisions.push(`Implement turn budget spent (size ${size}, ${used}/${budget} turns): checkpointed; one wrap-up session of ${grant} turns to finish or stop with a digest`);
721
+ const facts = await restartFacts();
722
+ const wopts = { ...opts, trigger: "budget_wrapup", restartOf: opts.restartOf ?? opts.trigger ?? null, contextMax: 0, turnMax: grant };
723
+ let r = null, overran = false;
724
+ try { r = await builderOnce(n, implementRestart() + turnBudgetWrapupPrompt({ size, budget, used, grant, ...facts }), null, false, wopts); } catch (e) {
725
+ if (e?.code !== "CLI_TURNS") throw e;
726
+ overran = true;
727
+ await attempt("implement", n, { usage: e.usage ?? null, turns: e.turns ?? null, tools: toolStats(e.toolUses), costUsd: null, model: opts.model ?? builderModel ?? null }, opts.mode, wopts.trigger, wopts.restartOf);
728
+ await checkpoint("turn budget wrap-up");
729
+ }
730
+ const res = await readResult();
731
+ if (!overran && res?.status !== "budget-exceeded") return r;
732
+ ev({ event: "TURN_BUDGET_STOP", size, budget, turns: tb.record().build, overran });
733
+ const digest = budgetDigest({ size, budget, used: tb.record().build, digest: res?.status === "budget-exceeded" ? res.digest : null, stat: facts.stat, check: facts.check });
734
+ throw Object.assign(new Error(digest), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED });
735
+ }
578
736
  const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
579
737
  const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
580
738
  /** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
@@ -649,9 +807,11 @@ export async function runCore(o) {
649
807
  filed.push(n);
650
808
  }
651
809
  } catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
652
- T.decisions.push(`Builder block: split into ${refs(filed)}`);
810
+ const why = rationaleLine(b.rationale);
811
+ T.decisions.push(`Builder block: split into ${refs(filed)} (${why})`);
812
+ for (const e of b.serialized ?? []) T.decisions.push(`Serialised sub-issue #${filed[e.from]} behind #${filed[e.to]}: both edit ${e.files.slice(0, 5).join(", ")} (#4385)`);
653
813
  let marked = false;
654
- try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); marked = true; } catch { /* best effort */ }
814
+ try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\nRationale: ${why}\n\n${s}`); marked = true; } catch { /* best effort */ }
655
815
  // #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
656
816
  if (await label("decomposed") && marked && closeParent) {
657
817
  try {
@@ -660,7 +820,7 @@ export async function runCore(o) {
660
820
  T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
661
821
  } catch { /* best effort: the parent stays open, as before #4337 */ }
662
822
  }
663
- return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
823
+ return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed, decomposition: b.rationale });
664
824
  }
665
825
  return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
666
826
  }
@@ -709,7 +869,7 @@ export async function runCore(o) {
709
869
  }
710
870
 
711
871
  if (io.ledger) {
712
- try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
872
+ try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, repo, host: myHost }); } catch { ledger = null; }
713
873
  }
714
874
  // A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
715
875
  o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
@@ -724,6 +884,7 @@ export async function runCore(o) {
724
884
  try {
725
885
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
726
886
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
887
+ const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
727
888
  if (String(iss.state).toUpperCase() === "CLOSED") {
728
889
  // #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
729
890
  // them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
@@ -827,15 +988,19 @@ export async function runCore(o) {
827
988
  }
828
989
  const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
829
990
  await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
830
- // #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
831
- if (io.deps?.prepare) {
832
- let res;
833
- try { res = await io.deps.prepare({ wt, repoKey: repoKey(cwd) }); } catch (e) { res = [{ status: "failed", eco: null, key: null, ms: 0, reason: String(e?.message ?? e).slice(0, 300) }]; }
834
- for (const r of Array.isArray(res) ? res : []) {
835
- ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
836
- if (r?.eco) T.decisions.push(`Dependencies (${r.eco}${r.key ? ` ${r.key}` : ""}): ${r.status}${r.reason ? ` (${r.reason})` : ""}${Number.isFinite(r.ms) ? ` in ${Math.round(r.ms / 100) / 10}s` : ""}`);
837
- }
991
+ // ---- 2a. lane profile (#4395): the repo's lane instruction file replaces CLAUDE.md; a bad one falls back with an anomaly ----
992
+ const lanePath = typeof o.laneInstructions === "string" ? o.laneInstructions.trim() : "";
993
+ if (laneProfile.name !== "full" && lanePath) {
994
+ let why = null, text = "";
995
+ if (lanePath.startsWith("/") || lanePath.split(/[\\/]/).includes("..")) why = "not a repo-relative path";
996
+ else { try { text = String(await io.fs.read(`${wt}/${lanePath}`) ?? "").trim(); } catch { why = "not readable in the lane worktree"; } }
997
+ if (!why && !text) why = "empty";
998
+ if (!why && Buffer.byteLength(text, "utf8") > LANE_INSTRUCTIONS_MAX_BYTES) why = `over ${LANE_INSTRUCTIONS_MAX_BYTES} bytes`;
999
+ if (why) T.anomalies.push(`forge.yaml engine.lane_instructions \`${lanePath.slice(0, 200)}\` ${why}; lanes keep CLAUDE.md (auto-memory off)`);
1000
+ else laneProfile = { name: "lane", text };
838
1001
  }
1002
+ T.decisions.push(laneProfile.name === "lane" ? `Lane profile: ${lanePath} (${Buffer.byteLength(laneProfile.text, "utf8")} bytes) instead of CLAUDE.md, auto-memory off`
1003
+ : laneProfile.name === "full" ? "Lane profile: full session (FORGEDOCK_FULL_SESSION=1)" : "Lane profile: auto-memory off, CLAUDE.md loaded");
839
1004
  // ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
840
1005
  try {
841
1006
  const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
@@ -848,6 +1013,16 @@ export async function runCore(o) {
848
1013
  if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
849
1014
  for (const n of tc?.notes ?? []) T.anomalies.push(n);
850
1015
  } catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
1016
+ // #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
1017
+ // After 2b (#4347): installs run with the toolchain env, so a pm only its shim provides resolves; no env is the host PATH.
1018
+ if (io.deps?.prepare) {
1019
+ let res;
1020
+ try { res = await io.deps.prepare({ wt, repoKey: repoKey(cwd), env: toolchain?.env ?? undefined }); } catch (e) { res = [{ status: "failed", eco: null, key: null, ms: 0, reason: String(e?.message ?? e).slice(0, 300) }]; }
1021
+ for (const r of Array.isArray(res) ? res : []) {
1022
+ ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
1023
+ if (r?.eco) T.decisions.push(`Dependencies (${r.eco}${r.key ? ` ${r.key}` : ""}): ${r.status}${r.reason ? ` (${r.reason})` : ""}${Number.isFinite(r.ms) ? ` in ${Math.round(r.ms / 100) / 10}s` : ""}`);
1024
+ }
1025
+ }
851
1026
  // ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
852
1027
  if (heavyOn) {
853
1028
  try {
@@ -883,6 +1058,10 @@ export async function runCore(o) {
883
1058
  const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
884
1059
  return touched.filter((f) => differs.has(f));
885
1060
  };
1061
+ /** #4364: the last `forgedock check` record vs the current tree: skipped|stale|red|green; any read or git error is skipped. */
1062
+ const selfCheckNow = async () => {
1063
+ try { return selfCheckState(JSON.parse(await io.fs.read(`${wt}/${SELF_CHECK_FILE}`)), await worktreeFingerprint({ git, base })); } catch { return "skipped"; }
1064
+ };
886
1065
  /** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
887
1066
  const hotspotsOf = async (args) => {
888
1067
  if (!io.hotspots) return null;
@@ -912,14 +1091,12 @@ export async function runCore(o) {
912
1091
  const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
913
1092
  const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
914
1093
  // Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
915
- const trustedOf = (i) => (i.comments ?? []).filter((c) => {
916
- const login = String(c?.author?.login ?? c?.user?.login ?? "");
917
- return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
918
- });
1094
+ const trustedOf = (i) => (i.comments ?? []).filter(isTrustedIssueComment); // #4467: gh shape, the App has no [bot]
919
1095
  // #4297: engine-posted `<!-- FORGE:` comments (FORGE:CONTEXT carries linked-issue text) are never quotable, as in scopeText
920
1096
  const isEngineComment = (b) => b.trimStart().startsWith("<!-- FORGE:");
921
1097
  const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => String(c?.body ?? "")).filter((b) => !isEngineComment(b))].join("\n\n");
922
1098
  let trustedComments = trustedOf(iss), issueEdited = false;
1099
+ factsSeen = trustedComments.map((c) => c?.body);
923
1100
  let issueText = textOf(iss, trustedComments);
924
1101
  let scopeHash = contractHash(scopeText(iss, trustedComments)); // #4265: the edit signal and the frozen contract's provenance
925
1102
  ctxLane = laneType({ resumed: Boolean(resumed || salvagedRef), attempts: priorAttempts(iss.body, trustedComments), body: iss.body });
@@ -928,23 +1105,36 @@ export async function runCore(o) {
928
1105
  * io.contextFacts or any failure gives null: never fails a run. The pack never enters issueText (contract quotes).
929
1106
  */
930
1107
  async function contextPackStep() {
1108
+ const pits = await lanePitfalls();
931
1109
  if (ctxCfg.mode === "off" || !io.contextFacts) return null;
932
1110
  try {
933
1111
  const headSha = String(await git(["rev-parse", base])).trim();
934
1112
  // #4316: linked-issue states are part of the key; a failed lookup only drops them from it
935
1113
  const links = await linkStateStamp({ gh: ghc, repo, issue, body: iss.body }).catch(() => "");
936
- const r = await contextPackFor({ cfg: ctxCfg, issue, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links },
937
- gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now() }) });
1114
+ const r = await contextPackFor({ cfg: ctxCfg, issue, repo, headSha, cacheDir: dir, fs: io.fs, stamp: { scope: scopeHash, attempts: priorAttempts(iss.body, trustedComments), links, pitfalls: pits.map((x) => x.sig) },
1115
+ gather: () => io.contextFacts({ repo, issue, iss, comments: trustedComments, cwd: wt, checkout: cwd, base, sources: ctxCfg.sources, days: ctxCfg.days, now: now(), pitfalls: pits }) });
938
1116
  const p = r.pack;
1117
+ const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
1118
+ pitMatched = inPack.filter((x) => x.match === "files").length;
1119
+ // #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
939
1120
  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,
940
1121
  hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
941
- ev(rec); await emit(rec);
1122
+ ev({ ...rec, pitfalls: inPack }); await emit(rec);
942
1123
  const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
943
1124
  if (p.items && !seen) { try { await post("FORGE:CONTEXT", renderContextRecord(p, { mode: ctxCfg.mode, laneType: ctxLane })); } catch { /* best effort */ } }
944
1125
  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"}`);
945
1126
  return p;
946
1127
  } catch (e) { T.anomalies.push(`Context pack not compiled (builder prompt unchanged): ${String(e?.message ?? e).slice(0, 200)}`); return null; }
947
1128
  }
1129
+ /** #4375: tick the repo's lane clock (pitfall aging) and select its pitfalls for this issue; [] without io.pitfalls or on any error. */
1130
+ async function lanePitfalls() {
1131
+ if (!io.pitfalls) return [];
1132
+ try {
1133
+ await io.pitfalls.append(repo, { t: "lane", run: runId, ts: new Date(now()).toISOString() });
1134
+ if (!ctxCfg.sources.includes("pitfalls")) return [];
1135
+ return selectPitfalls(foldPitfalls(await io.pitfalls.read(repo), { maxAge: ctxCfg.pitfallLanes }), { paths: namedPaths(iss.body ?? "") });
1136
+ } catch { return []; }
1137
+ }
948
1138
  /** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
949
1139
  async function refreshIssue(stage) {
950
1140
  let next;
@@ -954,7 +1144,7 @@ export async function runCore(o) {
954
1144
  if (contractHash(issueText) === contractHash(text)) return;
955
1145
  // #4265: the engine's own writes (FORGE:STATE, FORGE:* comments) refresh the text but are not an issue edit
956
1146
  const old = scopeHash, now = contractHash(scopeText(next, tc));
957
- iss = next; trustedComments = tc; issueText = text; scopeHash = now;
1147
+ iss = next; trustedComments = tc; factsSeen = tc.map((c) => c?.body); issueText = text; scopeHash = now;
958
1148
  if (old === now) return;
959
1149
  issueEdited = true;
960
1150
  ev({ event: "ISSUE_EDITED", old, new: now, stage });
@@ -965,7 +1155,7 @@ export async function runCore(o) {
965
1155
  let block = result?.status === "blocked" ? await resolveBlock(result) : null;
966
1156
  if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
967
1157
  T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
968
- await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId);
1158
+ await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId, false, undefined, { trigger: "block_correction" });
969
1159
  result = await readResult();
970
1160
  block = result?.status === "blocked" ? await resolveBlock(result) : null;
971
1161
  }
@@ -988,20 +1178,37 @@ export async function runCore(o) {
988
1178
  }
989
1179
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
990
1180
  }
991
- /** #4328: probe the host for the tools and modules a contract's validate commands need; the misses, [] without io.probeCommand. */
1181
+ /**
1182
+ * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
1183
+ * env leaves empty and run each remaining command once on the base worktree (io.preflightRun). The misses; [] without either hook.
1184
+ */
992
1185
  async function preflightValidates(contract) {
993
- if (!io.probeCommand || !contract || typeof contract !== "object") return [];
1186
+ if ((!io.probeCommand && !io.preflightRun) || !contract || typeof contract !== "object") return [];
994
1187
  const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
995
- const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
996
- if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
997
1188
  const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
998
- for (const p of probes) {
999
- if (p.needs && missing.has(p.needs)) continue;
1000
- let r;
1001
- try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
1002
- if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
1003
- if (p.kind === "command") missing.add(p.tool);
1004
- misses.push(p);
1189
+ if (io.probeCommand) {
1190
+ const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
1191
+ if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
1192
+ for (const p of probes) {
1193
+ if (p.needs && missing.has(p.needs)) continue;
1194
+ let r;
1195
+ try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
1196
+ if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
1197
+ if (p.kind === "command") missing.add(p.tool);
1198
+ misses.push({ ...p, source: "probe" });
1199
+ }
1200
+ }
1201
+ const fullEnv = { ...process.env, ...env };
1202
+ for (const v of Array.isArray(contract.validate) ? contract.validate : []) {
1203
+ if (v?.id && typeof v.command === "string") for (const name of unsetEnvVars(v.command, fullEnv)) misses.push({ ids: [v.id], tool: name, kind: "env-var", source: "env" });
1204
+ }
1205
+ if (io.preflightRun) {
1206
+ const skipIds = [...new Set(misses.flatMap((m) => m.ids))];
1207
+ const run = (a) => heavyStep("precheck", () => io.preflightRun({ ...a, cwd: wt }));
1208
+ const r = await runPreflight({ contract, run, git, env, skipIds, now, ...knownToContract(contract, files) });
1209
+ misses.push(...r.misses);
1210
+ if (r.skipped.length) T.anomalies.push(`Validate preflight run budget spent; ${r.skipped.join(", ")} not run on base`);
1211
+ if (r.reverted.length) T.anomalies.push(`Validate preflight reverted ${r.reverted.length} path(s) its runs changed: ${r.reverted.slice(0, 5).join(", ")}`);
1005
1212
  }
1006
1213
  return misses;
1007
1214
  }
@@ -1055,7 +1262,7 @@ export async function runCore(o) {
1055
1262
  await renew();
1056
1263
  const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
1057
1264
  await renew();
1058
- await attempt("group", 1, rr);
1265
+ await attempt("group", 1, rr, null, "plan");
1059
1266
  return rr?.text ?? "";
1060
1267
  } finally { slot = await heavyAcquire("split"); }
1061
1268
  } });
@@ -1079,6 +1286,45 @@ export async function runCore(o) {
1079
1286
  return { ...r, contract };
1080
1287
  }
1081
1288
  let frozen = null, hash = null, planned = false, files = [], split = null;
1289
+ const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
1290
+ async function notePitfall(trigger, text) {
1291
+ const pit = io.pitfalls ? pendingPitfall(trigger, text) : null;
1292
+ if (pit) { try { pit.before = String(await git(["rev-parse", "HEAD"])).trim(); pendingPits.push(pit); } catch { /* best effort */ } }
1293
+ }
1294
+ /** #4375: the gate of `triggers` (all when none) passed: record its pending rework rounds as pitfalls. Never fails a run. */
1295
+ async function gatePassed(...triggers) {
1296
+ for (const pit of pendingPits.filter((x) => !triggers.length || triggers.includes(x.trigger))) {
1297
+ pendingPits.splice(pendingPits.indexOf(pit), 1);
1298
+ try {
1299
+ const changed = String(await git(["diff", "--name-only", `${pit.before}..HEAD`])).split("\n").map((f) => f.trim()).filter(Boolean);
1300
+ await io.pitfalls.append(repo, pitfallRecord({ pending: pit, files: changed, run: runId, ts: new Date(now()).toISOString() }));
1301
+ ev({ event: "PITFALL_RECORDED", trigger: pit.trigger, sig: pit.sig });
1302
+ } catch { /* the ledger is a cache */ }
1303
+ }
1304
+ }
1305
+ /** #4394: a failed validate round on the mid tier moves this fix round and every later builder call to the top model. */
1306
+ async function escalateOn(trigger) {
1307
+ if (!shouldEscalate(route, escalated, trigger)) return;
1308
+ escalated = true;
1309
+ const rec = { event: "BUILDER_ESCALATED", from: route.model, to: builderModel ?? "default", trigger };
1310
+ ev(rec); await emit(rec);
1311
+ T.decisions.push(`Builder escalated from ${route.model} to the top model after a failed ${trigger} round`);
1312
+ }
1313
+ /**
1314
+ * #4363: one fix round. Mechanical failures (and a conformance/review FAIL citing file:line) run in a fresh session on
1315
+ * the fix model with a narrow prompt; one resume of the build session follows when it made no commit. Else: a resume.
1316
+ */
1317
+ async function fixRound(kind, task, failures, trigger, failureText = "") {
1318
+ await notePitfall(trigger, failureText); await escalateOn(trigger); // #4375: learned once the same gate passes; #4394
1319
+ if (!freshFix(kind, failures)) return await builder(++attemptN, task, sessionId ?? null, false, undefined, { mode: "resume", trigger });
1320
+ const before = String(await git(["rev-parse", "HEAD"])).trim();
1321
+ const ex = [".", `:!${LEAN_DIR}`], diff = String(await git(["diff", `${base}...HEAD`, "--", ...ex]).catch(() => "")), stat = String(await git(["diff", "--stat", `${base}...HEAD`, "--", ...ex]).catch(() => ""));
1322
+ const prompt = fixSessionPrompt({ task, diff, stat, base, contract: frozen, title: iss?.title ?? "", summary: issueSummary(iss?.body) });
1323
+ const r = await builder(++attemptN, prompt, null, false, () => prompt, { model: escalated ? null : fixModel ?? reviewerModel, mode: "fresh", keepSession: true, trigger });
1324
+ if (!sessionId || String(await git(["rev-parse", "HEAD"])).trim() !== before) return r;
1325
+ T.decisions.push(`Fresh ${kind} fix session made no commit; builder session resumed once`);
1326
+ return await builder(++attemptN, task, sessionId, false, undefined, { mode: "resume", trigger });
1327
+ }
1082
1328
  if (isSplit) {
1083
1329
  // ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
1084
1330
  split = await splitStep();
@@ -1100,14 +1346,23 @@ export async function runCore(o) {
1100
1346
  } else { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
1101
1347
  }
1102
1348
  }
1349
+ if ((resumed || salvagedRef || recover) && ctxCfg.mode !== "off") {
1350
+ // #4400: the last run's own records as a bounded digest in the builder prompts; never in issueText (contract quotes)
1351
+ const d = buildResumeDigest({ state: priorState, comments: trustedComments, contract: frozen, files: resumed ? await changedFiles().catch(() => []) : [], lane });
1352
+ resumeNote = d.text;
1353
+ if (d.text) {
1354
+ ev({ event: "RESUME_DIGEST", bytes: Buffer.byteLength(d.text, "utf8"), files: d.files, validates: d.validates, reason: d.reason });
1355
+ T.decisions.push(`Resume digest given to the builder: ${d.files} file(s), ${d.validates} validate result(s)${d.reason ? `, last run ${d.reason}` : ""}`);
1356
+ }
1357
+ }
1103
1358
  await label("building");
1104
1359
  hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
1105
1360
  enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
1106
1361
  facts = await factsPath([wt, cwd], io.fs?.exists);
1107
1362
  const ctxPack = frozen ? null : await contextPackStep();
1108
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1363
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1109
1364
  if (!frozen) {
1110
- await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
1365
+ await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
1111
1366
  planned = true;
1112
1367
  result = await readResult();
1113
1368
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
@@ -1124,7 +1379,23 @@ export async function runCore(o) {
1124
1379
  }
1125
1380
  const blocked = await endIfBlocked();
1126
1381
  if (blocked) return blocked;
1127
- let contract = await readContract();
1382
+ // #4353: the engine's scope gate is the only scope authority; a pure file-scope validate is dropped before it is checked or frozen
1383
+ const dropScope = (c) => {
1384
+ const d = dropScopeValidates(c);
1385
+ if (d.dropped.length) {
1386
+ ev({ event: "CONTRACT_SCOPE_VALIDATE_DROPPED", runs: d.dropped });
1387
+ const note = `Scope-check validate ${d.dropped.map((x) => x.id).join(", ")} dropped at contract freeze: the engine's scope gate (deliverables plus typed amendments) enforces scope`;
1388
+ if (!T.decisions.includes(note)) T.decisions.push(note);
1389
+ }
1390
+ const o = tagOutOfBand(d.contract); // #4404: a requirement measured outside the lane is judged by its instrumentation
1391
+ if (o.tagged.length) {
1392
+ ev({ event: "CONTRACT_OUT_OF_BAND_TAGGED", ids: o.tagged });
1393
+ const note = `Requirement(s) ${o.tagged.join(", ")} tagged verify out_of_band at contract freeze (measured by a job outside the lane)`;
1394
+ if (!T.decisions.includes(note)) T.decisions.push(note);
1395
+ }
1396
+ return o.contract;
1397
+ };
1398
+ let contract = dropScope(await readContract());
1128
1399
  await refreshIssue("contract validation");
1129
1400
  let vc = validateContract(contract, issueText, { repro: reproOn() });
1130
1401
  const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
@@ -1132,14 +1403,14 @@ export async function runCore(o) {
1132
1403
  if ((!vc.ok || missed.length) && sessionId) {
1133
1404
  const problems = [...vc.problems, ...preflightProblems(missed)];
1134
1405
  T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
1135
- await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt);
1136
- contract = await readContract();
1406
+ await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
1407
+ contract = dropScope(await readContract());
1137
1408
  await refreshIssue("contract validation");
1138
1409
  vc = validateContract(contract, issueText, { repro: reproOn() });
1139
1410
  if (missed.length) misses = await preflightValidates(contract);
1140
1411
  }
1141
1412
  if (missed.length) {
1142
- ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind })), corrected: misses.length === 0 });
1413
+ ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind, source: m.source })), found: missed.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind, source: m.source })), corrected: misses.length === 0 });
1143
1414
  if (misses.length) {
1144
1415
  T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
1145
1416
  T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
@@ -1159,13 +1430,28 @@ export async function runCore(o) {
1159
1430
  T.validate = "resumed (existing branch)";
1160
1431
  }
1161
1432
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1433
+ tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
1162
1434
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1163
- const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1435
+ // #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
1436
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${CHECK_PLAN_FILE}`, `${JSON.stringify({ v: 1, base, mode: vcfg.mode, learned: learnedCommands, maxMinutes: Number(o.precheck?.maxMinutes) > 0 ? Number(o.precheck.maxMinutes) : null, precheckTotalSeconds: vcfg.precheckTotalSeconds, precheckTimeoutSeconds: vcfg.precheckTimeoutSeconds })}\n`); } catch { /* the CLI falls back to forge.yaml */ } }
1437
+ route = routeBuilder({ contract: frozen, riskPaths, cfg: routingCfg, topModel: builderModel }); // #4394
1438
+ {
1439
+ const rec = { event: "BUILDER_ROUTE", mode: route.mode, class: route.class, tier: route.tier, model: route.model ?? "default", risk: route.risk };
1440
+ ev(rec); await emit(rec);
1441
+ T.decisions.push(`Builder routed to the ${route.tier} tier (${route.class}; ${route.model ?? "CLI default"})`);
1442
+ }
1443
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
1164
1444
  liveKeys = ["implementation"];
1165
1445
  stage = "implement";
1166
- await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
1446
+ await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
1167
1447
  stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
1168
1448
  result = await readResult();
1449
+ { // #4374: the builder's self-review against the shared rubric, log only
1450
+ const sr = readSelfReview(result);
1451
+ if (sr) ev(sr.state === "present" ? { event: "SELF_REVIEW", answers: sr.items.length, no: sr.no } : { event: "SELF_REVIEW_MISSING", state: sr.state });
1452
+ }
1453
+ // #4364: a builder that writes fixed without a green `forgedock check` on its final tree; recorded, never a failure
1454
+ if (result?.status === "fixed") { const state = await selfCheckNow(); if (state !== "green") ev({ event: "SELF_CHECK_MISSING", state }); }
1169
1455
  {
1170
1456
  const blocked = await endIfBlocked();
1171
1457
  if (blocked) return blocked;
@@ -1182,11 +1468,10 @@ export async function runCore(o) {
1182
1468
  // verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
1183
1469
  let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
1184
1470
  // #4272: an optional .forge/test-map.json picks the commands for the changed files; no map or no match keeps the list.
1185
- const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1186
1471
  // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1187
1472
  const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1188
1473
  // #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
1189
- const pickChecks = () => { const m = scopeChecks(testMap, files, learnedCommands); return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0 }; };
1474
+ const pickChecks = () => planPrecheck({ testMap, files, learned: learnedCommands, maxMinutes: o.precheck?.maxMinutes, verify: vcfg });
1190
1475
  let picked = pickChecks();
1191
1476
  // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1192
1477
  const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
@@ -1198,19 +1483,23 @@ export async function runCore(o) {
1198
1483
  if (round) picked = pickChecks();
1199
1484
  const precheckCommands = picked.commands;
1200
1485
  // #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
1201
- // commands run concurrently up to the host worker cap under ONE heavy slot; the rest are deferred to CI, not failed
1202
- const maxMin = Number(o.precheck?.maxMinutes), totalMs = (maxMin > 0 ? maxMin * 60 : Number(vcfg.precheckTotalSeconds) || 600) * 1000;
1486
+ // commands run concurrently up to the host worker cap; the rest are deferred to CI, not failed. #4344: one heavy
1487
+ // slot per running command, and the worker cap is divided among them (FORGE_HOST_PARALLELISM = floor(N / k))
1488
+ const totalMs = picked.budgetMs;
1203
1489
  const parallelism = Number(verifyParallelism()) || 1;
1204
- const round$ = await heavyStep("precheck", () => runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: vcfg.precheckTimeoutSeconds * 1000, now,
1205
- run: ({ command, timeoutMs }) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }) }));
1490
+ let workers = 1;
1491
+ const round$ = await runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: picked.perCommandMs, now,
1492
+ acquire: () => heavyAcquire("precheck"),
1493
+ run: ({ command, timeoutMs, workers: share }) => { workers = share; const env = laneEnv(); return io.runCommand({ command, cwd: wt, timeoutMs, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(share) } : env }); } });
1206
1494
  const { results, deferred } = round$;
1207
1495
  precheckMs += round$.ms; precheckDeferred += deferred.length;
1208
1496
  if (deferred.length) T.anomalies.push(`deferred-to-ci: pre-PR check budget ${totalMs / 1000} s reached; left to CI: ${deferred.map((c) => `\`${c}\``).join(", ")}`);
1497
+ const roundFailed = results.some((r) => r.rc !== 0);
1209
1498
  ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
1210
- ms: round$.ms, parallelism, ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1499
+ ms: round$.ms, parallelism, workers, ...(picked.source !== "learned" ? { source: picked.source } : {}), ...(roundFailed ? { self_check: await selfCheckNow() } : {}) });
1211
1500
  verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1212
1501
  const failed = results.filter((r) => r.rc !== 0);
1213
- if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
1502
+ if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); await gatePassed("precheck"); break; }
1214
1503
  const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
1215
1504
  if (isSplit) return await needsHuman(`Declared check command(s) fail on the refactor:split move (no builder edits a split); nothing merged: ${names}`, OUTCOME_CODES.CI_RED);
1216
1505
  checkCommandsFailing = true;
@@ -1218,7 +1507,14 @@ export async function runCore(o) {
1218
1507
  T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
1219
1508
  const before = String(await git(["rev-parse", "HEAD"])).trim();
1220
1509
  const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
1221
- await builder(++attemptN, precheckFailurePrompt(capped, vcfg.mode), sessionId);
1510
+ // #4390: the builder sees each failure quiet (tail, failing ids, full-log path); the pitfall text stays the capped tail
1511
+ const quiet = await Promise.all(failed.map(async (f, i) => {
1512
+ const rel = `${CHECK_LOG_DIR}/precheck-${round + 1}-${i + 1}.log`;
1513
+ let logPath = null;
1514
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${rel}`, String(f.output ?? "")); logPath = rel; } catch { /* tail only */ } }
1515
+ return { command: f.command, rc: f.rc, output: quietFailure(f.output, { maxChars: capped[i].output.length || 500, logPath }) };
1516
+ }));
1517
+ await fixRound("precheck", precheckFailurePrompt(quiet, vcfg.mode), [], "precheck", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
1222
1518
  await upsertStage("implementation");
1223
1519
  result = (await readResult()) ?? result;
1224
1520
  if (String(await git(["rev-parse", "HEAD"])).trim() === before) { T.anomalies.push(`Declared check command(s) failing and the builder made no new commit for them: ${names}`); break; }
@@ -1244,13 +1540,14 @@ export async function runCore(o) {
1244
1540
  let edges = undeclared();
1245
1541
  if (edges.length && sessionId) {
1246
1542
  T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
1247
- await builder(++attemptN, structurePrompt(edges, vcfg.mode), sessionId);
1543
+ await fixRound("structure", structurePrompt(edges, vcfg.mode), [], "structure", edges.map((e) => `undeclared package edge \`${e.from}\` -> \`${e.to}\` (${e.manifest})`).join("\n"));
1248
1544
  await upsertStage("implementation");
1249
1545
  result = (await readResult()) ?? result;
1250
1546
  structure = (await structureOf()) ?? structure;
1251
1547
  edges = undeclared();
1252
1548
  }
1253
1549
  verifyFacts.structure = structure; verifyFacts.undeclaredEdges = edges;
1550
+ if (!edges.length) await gatePassed("structure");
1254
1551
  if (edges.length) {
1255
1552
  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);
1256
1553
  }
@@ -1262,12 +1559,13 @@ export async function runCore(o) {
1262
1559
  if (docs && !docs.ok && sessionId) {
1263
1560
  ev({ event: "DOCS_STALE", round: 1 });
1264
1561
  T.decisions.push("Docs freshness check failed; builder resumed once");
1265
- await builder(++attemptN, docsPrompt(docs.output), sessionId);
1562
+ await fixRound("docs", docsPrompt(docs.output), [], "docs", String(docs.output ?? ""));
1266
1563
  await upsertStage("implementation");
1267
1564
  result = (await readResult()) ?? result;
1268
1565
  docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
1269
1566
  }
1270
1567
  verifyFacts.docs = docs;
1568
+ if (docs?.ok) await gatePassed("docs");
1271
1569
  if (docs && !docs.ok) {
1272
1570
  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);
1273
1571
  }
@@ -1280,30 +1578,98 @@ export async function runCore(o) {
1280
1578
  if (missing.length && sessionId) {
1281
1579
  ev({ event: "ENUMERATING_MISSING", count: missing.length });
1282
1580
  T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
1283
- await builder(++attemptN, missingEntriesPrompt(missing, vcfg.mode), sessionId);
1581
+ await fixRound("enumerating", missingEntriesPrompt(missing, vcfg.mode), [], "enumerating", missing.map((m) => `enumerating test ${m.test} misses new member \`${m.symbol}\``).join("\n"));
1284
1582
  await upsertStage("implementation");
1285
1583
  result = (await readResult()) ?? result;
1286
1584
  missing = await missingNow();
1287
1585
  }
1586
+ if (!missing.length) await gatePassed("enumerating");
1288
1587
  if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
1289
1588
  }
1290
- /** #4322: null (nothing to do), {done} (terminal), or {renumbered: true}. Fetches the lane, so the check sees its tip. */
1291
- async function sequenceHandoff(trigger) {
1589
+ /**
1590
+ * #4322: null (nothing to do), {done} (terminal), or {renumbered: true, deterministic?}. Fetches the lane, so the check
1591
+ * sees its tip. #4386: the engine renumbers first (no builder, no session); the builder only when that is unsupported.
1592
+ * `laneRef`: the pre-merge lane head after a base-sync took the base side of sequence-only conflicts.
1593
+ */
1594
+ async function sequenceHandoff(trigger, { laneRef = null } = {}) {
1292
1595
  if (!io.sequenceCheck || isSplit) return null;
1293
1596
  try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
1294
1597
  const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
1295
1598
  if (!collisions.length) return null;
1296
1599
  const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
1297
1600
  ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
1298
- T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ")}; builder renumbers`);
1299
- await builder(++attemptN, sequencePrompt(collisions, lane, vcfg.mode), sessionId ?? null);
1601
+ const taken = collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ");
1602
+ if (io.sequenceRenumber) {
1603
+ const det = (await io.sequenceRenumber({ cwd: wt, base, ...(laneRef ? { laneRef } : {}), regenerate: o.sequenceRegenerate ?? null }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? { unsupported: "no result" };
1604
+ if (det.ok && det.moves?.length) {
1605
+ ev({ event: "SEQUENCE_RENUMBERED", trigger, mode: "deterministic", moves: det.moves });
1606
+ T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; renumbered deterministically (no builder): ${det.moves.map((m) => `${m.from} → ${m.to}`).join(", ")}`);
1607
+ const v = (await io.sequenceVerify?.({ cwd: wt, base, before, moves: det.moves }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
1608
+ if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the deterministic renumber (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
1609
+ await gatePassed("sequence");
1610
+ files = await changedFiles();
1611
+ return { renumbered: true, deterministic: true };
1612
+ }
1613
+ const why = det.unsupported ?? "no movable sequence file";
1614
+ ev({ event: "SEQUENCE_RENUMBER_FALLBACK", trigger, reason: why });
1615
+ T.decisions.push(`Deterministic renumber not possible (${why}); builder renumbers`);
1616
+ }
1617
+ T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${taken}; builder renumbers`);
1618
+ await fixRound("sequence", sequencePrompt(collisions, lane, vcfg.mode), [], "sequence", collisions.map((c) => `sequence number collision: ${c.lane} already taken by ${(c.base ?? []).join(", ")}`).join("\n"));
1300
1619
  result = (await readResult()) ?? result;
1301
1620
  const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
1302
1621
  if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the builder renumbered (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
1622
+ await gatePassed("sequence");
1303
1623
  files = await changedFiles();
1304
1624
  return { renumbered: true };
1305
1625
  }
1306
1626
  { const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
1627
+ /** #4406: same-app duplicate exports (verify.duplicate_exports): one "rename or reuse" round, then an anomaly. True when a fix round ran. */
1628
+ async function duplicateGate(trigger) {
1629
+ if (!io.duplicateExports || isSplit || vcfg.duplicateExports === false) return false;
1630
+ const found = async () => (await io.duplicateExports({ cwd: wt, base }).catch(() => null)) ?? [];
1631
+ let dups = await found();
1632
+ if (dups.length && sessionId) {
1633
+ const text = dups.map((d) => `duplicate export \`${d.symbol}\`: ${d.path} and ${(d.others ?? []).join(", ")}`).join("\n");
1634
+ ev({ event: "DUPLICATE_EXPORTS", trigger, count: dups.length });
1635
+ T.decisions.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}; builder resumed once`);
1636
+ await fixRound("duplicate", duplicateExportsPrompt(dups, vcfg.mode), [], "duplicate", text);
1637
+ result = (await readResult()) ?? result;
1638
+ files = await changedFiles();
1639
+ dups = await found();
1640
+ if (dups.length) T.anomalies.push(`Duplicate export(s) remain (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}`);
1641
+ else await gatePassed("duplicate");
1642
+ return true;
1643
+ }
1644
+ if (dups.length) T.anomalies.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path}`).join("; ")}`);
1645
+ return false;
1646
+ }
1647
+ await duplicateGate("hand-off");
1648
+ /** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
1649
+ async function postMergeGate(trigger) {
1650
+ const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
1651
+ let fixed = false, results = [];
1652
+ for (let round = 0; commands.length; round++) {
1653
+ await renew();
1654
+ results = [];
1655
+ for (const command of commands) {
1656
+ let x; try { x = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env: laneEnv() })); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1657
+ results.push({ command, rc: Number(x?.rc ?? 1), output: String(x?.output ?? "") });
1658
+ }
1659
+ ev({ event: "POST_MERGE_CHECKS", trigger, round, results: results.map(({ command, rc }) => ({ command, rc })) });
1660
+ const failed = results.filter((r) => r.rc !== 0), names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
1661
+ if (!failed.length) { if (round) await gatePassed("post_merge"); break; }
1662
+ if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Post-merge check(s) still fail after merging ${base} (${trigger}): ${names}`); break; }
1663
+ T.decisions.push(`Post-merge check(s) failed after merging ${base} (${trigger}): ${names}; builder resumed before the push (round ${round + 1}/${vcfg.maxRounds})`);
1664
+ const before = String(await git(["rev-parse", "HEAD"])).trim();
1665
+ const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
1666
+ await fixRound("post_merge", postMergeFailurePrompt(capped, vcfg.mode), [], "post_merge", capped.map((f) => `${f.command}\n${f.output ?? ""}`).join("\n"));
1667
+ result = (await readResult()) ?? result;
1668
+ fixed = true;
1669
+ if ((String(await git(["rev-parse", "HEAD"])).trim()) === before) { T.anomalies.push(`Post-merge check(s) failing and the builder made no new commit for them: ${names}`); break; }
1670
+ }
1671
+ return { fixed, ran: results.map(({ command, rc }) => `${command}: ${rc === 0 ? "pass" : `fail (exit ${rc})`}`).join("; ") };
1672
+ }
1307
1673
  const structureLine = structureNote(structure);
1308
1674
 
1309
1675
  // ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
@@ -1311,6 +1677,7 @@ export async function runCore(o) {
1311
1677
  // cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
1312
1678
  let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
1313
1679
  const ciSeenRuns = [];
1680
+ let ciGateNames; // #4387: checks that gate CI verification, read once per lane
1314
1681
  /** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
1315
1682
  async function ciVerify(trigger) {
1316
1683
  let fixes = 0;
@@ -1318,15 +1685,18 @@ export async function runCore(o) {
1318
1685
  const head = String(await git(["rev-parse", "HEAD"])).trim();
1319
1686
  if (head === ciVerifiedHead || ciFellBack) return { fixes };
1320
1687
  files = await changedFiles();
1321
- const v = await runCiVerify({ io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns,
1688
+ if (ciGateNames === undefined) ciGateNames = vcfg.workflow ? null : await ciGate({ io, lane, mergeWait, verify: vcfg });
1689
+ const verifyArgs = { io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns, gate: ciGateNames,
1322
1690
  push: () => gitPush(["push", "-u", "origin", branch]),
1323
- onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } });
1691
+ onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } };
1692
+ const v = await runCiVerify(verifyArgs);
1324
1693
  if (v.runId != null) ciSeenRuns.push(v.runId);
1325
1694
  const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
1326
1695
  ev(rec); await emit({ ...rec, phase: "verify" });
1327
1696
  const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
1328
1697
  if (v.result === "pass" || v.result === "skipped") {
1329
1698
  ciVerifiedHead = head;
1699
+ if (v.result === "pass") await gatePassed("ci_red", "merge_ci");
1330
1700
  row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
1331
1701
  if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
1332
1702
  return { fixes };
@@ -1341,7 +1711,7 @@ export async function runCore(o) {
1341
1711
  ciFellBack = true;
1342
1712
  row("unavailable: local fallback");
1343
1713
  T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
1344
- await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
1714
+ await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null, false, undefined, { trigger: "ci_unavailable" });
1345
1715
  await upsertStage("implementation");
1346
1716
  result = (await readResult()) ?? result;
1347
1717
  return { fixes: fixes + 1 };
@@ -1355,11 +1725,22 @@ export async function runCore(o) {
1355
1725
  ciRounds++; fixes++;
1356
1726
  T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
1357
1727
  ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
1358
- await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
1728
+ await fixRound("ci", ciFailurePrompt(v.failures, vcfg.mode), [], "ci_red", failureLog(v.failures));
1359
1729
  await upsertStage("implementation");
1360
1730
  result = (await readResult()) ?? result;
1361
1731
  if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
1362
- return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED) };
1732
+ // #4387: "no commit" says the failure is not the change's: rerun the failed jobs once to tell a flake from a real failure.
1733
+ const rr = isSplit ? { ok: false, runs: [], jobs: [], detail: "refactor:split" } : await rerunFailedOnce({ io, v });
1734
+ const v2 = rr.ok ? await runCiVerify({ ...verifyArgs, rerunOf: rerunTarget(v) }) : null;
1735
+ if (rr.detail !== "no rerun seam") ev({ event: "CI_FLAKE_RERUN", trigger, head, runs: rr.runs, jobs: rr.jobs, result: v2?.result ?? "rerun-failed", detail: v2?.detail ?? rr.detail });
1736
+ if (v2?.result === "pass") {
1737
+ ciVerifiedHead = head; await gatePassed("ci_red", "merge_ci"); row(`CI green at ${head.slice(0, 12)} after a rerun of the failed jobs`);
1738
+ T.decisions.push(`CI verification red (${jobs}), no fix commit; green after one rerun of the failed jobs (flake)`);
1739
+ return { fixes };
1740
+ }
1741
+ if (v2?.result === "timeout") return { done: await finish("ci-pending", `CI rerun of the failed jobs on ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
1742
+ const how = !rr.ok ? (rr.detail === "no rerun seam" ? "" : ` (rerun of the failed jobs not possible: ${rr.detail})`) : " and stayed red after one rerun of the failed jobs";
1743
+ return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it${how}; nothing merged.`, OUTCOME_CODES.CI_RED) };
1363
1744
  }
1364
1745
  }
1365
1746
  }
@@ -1370,8 +1751,8 @@ export async function runCore(o) {
1370
1751
  // plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
1371
1752
  // A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
1372
1753
  await label("in-review");
1373
- const shadow = conformance !== "enforce";
1374
- let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
1754
+ let shadow = conformance !== "enforce";
1755
+ let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
1375
1756
  const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
1376
1757
  const triggerSkipNote = "## Conformance Review\n**Skipped by `review.trigger: enforce`:** no risk path, package edge or hotspot in the diff, and every deterministic gate passed; no reviewer ran (#4273).";
1377
1758
  async function amend() {
@@ -1406,17 +1787,61 @@ export async function runCore(o) {
1406
1787
  prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
1407
1788
  : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
1408
1789
  await renew();
1409
- await attempt("review", ++reviewN, rr);
1790
+ await attempt("review", ++reviewN, rr, null, "review");
1410
1791
  const parsed = parseReview(rr?.text);
1411
1792
  review = repair ? mergeReviewRows(first, parsed) : parsed;
1412
1793
  first = review;
1413
1794
  d = decide(review, ctx);
1414
1795
  if (d.review) review = d.review;
1415
1796
  ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
1797
+ const cmp = selfReviewCompared ? null : compareSelfReview(readSelfReview(result), d); // #4374: once, on the first judgement
1798
+ if (cmp) { selfReviewCompared = true; ev({ event: cmp.agreed ? "SELF_REVIEW_AGREED" : "SELF_REVIEW_DISAGREED", reviewer: cmp.reviewer, self_no: cmp.self_no, failures: d.failures.map((x) => x.code) }); }
1416
1799
  if (!d.state.startsWith("review.")) return d;
1417
1800
  }
1418
1801
  return d;
1419
1802
  }
1803
+ /**
1804
+ * #4397: one independent adversarial verifier call per head: fresh context, read-only, never the builder's model, result
1805
+ * or notes. Returns the failures to enforce ([] in shadow, on an empty diff or on a verifier error, which is an anomaly).
1806
+ */
1807
+ const verifiedHeads = new Map();
1808
+ let verifierRun = null, verifyN = 0;
1809
+ async function runVerifier() {
1810
+ const head = String(await git(["rev-parse", "HEAD"])).trim();
1811
+ if (verifiedHeads.has(head)) return verifiedHeads.get(head);
1812
+ const diff = String(await git(["diff", `${base}...HEAD`]));
1813
+ const run = { mode: verifierCfg.mode, head, findings: [], model: null, swapped: false, cost_usd: 0 };
1814
+ if (!diff.trim()) run.skipped = "empty_diff";
1815
+ else {
1816
+ const bytes = Buffer.byteLength(diff, "utf-8");
1817
+ const stat = bytes > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
1818
+ const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1819
+ const callers = await callersOf({ symbols: changedSymbols(diff), gitGrep: git, ignore: IGNORE });
1820
+ Object.assign(run, pickVerifierModel({ configured: verifierCfg.model, builderModel, builderReported: [...impl.models, ...(route?.tier === "mid" ? [route.model] : [])] }));
1821
+ const { maxMs, maxTurns } = verifierBudget(bytes);
1822
+ try {
1823
+ await renew();
1824
+ const rr = await callRunner({ role: "verifier", issue, cwd: wt, model: run.model, resume: null, system: VERIFIER_SYSTEM, readOnly: true, maxMs, maxTurns,
1825
+ prompt: verifierPrompt({ issue: iss, comments: trustedComments, contract: effective, diff, stat, base, files, callers }) });
1826
+ await renew();
1827
+ await attempt("verify", ++verifyN, rr, null, "verifier");
1828
+ run.findings = parseVerifierFindings(rr?.text, { pathExists: (x) => listed.has(x) });
1829
+ run.cost_usd = num(rr?.costUsd) ?? 0;
1830
+ } catch (e) {
1831
+ if (e?.deferUsageLimit) throw e;
1832
+ run.error = String(e?.code || e?.message || e).split("\n")[0].slice(0, 200);
1833
+ T.anomalies.push(`Independent verifier failed at ${head.slice(0, 12)}: ${run.error}`);
1834
+ }
1835
+ }
1836
+ const failures = verifierCfg.mode === "enforce" ? findingsToFailures(run.findings) : [];
1837
+ ev({ event: "VERIFIER", mode: run.mode, head, model: run.model, swapped: run.swapped, findings: run.findings.length, cost_usd: run.cost_usd, implement_cost_usd: impl.cost,
1838
+ ratio: impl.cost > 0 ? Math.round((run.cost_usd / impl.cost) * 1000) / 1000 : null, ...(run.skipped ? { skipped: run.skipped } : {}), ...(run.error ? { error: run.error } : {}) });
1839
+ if (run.findings.length) T.decisions.push(`Independent verifier (${run.mode}) found ${run.findings.length} defect(s): ${run.findings.slice(0, 3).map((f) => `${f.file}:${f.line}`).join(", ")}`);
1840
+ verifiedHeads.set(head, failures);
1841
+ verifierRun = run;
1842
+ return failures;
1843
+ }
1844
+ const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
1420
1845
  /** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
1421
1846
  async function rerunFailedValidates(contract) {
1422
1847
  if (!io.runCommand) return null;
@@ -1437,12 +1862,26 @@ export async function runCore(o) {
1437
1862
  await refreshIssue("conformance");
1438
1863
  result = (await readResult()) ?? result;
1439
1864
  files = await changedFiles();
1865
+ gateRan = true;
1866
+ // #4398: a diff touching a declared review.risk_paths glob makes this lane enforce from here on (sticky)
1867
+ const hits = shadow ? riskPathHits(files, riskPaths) : [];
1868
+ if (hits.length) {
1869
+ shadow = false; riskEnforced = hits;
1870
+ ev({ event: "CONFORMANCE_RISK_ENFORCED", paths: hits });
1871
+ T.decisions.push(`Conformance enforced (review.risk_paths): ${hits.slice(0, 10).join(", ")}${hits.length > 10 ? ` and ${hits.length - 10} more` : ""}`);
1872
+ }
1440
1873
  const amendProblems = await amend();
1441
1874
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1442
1875
  const hotspots = await hotspotsOf({ base, paths: files });
1443
1876
  const validateRuns = await rerunFailedValidates(effective);
1444
1877
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1445
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas });
1878
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
1879
+ // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
1880
+ if (pc.superseded?.length) {
1881
+ ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
1882
+ const note = `Scope-check validate ${pc.superseded.map((x) => x.id).join(", ")} superseded by the scope gate (amended: ${[...new Set(pc.superseded.flatMap((x) => x.files))].join(", ")})`;
1883
+ if (!T.decisions.includes(note)) T.decisions.push(note);
1884
+ }
1446
1885
  Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1447
1886
  // #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
1448
1887
  // never a conformance failure, never a builder resume
@@ -1466,14 +1905,19 @@ export async function runCore(o) {
1466
1905
  }
1467
1906
  triggerSkipped = false;
1468
1907
  }
1469
- const d = await reviewOnce();
1470
- if (!shadow) return { ...d, by: "review" };
1908
+ let d = await reviewOnce();
1909
+ // #4397: the verifier runs only on a change every gate and the reviewer passed; in enforce its findings block
1910
+ if (verifierCfg.mode !== "off" && d.state === "conforms" && !pre.length) {
1911
+ const vf = await runVerifier();
1912
+ if (vf.length) d = { state: "nonconforming", failures: vf, by: "verifier" };
1913
+ }
1914
+ if (!shadow) return { ...d, by: d.by ?? "review" };
1471
1915
  // shadow: record what enforce would have done; block only on what the previous reviewer blocked on
1472
1916
  const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
1473
1917
  const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
1474
1918
  if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
1475
1919
  const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
1476
- return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
1920
+ return { state: blocking.length ? "nonconforming" : "conforms", by: d.by ?? "review", failures: blocking, would, wouldFail, reviewState: d.state };
1477
1921
  }
1478
1922
  const verifyReported = new Set();
1479
1923
  /**
@@ -1527,7 +1971,7 @@ export async function runCore(o) {
1527
1971
  const outcome = await gate();
1528
1972
  verdict = outcome;
1529
1973
  for (const f of [...(outcome.failures ?? []), ...(outcome.wouldFail ?? [])]) if (f?.code) failureClasses.add(f.code);
1530
- if (outcome.state === "conforms") break;
1974
+ if (outcome.state === "conforms") { await gatePassed("conformance"); break; }
1531
1975
  const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
1532
1976
  const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1533
1977
  if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
@@ -1544,7 +1988,7 @@ export async function runCore(o) {
1544
1988
  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`);
1545
1989
  // A failed validate may only mean the base moved on: fetch the lane so the resume can merge it (never rebase).
1546
1990
  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 */ } }
1547
- await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode, lane), sessionId);
1991
+ await fixRound("conformance", conformanceResumePrompt(outcome.failures, vcfg.mode, lane), outcome.failures, "conformance", failureLog(outcome.failures));
1548
1992
  await upsertStage("implementation"); // the published note follows the code after every resume
1549
1993
  }
1550
1994
  // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
@@ -1567,14 +2011,24 @@ export async function runCore(o) {
1567
2011
  ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
1568
2012
  await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
1569
2013
  T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
1570
- await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, degraded: degradedReq?.tracked ?? (degraded ? "untracked" : null) }));
2014
+ await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, degraded: degradedReq?.tracked ?? (degraded ? "untracked" : null) }) + verifierSection());
1571
2015
  } else {
1572
2016
  T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
1573
- await post("FORGE:REVIEW", renderReview(review, effective));
2017
+ await post("FORGE:REVIEW", renderReview(review, effective) + verifierSection());
1574
2018
  }
1575
2019
  }
1576
2020
  ev({ event: "PHASE_COMMIT", phase: "review" });
1577
- const closing = closePlan(effective);
2021
+ const closing = closePlan(effective, { title: iss.title, review });
2022
+ if (closing.reason === "partial") { // #4398: an investigation whose question is not answered carries it forward
2023
+ const untracked = closing.open.filter((r) => !r.tracked);
2024
+ if (untracked.length) {
2025
+ const carried = untracked.map((r) => ({ ...r, disposition: "defer", why: "investigation question not answered" }));
2026
+ await track(carried, "the investigation's question is not answered");
2027
+ for (const [i, r] of untracked.entries()) r.tracked = carried[i].tracked;
2028
+ }
2029
+ for (const t of closing.open.map((r) => r.tracked).filter(Boolean)) if (!closing.tracked.includes(t)) closing.tracked.push(t);
2030
+ T.decisions.push(`Investigation not answered (${closing.open.map((r) => r.id).join(", ")}): closing as partial, carried forward in ${closing.tracked.join(", ")}`);
2031
+ }
1578
2032
  if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
1579
2033
 
1580
2034
  // ---- 5. push + PR ----
@@ -1611,7 +2065,8 @@ export async function runCore(o) {
1611
2065
  T.decisions.push(`merge_wait=${mergeWait}: ${policyText}`);
1612
2066
 
1613
2067
  // ---- base-sync (#4100): merge the lane into the branch when the PR conflicts; merge only, never rebase or force-push ----
1614
- let syncs = 0, mergeRetries = 0;
2068
+ // #4386: the bound counts syncs against one base head; a sync overtaken by a new base head made progress.
2069
+ let syncs = 0, sameBaseSyncs = 0, lastSyncBase = null, mergeRetries = 0;
1615
2070
  const MAX_SYNCS = 2;
1616
2071
  const gitOut = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
1617
2072
  /** CONFLICTING / DIRTY, or UNKNOWN after one short re-poll. Unreadable state counts as not conflicting. */
@@ -1646,11 +2101,12 @@ export async function runCore(o) {
1646
2101
  const syncRow = [];
1647
2102
  /** Returns null to continue with the merge flow, or a terminal result. */
1648
2103
  async function baseSync(trigger, soft = false) {
1649
- if (syncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
1650
- syncs++;
1651
2104
  await renew();
1652
2105
  try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
1653
- const baseSha = (await gitOut(["rev-parse", base])).slice(0, 12);
2106
+ const baseFull = await gitOut(["rev-parse", base]), baseSha = baseFull.slice(0, 12);
2107
+ if (baseFull !== lastSyncBase) { lastSyncBase = baseFull; sameBaseSyncs = 0; }
2108
+ if (sameBaseSyncs >= MAX_SYNCS) return await needsHuman(`PR #${pr} still conflicts with ${lane} after ${MAX_SYNCS} base-syncs against ${baseSha || base} (${trigger}).`, OUTCOME_CODES.BASE_CONFLICT);
2109
+ sameBaseSyncs++; syncs++;
1654
2110
  const before = await gitOut(["rev-parse", "HEAD"]);
1655
2111
  let clean = true, files = [];
1656
2112
  try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
@@ -1658,13 +2114,21 @@ export async function runCore(o) {
1658
2114
  await gitOut(["merge", "--abort"]);
1659
2115
  return await needsHuman(`PR #${pr} (refactor:split) conflicts with ${lane} in ${files.join(", ") || "unknown files"}; a split is never resolved by an LLM. Close the PR and rerun on the new base.`, OUTCOME_CODES.BASE_CONFLICT);
1660
2116
  }
1661
- let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
2117
+ let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)", detResolved = false;
2118
+ // #4386: conflicts only in sequence files (numbered, journal, snapshot, their index) resolve without a builder
2119
+ const rs = !clean && io.sequenceResolve ? ((await io.sequenceResolve({ cwd: wt, files }).catch((e) => ({ unsupported: String(e?.message ?? e) }))) ?? {}) : null;
2120
+ if (rs?.unsupported) T.decisions.push(`Base-sync ${syncs}: no deterministic resolution (${rs.unsupported})`);
1662
2121
  if (clean) {
1663
2122
  how = "clean merge of the base into the branch";
2123
+ } else if (rs?.ok) {
2124
+ detResolved = true;
2125
+ T.decisions.push(`Base-sync ${syncs}: conflicts only in sequence files (${files.join(", ")}); base side taken, lane files renumbered by the engine (no builder)`);
2126
+ how = `deterministic: base side taken for ${files.length} sequence file(s)`;
2127
+ tests = "not required (deterministic sequence resolution)";
1664
2128
  } else {
1665
2129
  T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
1666
2130
  const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge.${sequenceSyncNote(files)} Do not push.`;
1667
- await builder(++attemptN, prompt, sessionId ?? null);
2131
+ await builder(++attemptN, prompt, sessionId ?? null, false, undefined, { trigger: "recovery" }); // #4362: base-sync conflict
1668
2132
  if (await unresolved()) {
1669
2133
  await gitOut(["merge", "--abort"]);
1670
2134
  return await needsHuman(`Base-sync could not resolve the conflicts in ${files.join(", ")} (conflict markers or an unfinished merge remain).`, OUTCOME_CODES.BASE_CONFLICT);
@@ -1673,11 +2137,19 @@ export async function runCore(o) {
1673
2137
  const r2 = await readResult();
1674
2138
  tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
1675
2139
  }
1676
- const sq = await sequenceHandoff(`base-sync ${syncs}`); // #4322: a clean text merge can still bring a taken number
2140
+ const sq = await sequenceHandoff(`base-sync ${syncs}`, detResolved ? { laneRef: before } : {}); // #4322: a clean text merge can still bring a taken number
1677
2141
  if (sq?.done) return sq.done;
1678
- if (sq?.renumbered) { how += "; builder renumbered sequence-numbered files"; }
2142
+ if (sq?.renumbered) { how += sq.deterministic ? "; lane files renumbered deterministically" : "; builder renumbered sequence-numbered files"; }
2143
+ let pm = { fixed: false, ran: "" }, dupFixed = false;
2144
+ if ((await gitOut(["rev-parse", "HEAD"])) !== before) { // #4406: the merged tree, before the push
2145
+ pm = await postMergeGate(`base-sync ${syncs}`);
2146
+ if (pm.ran) tests = clean ? pm.ran : `${tests}; post-merge: ${pm.ran}`;
2147
+ if (pm.fixed) how += "; builder fixed post-merge check failures";
2148
+ dupFixed = await duplicateGate(`base-sync ${syncs}`);
2149
+ if (dupFixed) how += "; builder resolved duplicate exports";
2150
+ }
1679
2151
  const after = await gitOut(["rev-parse", "HEAD"]);
1680
- if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
2152
+ if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
1681
2153
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
1682
2154
  let ciFixes = 0;
1683
2155
  if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
@@ -1686,12 +2158,14 @@ export async function runCore(o) {
1686
2158
  rereview = "refactor-verify re-run against the new base: ok";
1687
2159
  }
1688
2160
  if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
1689
- if (!isSplit && (!clean || sq?.renumbered || ciFixes)) {
2161
+ const engineOnly = !ciFixes && !pm.fixed && !dupFixed && (clean || detResolved) && (!sq?.renumbered || sq.deterministic); // #4386: no builder touched the delta
2162
+ if (!isSplit && engineOnly && (detResolved || sq?.deterministic)) rereview = "not required (deterministic renumber + base merge)";
2163
+ else if (!isSplit && (!clean || sq?.renumbered || ciFixes || pm.fixed || dupFixed)) {
1690
2164
  let rv = await doReview();
1691
2165
  if (rv.env) return await envNeedsHuman(rv.env);
1692
2166
  if (rv?.fail) {
1693
2167
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
1694
- await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
2168
+ await fixRound("review", conformanceResumePrompt(rv.failures, vcfg.mode), rv.failures, "review", failureLog(rv.failures));
1695
2169
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
1696
2170
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
1697
2171
  rv = await doReview();
@@ -1699,6 +2173,7 @@ export async function runCore(o) {
1699
2173
  }
1700
2174
  if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
1701
2175
  if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
2176
+ await gatePassed("review");
1702
2177
  rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
1703
2178
  }
1704
2179
  localHead = await gitOut(["rev-parse", "HEAD"]);
@@ -1724,7 +2199,7 @@ export async function runCore(o) {
1724
2199
  T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
1725
2200
  ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
1726
2201
  const before = await gitOut(["rev-parse", "HEAD"]);
1727
- await builder(++attemptN, ciFailurePrompt(failures, vcfg.mode), sessionId);
2202
+ await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
1728
2203
  await upsertStage("implementation");
1729
2204
  result = (await readResult()) ?? result;
1730
2205
  if ((await gitOut(["rev-parse", "HEAD"])) === before) {
@@ -1781,6 +2256,30 @@ export async function runCore(o) {
1781
2256
  await emit(rec);
1782
2257
  }
1783
2258
 
2259
+ /**
2260
+ * #4403 e2e merge gate (e2e.mjs): a lane touching e2e specs or with [type:e2e] criteria runs the repo's e2e (label or
2261
+ * dispatch) on the reviewed head before merge. Returns null to merge, {again} after a fix round, or {done}.
2262
+ */
2263
+ let e2eHead = null;
2264
+ const e2eRuns = [];
2265
+ async function e2eGate() {
2266
+ const cur = await changedFiles(), want = e2eWanted({ cfg: vcfg.e2e, files: cur, contract: effective });
2267
+ if (!want.run) { if (want.reason === NO_TRIGGER && want.specs.length) T.decisions.push(`e2e not run: ${want.specs.length} e2e spec(s) changed but ${NO_TRIGGER}`); e2eHead = localHead; return null; }
2268
+ const v = await runE2eGate({ io: { ...io, addPrLabel: (n, l) => ghw(["pr", "edit", String(n), "-R", repo, "--add-label", l]) }, pr, branch, head: localHead, cfg: vcfg.e2e, specs: want.specs,
2269
+ changedFiles: cur, ciMaxMinutes, now, sleep, random, exclude: e2eRuns, onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "merge", detail: `e2e pending on PR #${pr}` }); } });
2270
+ if (v.runId != null) e2eRuns.push(v.runId);
2271
+ const own = v.result === "fail" ? (vcfg.e2e.blocking === "all" ? v.failures : ownFailures(v.failures, want.specs)) : [];
2272
+ const rec = { event: "E2E_VERIFY", pr, head: localHead, reason: want.reason, result: v.result, specs: want.specs, failures: v.failures.map((f) => f.job), own: own.map((f) => f.job), blocking: vcfg.e2e.blocking };
2273
+ ev(rec); await emit({ ...rec, phase: "merge" });
2274
+ T.decisions.push(`e2e (${want.reason}): ${v.result} at ${localHead.slice(0, 12)}${v.detail ? ` (${v.detail})` : ""}`);
2275
+ if (v.result === "timeout") return { done: await finish("ci-pending", `PR #${pr} e2e still pending after ${ciMaxMinutes} min`, { pr }) };
2276
+ if (v.result === "unavailable") return { done: await needsHuman(`e2e verification unavailable on PR #${pr} (${v.detail}); nothing merged.`) };
2277
+ if (own.length) { const r = await mergeFixRound(own.map((f) => ({ name: f.job, url: f.url }))); return r ? { done: r } : { again: true }; }
2278
+ if (v.result === "fail") T.anomalies.push(`Pre-existing e2e failure(s) reported, not blocking: ${v.failures.map((f) => f.job).join(", ")}`);
2279
+ e2eHead = localHead;
2280
+ return null;
2281
+ }
2282
+
1784
2283
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
1785
2284
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
1786
2285
  for (;;) {
@@ -1842,11 +2341,34 @@ export async function runCore(o) {
1842
2341
  return await needsHuman(`CI passed for ${gateHead.slice(0, 12)} but the reviewed head is ${localHead.slice(0, 12)}; refusing to merge an unreviewed head.`, OUTCOME_CODES.MERGE_HEAD_MISMATCH);
1843
2342
  }
1844
2343
  }
2344
+ if (e2eHead !== localHead) { const g = await e2eGate(); if (g?.done) return g.done; if (g?.again) continue; }
1845
2345
  if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
2346
+ // #4386: late binding. A lane with numbered files holds the host sequence lease from here through the merge and
2347
+ // re-checks its numbers against the fetched lane; a taken number is renumbered by the engine and only CI is waited on.
2348
+ if (io.sequenceCheck && io.sequenceLease && !isSplit) {
2349
+ if (!seqLease) {
2350
+ const mine = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
2351
+ if (mine.length) {
2352
+ seqLease = await io.sequenceLease.acquire({ repo, lane, dirs: [...new Set(mine.map((f) => dirname(f.path)))], issue, maxWaitS: ciMaxMinutes * 60 }).catch(() => null);
2353
+ if (seqLease) T.decisions.push(seqLease.timedOut ? `Sequence lease timed out after ${Math.round((seqLease.waitMs ?? 0) / 1000)}s (fail-open)` : `Sequence lease held (waited ${Math.round((seqLease.waitMs ?? 0) / 1000)}s)`);
2354
+ }
2355
+ }
2356
+ if (seqLease) {
2357
+ try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
2358
+ const late = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
2359
+ if (late.length) {
2360
+ ev({ event: "SEQUENCE_LATE_BIND", pr, files: late.map((c) => c.lane) });
2361
+ const r = await baseSync("merge gate: sequence number taken"); if (r) return r;
2362
+ continue; // wait for the required checks of the renumbered head
2363
+ }
2364
+ }
2365
+ }
1846
2366
  T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
1847
2367
  laneGuard(resumed?.base ?? lane, "merge");
1848
2368
  try {
1849
2369
  await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
2370
+ try { seqLease?.release(); } catch { /* fail-open */ }
2371
+ seqLease = null;
1850
2372
  break;
1851
2373
  } catch (e) {
1852
2374
  const msg = `${e?.stderr ?? ""}${e?.message ?? ""}`;
@@ -1879,8 +2401,11 @@ export async function runCore(o) {
1879
2401
  }
1880
2402
  try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
1881
2403
  await deleteMergedBranch();
1882
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
1883
- if (closing.tracked.length) {
2404
+ const partial = closing.reason === "partial";
2405
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", partial ? "not planned" : "completed"]).catch(() => {});
2406
+ if (partial) {
2407
+ try { await post("FORGE:TRACKED", `## Closed as partial\nThe investigation's stated question is not answered in this change (PR #${pr}); carried forward in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
2408
+ } else if (closing.tracked.length) {
1884
2409
  try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
1885
2410
  }
1886
2411
  if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
@@ -1893,7 +2418,8 @@ export async function runCore(o) {
1893
2418
  }
1894
2419
  await label("merged");
1895
2420
  // forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
1896
- return await finish("merged", `PR #${pr}`, { pr, structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
2421
+ await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
2422
+ return await finish("merged", `PR #${pr}`, { pr, ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
1897
2423
  } catch (e) {
1898
2424
  if (e?.deferUsageLimit) {
1899
2425
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");