forgedock 1.27.0 → 1.28.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
package/bin/lean/lean.mjs CHANGED
@@ -5,36 +5,55 @@
5
5
  * ForgeDock registers everything on GitHub (labels, one register comment, PR, merge). No investigation, synthesis,
6
6
  * severity taxonomy, risk tiers or local CI gates. All effects are injected through `io` so it is unit-testable.
7
7
  */
8
- import { join, dirname, basename } from "node:path";
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
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
14
14
  import { createHash } from "node:crypto";
15
- import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking, reviewSkeleton, unnamedEdges, structureNote } from "./conformance.mjs";
16
- import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
+ import { TRACKED, isBugIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
+ import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
+ import { namedPaths } from "./prefilter.mjs";
17
18
  import { resolveLane, isDefaultLane } from "./lane.mjs";
18
19
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
20
+ import { repoKey } from "../runner.mjs";
21
+ import { prepareToolchain } from "./toolchain.mjs";
19
22
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
20
23
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
21
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, ciFailurePrompt, ciUnavailablePrompt, syncChecks } from "./prompts.mjs";
22
- import { parseVerify, runCiVerify, VERIFY_MODES } from "./verify.mjs";
24
+ import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
25
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
26
+ import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
27
+ import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES } from "./verify.mjs";
28
+ import { laneBackendId } from "./limits.mjs";
29
+ import { SPLIT_LABEL } from "./refactor.mjs";
30
+ import { writeHeavyShims, sumWaits } from "./heavy.mjs";
31
+ import { priorityFromLabels } from "./sched.mjs";
23
32
 
24
33
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
25
34
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
26
35
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
27
- /** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
36
+ /**
37
+ * In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression
38
+ * (precheck form: SHADOW_PRE_BLOCKS). A review without usable rows never merges either.
39
+ */
28
40
  const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
29
41
  /**
30
- * Precheck failures that block in shadow mode too (#4160): the builder's own validate command failed, or it reported a
31
- * contracted requirement as not done. These are the precheck's form of "a requirement not done". Missing validate results
32
- * (often CI-only steps), scope creep and amendment problems stay recorded-only in shadow.
42
+ * Precheck failures that block in shadow mode too (#4160): every precheck or amendment failure coded
43
+ * conformance.unimplemented (a validate command that failed or has no recorded result, a deliver requirement reported as
44
+ * not done, an invalid amendment) or conformance.regression, the same codes a review blocks on, and unexempted growth of an
45
+ * over-threshold file (maintenance.hotspot_growth, forge#4195). Scope creep stays recorded-only.
33
46
  */
34
- const SHADOW_PRE_BLOCKS = ["validate_failed", "requirement_unimplemented"];
47
+ const SHADOW_PRE_BLOCKS = [...SHADOW_BLOCKS, OUTCOME_CODES.MAINTENANCE_HOTSPOT_GROWTH];
48
+ /** 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. */
49
+ export const REVIEW_INCOMPLETE_MODES = Object.freeze(["needs-human", "degraded"]);
50
+ export const REVIEW_DEGRADED_LABEL = "review-degraded";
51
+ const UNUSABLE_REVIEW_CODES = { "review.unparseable": OUTCOME_CODES.REVIEW_NO_VERDICT, "review.incomplete": OUTCOME_CODES.REVIEW_INCOMPLETE, "review.inconsistent": OUTCOME_CODES.REVIEW_INCONSISTENT };
35
52
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
36
53
  const num = (v) => (Number.isFinite(v) ? v : null);
37
54
  export const MAX_SUBISSUES = 8;
55
+ /** #4266: required checks with no rollup entry this long into a CI wait on a MERGEABLE PR log CI_NOT_STARTED (once per wait). */
56
+ export const CI_NOT_STARTED_GRACE_MS = 10 * 60 * 1000;
38
57
  /** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
39
58
  export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
40
59
 
@@ -86,15 +105,27 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
86
105
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
87
106
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
88
107
  * @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
108
+ * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
109
+ * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
89
110
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
111
+ * @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
112
+ * @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
113
+ * @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
114
+ * @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
115
+ * @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
116
+ * @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
117
+ * @param {number} [o.sparseMaxKb] forge.yaml env.sparse_max_kb: tracked blobs >= this many KB stay out of the lane checkout (default 1024; 0 = off, #4221)
118
+ * @param {object} [o.placement] {where: local|remote, reason, mode}: the dispatcher's lane placement (#4256), kept in FORGE:STATE
90
119
  */
91
120
  export async function runCore(o) {
92
121
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
93
- ciMaxMinutes = 360, conformance = "shadow", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
122
+ toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", 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;
94
123
  const now = io.now ?? (() => Date.now());
95
124
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
96
125
  if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
97
126
  const remoteVerify = vcfg.mode !== "local";
127
+ const triggerMode = TRIGGER_MODES.includes(o.reviewTrigger?.mode) ? o.reviewTrigger.mode : "shadow";
128
+ const riskPaths = Array.isArray(o.reviewTrigger?.riskPaths) ? o.reviewTrigger.riskPaths : [];
98
129
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
99
130
  let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
100
131
  const branch = `feat/issue-${issue}-lean`;
@@ -152,9 +183,28 @@ export async function runCore(o) {
152
183
 
153
184
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
154
185
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
186
+ let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
155
187
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
156
188
  const runId = `${issue}-${t0}`;
157
189
  let runTmp = null;
190
+ let sparse = null; // #4221: {excluded, maxKb} when the lane worktree is a sparse checkout; null leaves prompts unchanged
191
+ 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
192
+ // #4169: heavy steps take a host semaphore slot (io.heavy); LLM phases are not gated. `heavyEnv` puts the heavy shims on
193
+ // the runner PATH; `waits` ({phase: ms}) and `heavyQueueMax` ride on the lane result for the batch report.
194
+ const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
195
+ let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
196
+ const waits = {};
197
+ const addWait = (phase, ms, queued = 0) => {
198
+ if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
199
+ if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
200
+ };
201
+ const lanePriority = () => priorityFromLabels(iss?.labels);
202
+ /** A host heavy-step slot for an engine-side step, or null (no io.heavy, disabled, or the registry failed: ungated). */
203
+ async function heavyAcquire(phase) {
204
+ if (!heavyOn) return null;
205
+ try { const g = await io.heavy.acquire({ issue, phase, priority: lanePriority() }, heavyCfg); addWait(phase, g?.waitMs, g?.queued); return g; } catch { return null; }
206
+ }
207
+ async function heavyStep(phase, fn) { const g = await heavyAcquire(phase); try { return await fn(); } finally { try { g?.release?.(); } catch { /* fail-open */ } } }
158
208
  // ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
159
209
  let ledger = null, deleteLocalBranch = false;
160
210
  const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
@@ -236,10 +286,16 @@ export async function runCore(o) {
236
286
  return finishing;
237
287
  }
238
288
  async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
289
+ if (heavyWaitsFile) { // #4169: builder-run heavy waits, read before teardown removes the run TMPDIR
290
+ try { heavyQueueMax = Math.max(heavyQueueMax, sumWaits(await io.fs.read(heavyWaitsFile), waits).queueMax); } catch { /* no heavy step ran */ }
291
+ }
239
292
  await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
240
293
  const code = codeOverride ?? codeForTerminalReason(reason);
241
- ev({ event: "RUN_TERMINAL", reason, code });
242
- await emit({ event: "RUN_TERMINAL", reason, code });
294
+ // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
295
+ const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
296
+ conformance_mode: conformance === "enforce" ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}) };
297
+ ev(term);
298
+ await emit(term);
243
299
  if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
244
300
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
245
301
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
@@ -251,7 +307,8 @@ export async function runCore(o) {
251
307
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
252
308
  }
253
309
  await releaseLease(reason);
254
- done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, ...extra };
310
+ done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0,
311
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
255
312
  return done;
256
313
  }
257
314
  async function needsHuman(why, code = OUTCOME_CODES.ENGINE_ERROR) {
@@ -324,13 +381,25 @@ export async function runCore(o) {
324
381
  }
325
382
  }
326
383
 
327
- /** Runner call with the per-run TMPDIR; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
384
+ /** Verification worker cap for this call (#4182): an operator-set positive int wins, else cores / live lanes; null when unknown. */
385
+ function verifyParallelism() {
386
+ const set = String(process.env.FORGE_HOST_PARALLELISM ?? "").trim();
387
+ if (/^\d+$/.test(set) && Number(set) > 0) return set;
388
+ if (!io.host?.cores) return null;
389
+ try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
390
+ }
391
+ /** Runner call with the per-run TMPDIR and FORGE_HOST_PARALLELISM; a usage limit pauses and resumes the same step, or defers past the cap (#4101). */
328
392
  async function callRunner(args) {
329
393
  let pauses = 0;
330
394
  for (;;) {
331
395
  // #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
332
396
  const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
333
- try { return await io.runner({ ...args, env: { FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}) } }); }
397
+ const par = verifyParallelism(); // #4182
398
+ let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
399
+ try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
400
+ // #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
401
+ 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()) } : {};
402
+ try { return await io.runner({ ...args, env: { ...deps, FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
334
403
  catch (e) {
335
404
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
336
405
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -370,7 +439,10 @@ export async function runCore(o) {
370
439
  const SALVAGE_NOTE = "\n\nAn earlier run on this issue stopped with uncommitted work, saved at `REF` (not on the branch). Inspect it with `git diff HEAD REF --stat` and reuse what fits the contract (`git checkout REF -- <path>`) instead of rewriting it.";
371
440
  const RERUN_NOTE = `\n\nThis is a rerun: the branch already holds commits from an earlier run. Read \`git log\` and \`git diff origin/BASE...HEAD\`, verify that work against the contract, complete what is missing, ${vcfg.mode === "ci" ? "re-run the light checks (the engine verifies on CI)" : "re-run every validate command"} and write the result file. Do not start over.`;
372
441
 
373
- /** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
442
+ /**
443
+ * Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume
444
+ * when under. Since #4169 the ceiling is the mem/tmp floors (load average only when configured); paused time is a wait.
445
+ */
374
446
  function startGovernor(control) {
375
447
  if (!io.host || o.governor === false || o.admission?.enabled === false) return { stop() {} };
376
448
  const pollMs = o.governorPollMs ?? 15000, maxPauseMs = o.maxPauseMs ?? 600000;
@@ -384,16 +456,18 @@ export async function runCore(o) {
384
456
  try { ok = (await checkAdmission({ cfg: o.admission, host: io.host })).ok; } catch { ok = true; }
385
457
  const nowMs = Date.now();
386
458
  if (!ok && !paused && !forced && control.pause) { if (control.pause()) { paused = true; pausedAt = nowMs; ev({ event: "LANE_PAUSED", reason: "host over ceiling" }); onProgress({ event: "phase_paused", phase: "builder", reason: "host-busy", detail: "host over the admission ceiling; lane paused" }); } }
387
- else if (ok && paused) { control.resume?.(); paused = false; forced = false; ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
459
+ else if (ok && paused) { control.resume?.(); paused = false; forced = false; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "host recovered" }); }
388
460
  else if (ok) forced = false;
389
- else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
461
+ else if (paused && nowMs - pausedAt >= maxPauseMs) { control.resume?.(); paused = false; forced = true; addWait(stage, nowMs - pausedAt); ev({ event: "LANE_RESUMED", reason: "max pause reached" }); }
390
462
  }
391
463
  })();
392
- return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
464
+ return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; addWait(stage, Date.now() - pausedAt); } } };
393
465
  }
394
466
 
395
467
  /** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
396
- const implementRestart = () => builderImplementPrompt(vcfg.mode) + CONTINUE_NOTE.replace("BASE", lane);
468
+ const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse });
469
+ let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
470
+ const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
397
471
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
398
472
  let timeoutRetries = 0, p = prompt;
399
473
  for (;;) {
@@ -534,6 +608,7 @@ export async function runCore(o) {
534
608
  if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
535
609
  lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
536
610
  if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
611
+ if (o.placement?.where) T.decisions.push(`Placement: ${o.placement.where}${o.placement.reason ? ` (${o.placement.reason})` : ""}`);
537
612
  }
538
613
  /** Last guard before a PR is created or merged: the default branch is never a lane. */
539
614
  const laneGuard = (target, what) => {
@@ -558,7 +633,8 @@ export async function runCore(o) {
558
633
  } else if (st?.lease && st.lease.until > now() && st.lease.by !== agentId) {
559
634
  return { terminalReason: "deferred", code: "lease-held", detail: `issue ${issue} leased by ${st.lease.by} until ${new Date(st.lease.until).toISOString()}`, branch, worktree: wt };
560
635
  }
561
- leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false };
636
+ leaseState = { ...(st ?? { v: 1, run: `core_${issue}`, issue, lane, committed: [], phase: null, branch: null, pr: null, terminalReason: null }), terminal: false,
637
+ ...(o.placement ? { placement: o.placement } : {}) }; // #4256: the latest placement replaces an earlier run's
562
638
  try { await writeLease(leaseObj()); } catch { /* best effort */ }
563
639
  leaseTimer = setInterval(() => { writeLease(leaseObj()).catch(() => {}); }, o.leaseRenewMs ?? 120000);
564
640
  leaseTimer.unref?.();
@@ -577,9 +653,8 @@ export async function runCore(o) {
577
653
  if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
578
654
  }
579
655
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
580
- let iss;
581
656
  try {
582
- iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
657
+ iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]));
583
658
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
584
659
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
585
660
 
@@ -621,10 +696,12 @@ export async function runCore(o) {
621
696
  };
622
697
  await leftoverDir();
623
698
  const LOCK_RE = /could not lock|cannot lock ref|\.lock'?: File exists|Unable to create .*\.lock|index\.lock|packed-refs\.lock/i;
624
- const addWorktree = async (args) => {
699
+ // #4221: tracked blobs >= sparseMaxKb stay out of the checkout (lockfiles/manifests always in); falls back to a full checkout.
700
+ const sparseMaxKb = Number.isFinite(o.sparseMaxKb) && o.sparseMaxKb >= 0 ? o.sparseMaxKb : DEFAULT_SPARSE_MAX_KB;
701
+ const addWorktree = async (args, ref) => {
625
702
  let cleaned = false;
626
703
  for (let i = 0; ; i++) {
627
- try { return await io.git(args, { cwd }); } catch (e) {
704
+ try { return await addLaneWorktree(io.git, io.fs?.write, { cwd, wt, addArgs: args, ref, maxBlobBytes: sparseMaxKb * 1024 }); } catch (e) {
628
705
  const msg = String(e?.stderr ?? e?.message ?? e);
629
706
  if (!cleaned && /already exists/i.test(msg) && (await leftoverDir())) { cleaned = true; continue; }
630
707
  if (i >= 4 || !LOCK_RE.test(msg)) throw e;
@@ -645,19 +722,105 @@ export async function runCore(o) {
645
722
  }
646
723
  } catch { /* no local branch */ }
647
724
  ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
725
+ let wtInfo = null;
648
726
  if (keepLocal) {
649
- await addWorktree(["worktree", "add", wt, branch]);
727
+ wtInfo = await addWorktree(["worktree", "add", wt, branch], `refs/heads/${branch}`);
650
728
  resumed = resumed ?? { pr: null, local: true };
651
729
  ev({ event: "LOCAL_BRANCH_REUSED", branch });
652
730
  } else if (resumed) {
653
- await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`]);
731
+ wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, `origin/${branch}`], `origin/${branch}`);
654
732
  } else {
655
- await addWorktree(["worktree", "add", "-B", branch, wt, base]);
733
+ wtInfo = await addWorktree(["worktree", "add", "-B", branch, wt, base], base);
656
734
  }
657
735
  wtCreated = true;
736
+ if (wtInfo?.sparse) {
737
+ sparse = { excluded: wtInfo.excluded, maxKb: sparseMaxKb };
738
+ ev({ event: "WORKTREE_SPARSE", excluded: wtInfo.excluded, excluded_bytes: wtInfo.bytes, max_kb: sparseMaxKb });
739
+ T.decisions.push(`Sparse worktree: ${wtInfo.excluded} tracked file(s) >= ${sparseMaxKb} KB (${Math.round(wtInfo.bytes / 1048576)} MB) left out of the checkout`);
740
+ } else if (wtInfo?.fallback) {
741
+ ev({ event: "WORKTREE_SPARSE_FALLBACK", reason: wtInfo.fallback });
742
+ T.anomalies.push(`Sparse checkout failed, full checkout used: ${wtInfo.fallback}`);
743
+ }
658
744
  const excl = String(await git(["rev-parse", "--git-path", "info/exclude"])).trim();
659
745
  await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
660
- const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
746
+ // #4220: link node_modules/.venv from the shared cache keyed by lock hash (or install once and publish). Never fails the run.
747
+ if (io.deps?.prepare) {
748
+ let res;
749
+ 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) }]; }
750
+ for (const r of Array.isArray(res) ? res : []) {
751
+ ev({ event: "DEPS_PREPARED", status: r?.status ?? null, eco: r?.eco ?? null, key: r?.key ?? null, ms: r?.ms ?? null, reason: r?.reason ?? null });
752
+ 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` : ""}`);
753
+ }
754
+ }
755
+ // ---- 2b. toolchain pins + env.setup (#4163): before the builder; zero LLM. No pins and no setup changes nothing ----
756
+ try {
757
+ const tc = await (io.prepareToolchain ?? prepareToolchain)({ dir: wt, override: toolchainOverride, read: io.fs.read,
758
+ binDir: join(runTmp ?? join(wt, LEAN_DIR), "toolchain", "bin") });
759
+ if (tc?.summary) {
760
+ toolchain = { env: tc.env ?? null, summary: tc.summary };
761
+ ev({ event: "TOOLCHAIN_PINNED", pins: tc.summary, shims: tc.shims });
762
+ T.decisions.push(tc.env ? `Toolchain pinned on the runner PATH: ${tc.summary}` : `Toolchain pins detected (no shim): ${tc.summary}`);
763
+ }
764
+ if (tc?.warning) { T.anomalies.push(tc.warning); ev({ event: "TOOLCHAIN_WARNING", warning: tc.warning }); }
765
+ for (const n of tc?.notes ?? []) T.anomalies.push(n);
766
+ } catch (e) { T.anomalies.push(`Toolchain detection failed (host toolchain used): ${String(e?.message ?? e).slice(0, 200)}`); }
767
+ // ---- 2c. heavy-step shims (#4169): after the toolchain, so a heavy shim's real binary is the pinned toolchain shim ----
768
+ if (heavyOn) {
769
+ try {
770
+ const root = runTmp ?? join(wt, LEAN_DIR), binDir = join(root, "heavy", "bin"), path = toolchain?.env?.PATH ?? process.env.PATH ?? "";
771
+ const names = await (io.writeHeavyShims ?? writeHeavyShims)(binDir, { path });
772
+ if (names?.length) {
773
+ heavyWaitsFile = join(root, "heavy-waits.jsonl");
774
+ const pinned = /^\d+$/.test(String(process.env.FORGE_HOST_PARALLELISM ?? "").trim()) && Number(process.env.FORGE_HOST_PARALLELISM) > 0;
775
+ heavyEnv = { PATH: path ? `${binDir}${delimiter}${path}` : binDir, FORGE_HEAVY_WAITS: heavyWaitsFile, ...(pinned ? { FORGE_HEAVY_PARALLELISM_PINNED: "1" } : {}),
776
+ ...Object.fromEntries([["FORGE_HEAVY_MAX", heavyCfg.max], ["FORGE_HEAVY_STEP_MEM_MB", heavyCfg.step_mem_mb], ["FORGE_HEAVY_RESERVE_MB", heavyCfg.reserve_mb]].filter(([, v]) => Number(v) > 0).map(([k, v]) => [k, String(v)])) };
777
+ ev({ event: "HEAVY_SHIMS", names });
778
+ }
779
+ } catch (e) { T.anomalies.push(`Heavy-step shims not written (builder steps ungated): ${String(e?.message ?? e).slice(0, 200)}`); }
780
+ }
781
+ if (typeof envSetup === "string" && envSetup.trim()) {
782
+ let r;
783
+ if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
784
+ else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: { ...(runTmp ? { TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}) } })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
785
+ ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
786
+ if (r?.exit !== 0) {
787
+ const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
788
+ T.anomalies.push(`Environment error: forge.yaml env.setup exited ${r?.exit ?? "?"}; the builder did not run`);
789
+ return await needsHuman(`Environment error (not a build failure): forge.yaml \`env.setup\` (\`${envSetup.slice(0, 200)}\`) exited ${r?.exit ?? "?"} in the fresh worktree, so the builder was not started. Fix the setup command or the environment, then rerun.${tail ? `\n\n\`\`\`\n${tail}\n\`\`\`` : ""}`, OUTCOME_CODES.ENV_SETUP_FAILED);
790
+ }
791
+ T.decisions.push("forge.yaml env.setup ran once in the fresh worktree");
792
+ }
793
+ const names = (out) => String(out).split("\n").map((s) => s.trim()).filter(Boolean);
794
+ // #4160: files the branch touched (three-dot) whose content still differs from the base tip (two-dot); a file merged
795
+ // in from the base by a base-sync equals the base and drops out of the scope check.
796
+ const changedFiles = async () => {
797
+ const touched = names(await git(["diff", "--name-only", `${base}...HEAD`]));
798
+ if (!touched.length) return touched;
799
+ const differs = new Set(names(await git(["diff", "--name-only", base, "HEAD"])));
800
+ return touched.filter((f) => differs.has(f));
801
+ };
802
+ /** forge#4195: scripts/hotspots.sh facts in the worktree, or null (no io.hotspots, script failure): never fails a run. */
803
+ const hotspotsOf = async (args) => {
804
+ if (!io.hotspots) return null;
805
+ const h = await io.hotspots({ cwd: wt, ...args }).catch(() => null);
806
+ return h && typeof h === "object" && Array.isArray(h.files) ? h : null;
807
+ };
808
+ /** #4271: a bug lane with lean.repro_test on; refactor:split lanes never set it (no plan call, no repro_test). */
809
+ const reproOn = () => reproTest !== "off" && isBugIssue(iss?.labels);
810
+ let reproNoted = false;
811
+ /** #4271: the frozen repro_test's red-green words for precheck, or null (gate off, "none", no io.redGreen). */
812
+ const reproOf = async (c) => {
813
+ const t = c?.repro_test;
814
+ if (!reproOn() || !io.redGreen || !t || typeof t !== "object") {
815
+ if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
816
+ return null;
817
+ }
818
+ const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
819
+ const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
820
+ ev({ event: "REPRO_RED_GREEN", ...words });
821
+ T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
822
+ return words;
823
+ };
661
824
 
662
825
  // ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
663
826
  let result = null, attemptN = 0, reviewN = 0;
@@ -675,7 +838,7 @@ export async function runCore(o) {
675
838
  /** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
676
839
  async function refreshIssue(stage) {
677
840
  let next;
678
- try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]))); } catch { return; }
841
+ try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels"]))); } catch { return; }
679
842
  if (!next || typeof next !== "object") return;
680
843
  const tc = trustedOf(next), text = textOf(next, tc);
681
844
  const old = contractHash(issueText), now = contractHash(text);
@@ -726,68 +889,166 @@ export async function runCore(o) {
726
889
  return found;
727
890
  }
728
891
 
729
- let frozen = null, hash = null, planned = false;
730
- if (resumed) {
731
- const fc = await readFrozenContract();
732
- if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
892
+ const isSplit = (iss.labels ?? []).some((l) => (l?.name ?? l) === SPLIT_LABEL);
893
+ /**
894
+ * #4211: the refactor:split step. One tool-less grouping call over the symbol index (none for a chained plan), the
895
+ * codemod move and its deterministic proof (bin/lean/refactor.mjs). A rerun on a branch that already holds the move
896
+ * re-verifies it instead. Returns {done} (needs-human, worktree left at its start commit) or the split result with a
897
+ * synthetic location-only contract for the renderers.
898
+ */
899
+ async function splitStep() {
900
+ await label("building");
901
+ const mod = o.refactorModule ?? (await import("./refactor.mjs"));
902
+ const body = String(iss.body ?? "");
903
+ const precheckList = Array.isArray(o.precheck?.commands) ? o.precheck.commands : [];
904
+ let r;
905
+ if (hasCommits) {
906
+ const target = mod.parseSplitTarget(body) ?? mod.parseSplitPlan(body)?.target, testCmd = mod.parseTestCmd(body, precheckList);
907
+ r = target && testCmd ? await heavyStep("split", () => mod.reverifySplit({ wt, base, target, testCmd, runTmp, git })) : { ok: false, code: OUTCOME_CODES.SPLIT_PLAN_INVALID, reason: "rerun of a split branch without a target or test command in the issue" };
908
+ if (r.ok) {
909
+ r = { ...r, target, testCmd, package: null, modules: {}, rest: null, grouped: false };
910
+ T.anomalies.push("Rerun of a split branch: the existing move was re-verified; a chained remainder is not recomputed");
911
+ }
912
+ } else {
913
+ let slot = await heavyAcquire("split"); // the grouper's LLM call gives the slot back while it runs (#4169)
914
+ try {
915
+ r = await mod.runSplitStep({ wt, base, issue, issueBody: body, precheckCommands: precheckList, runTmp, git, ...(o.splitMaxLines ? { maxLines: o.splitMaxLines } : {}),
916
+ callGrouper: async (prompt, { cwd } = {}) => {
917
+ try { slot?.release?.(); } catch { /* fail-open */ }
918
+ slot = null;
919
+ try {
920
+ await renew();
921
+ const rr = await callRunner({ role: "grouper", issue, cwd: cwd ?? runTmp ?? wt, model: reviewerModel, resume: null, system: GROUPING_SYSTEM, prompt, noTools: true });
922
+ await renew();
923
+ await attempt("group", 1, rr);
924
+ return rr?.text ?? "";
925
+ } finally { slot = await heavyAcquire("split"); }
926
+ } });
927
+ } finally { try { slot?.release?.(); } catch { /* fail-open */ } }
928
+ }
929
+ ev({ event: "SPLIT", ok: r.ok, code: r.code ?? null, grouped: Boolean(r.grouped), modules: Object.keys(r.modules ?? {}).length, rest: Object.keys(r.rest ?? {}).length });
930
+ if (!r.ok) {
931
+ T.validate = ["SPLIT REFUSED", r.code]; T.plan = "skipped"; T.implement = "none (worktree reset)"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
932
+ return { done: await needsHuman(`refactor:split refused (${r.code}); nothing was pushed:\n${r.reason}`, r.code) };
933
+ }
934
+ const moved = await changedFiles();
935
+ const contract = { requirements: [], deliverables: moved.map((path, i) => ({ id: `D${i + 1}`, path, change: "location-only: moved verbatim or re-export shim", requirements: [] })), preserve: [], validate: [] };
936
+ const mods = Object.entries(r.modules ?? {});
937
+ try {
938
+ await post("FORGE:SPLIT", `## refactor:split\n**Target:** \`${r.target}\`\n**Package:** \`${r.package ?? "(rerun)"}\`\n**Modules:** ${mods.map(([m, l]) => `\`${m}\` (${l.length})`).join(", ") || "n/a"}\n**Plan:** ${r.grouped ? "one grouping call over the symbol index" : "no LLM call"}\n**Remaining:** ${Object.keys(r.rest ?? {}).length} module(s)\n\n\`\`\`json\n${JSON.stringify(r.verify ?? {})}\n\`\`\``);
939
+ } catch { /* best effort */ }
940
+ T.validate = ["CONFIRMED", `refactor:split \`${r.target}\``];
941
+ T.decisions.push(`refactor:split: ${r.grouped ? "one grouping call" : "no LLM call"}, ${mods.length} module(s) into ${r.package ?? "(rerun)"}, refactor-verify ok${r.rest ? `, ${Object.keys(r.rest).length} module(s) chained` : ""}`);
942
+ T.implement = [`${moved.length} file(s)`, branch];
943
+ sessionId = null;
944
+ return { ...r, contract };
733
945
  }
734
- await label("building");
735
- if (!frozen) {
736
- await builder(++attemptN, builderPlanPrompt(issue) + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, () => builderPlanPrompt(issue));
737
- planned = true;
738
- result = await readResult();
739
- const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
740
- const closes = ["invalid", "already-fixed"].includes(result?.status);
741
- if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
742
- T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
743
- if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
744
- if (closes) {
745
- T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
746
- T.decisions.push(`Builder verdict: ${result.status}`);
747
- await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
748
- await label("invalid");
749
- return await finish("invalid", result.summary ?? result.status);
946
+ let frozen = null, hash = null, planned = false, files = [], split = null;
947
+ if (isSplit) {
948
+ // ---- 3'. refactor:split (#4211): deterministic move + proof replaces the builder; no builder session exists ----
949
+ split = await splitStep();
950
+ if (split.done) return split.done;
951
+ frozen = split.contract; hash = contractHash(contractJson(frozen));
952
+ files = await changedFiles();
953
+ } else {
954
+ if (resumed) {
955
+ const fc = await readFrozenContract();
956
+ if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
750
957
  }
751
- const blocked = await endIfBlocked();
752
- if (blocked) return blocked;
753
- let contract = await readContract();
754
- await refreshIssue("contract validation");
755
- let vc = validateContract(contract, issueText);
756
- if (!vc.ok && sessionId) {
757
- T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
758
- await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
759
- contract = await readContract();
958
+ await label("building");
959
+ hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
960
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn());
961
+ if (!frozen) {
962
+ await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, planPrompt);
963
+ planned = true;
964
+ result = await readResult();
965
+ const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
966
+ const closes = ["invalid", "already-fixed"].includes(result?.status);
967
+ if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
968
+ T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
969
+ if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
970
+ if (closes) {
971
+ T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
972
+ T.decisions.push(`Builder verdict: ${result.status}`);
973
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
974
+ await label("invalid");
975
+ return await finish("invalid", result.summary ?? result.status);
976
+ }
977
+ const blocked = await endIfBlocked();
978
+ if (blocked) return blocked;
979
+ let contract = await readContract();
760
980
  await refreshIssue("contract validation");
761
- vc = validateContract(contract, issueText);
981
+ let vc = validateContract(contract, issueText, { repro: reproOn() });
982
+ if (!vc.ok && sessionId) {
983
+ T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
984
+ await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, planPrompt);
985
+ contract = await readContract();
986
+ await refreshIssue("contract validation");
987
+ vc = validateContract(contract, issueText, { repro: reproOn() });
988
+ }
989
+ await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
990
+ if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID);
991
+ frozen = contract;
992
+ hash = contractHash(contractJson(frozen));
993
+ let body = renderContract(frozen, hash);
994
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
995
+ await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
996
+ const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
997
+ ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
998
+ T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
999
+ } else {
1000
+ T.validate = "resumed (existing branch)";
762
1001
  }
763
- await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
764
- if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID);
765
- frozen = contract;
766
- hash = contractHash(contractJson(frozen));
767
- let body = renderContract(frozen, hash);
768
- if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
769
- await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
770
- const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
771
- ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
772
- T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
773
- } else {
774
- T.validate = "resumed (existing branch)";
1002
+ T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1003
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1004
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
1005
+ liveKeys = ["implementation"];
1006
+ stage = "implement";
1007
+ await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
1008
+ stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
1009
+ result = await readResult();
1010
+ {
1011
+ const blocked = await endIfBlocked();
1012
+ if (blocked) return blocked;
1013
+ }
1014
+ files = await changedFiles();
1015
+ if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
1016
+ await flushStages(result, ["implementation"]);
1017
+ T.implement = [`${files.length} file(s)`, branch];
1018
+ T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
775
1019
  }
776
- T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
777
- if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
778
- const implementPrompt = builderImplementPrompt(vcfg.mode) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
779
- liveKeys = ["implementation"];
780
- await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
781
- result = await readResult();
782
- {
783
- const blocked = await endIfBlocked();
784
- if (blocked) return blocked;
1020
+
1021
+ // ---- 3a. declared check commands (#4240): the repo's own cheap gates (forge.yaml learned.test_commands), run locally in
1022
+ // the worktree before review and PR; no Actions minutes. A failure goes back to the builder in-session (#4149), up to
1023
+ // verify.max_rounds; a round with no commit or exhausted rounds is an anomaly, not a stop: CI stays the gate.
1024
+ let checkCommandsFailing = false; // #4273: a failed gate triggers the reviewer
1025
+ const precheckCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1026
+ if (precheckCommands.length && io.runCommand) {
1027
+ for (let round = 0; ; round++) {
1028
+ await renew();
1029
+ const results = [];
1030
+ for (const command of precheckCommands) {
1031
+ let r;
1032
+ try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1033
+ results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1034
+ }
1035
+ ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })) });
1036
+ const failed = results.filter((r) => r.rc !== 0);
1037
+ if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
1038
+ const names = failed.map((f) => `\`${f.command}\` (exit ${f.rc})`).join(", ");
1039
+ 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);
1040
+ checkCommandsFailing = true;
1041
+ if (round >= vcfg.maxRounds || !sessionId) { T.anomalies.push(`Declared check command(s) still failing before the PR: ${names}`); break; }
1042
+ T.decisions.push(`Declared check command(s) failed: ${names}; builder resumed (round ${round + 1}/${vcfg.maxRounds})`);
1043
+ const before = String(await git(["rev-parse", "HEAD"])).trim();
1044
+ const capped = capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log }));
1045
+ await builder(++attemptN, precheckFailurePrompt(capped, vcfg.mode), sessionId);
1046
+ await upsertStage("implementation");
1047
+ result = (await readResult()) ?? result;
1048
+ 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; }
1049
+ files = await changedFiles();
1050
+ }
785
1051
  }
786
- let files = await changedFiles();
787
- if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
788
- await flushStages(result, ["implementation"]);
789
- T.implement = [`${files.length} file(s)`, branch];
790
- T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
791
1052
 
792
1053
  // ---- 3b. structure delta (forge#4159): deterministic, before the conformance review; no reviewer run of its own ----
793
1054
  // A package.json edge between workspace packages the contract does not name blocks (one builder resume) in either
@@ -802,8 +1063,7 @@ export async function runCore(o) {
802
1063
  const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
803
1064
  const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
804
1065
  import_files_added: sum("files_added"), import_files_removed: sum("files_removed"), boundary_changed: Boolean(structure.boundary_config_changed?.changed), boundary_loosened: Boolean(structure.boundary_config_changed?.loosens) };
805
- ev(rec);
806
- await emit({ ...rec, phase: "implement" });
1066
+ ev(rec); // run log only: the telemetry write is the merge-time record (forge#4165)
807
1067
  const undeclared = () => unnamedEdges(applyAmendments(frozen, result?.amendments, issueText).contract, structure);
808
1068
  let edges = undeclared();
809
1069
  if (edges.length && sessionId) {
@@ -818,6 +1078,22 @@ export async function runCore(o) {
818
1078
  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);
819
1079
  }
820
1080
  }
1081
+ // ---- 3c. the target repo's devdocs freshness rule, when it has one: deterministic, before any PR, so a lane does not
1082
+ // pay for a failed required check. Exit 1 resumes the builder once; still failing is needs-human docs.stale.
1083
+ if (io.docsFreshness) {
1084
+ let docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
1085
+ if (docs && !docs.ok && sessionId) {
1086
+ ev({ event: "DOCS_STALE", round: 1 });
1087
+ T.decisions.push("Docs freshness check failed; builder resumed once");
1088
+ await builder(++attemptN, docsPrompt(docs.output), sessionId);
1089
+ await upsertStage("implementation");
1090
+ result = (await readResult()) ?? result;
1091
+ docs = await io.docsFreshness({ cwd: wt, base }).catch(() => null);
1092
+ }
1093
+ if (docs && !docs.ok) {
1094
+ return await needsHuman(`The repository's docs freshness check still fails${sessionId ? " after one builder resume" : ""}; nothing merged:\n\n${String(docs.output).slice(0, 2000)}`, OUTCOME_CODES.DOCS_STALE);
1095
+ }
1096
+ }
821
1097
  const structureLine = structureNote(structure);
822
1098
 
823
1099
  // ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
@@ -851,6 +1127,7 @@ export async function runCore(o) {
851
1127
  return { done: await finish("ci-pending", `CI verification of ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
852
1128
  }
853
1129
  if (v.result === "unavailable") {
1130
+ if (isSplit) return { done: await needsHuman(`CI verification unavailable for the refactor:split move (${v.detail}); a split never falls back to builder validation; nothing merged.`) };
854
1131
  ciFellBack = true;
855
1132
  row("unavailable: local fallback");
856
1133
  T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
@@ -867,6 +1144,7 @@ export async function runCore(o) {
867
1144
  }
868
1145
  ciRounds++; fixes++;
869
1146
  T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
1147
+ ev({ event: "CI_FIX_ROUND", trigger, round: ciRounds, jobs: v.failures.map((f) => f.job) });
870
1148
  await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
871
1149
  await upsertStage("implementation");
872
1150
  result = (await readResult()) ?? result;
@@ -880,9 +1158,12 @@ export async function runCore(o) {
880
1158
  // enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
881
1159
  // but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression),
882
1160
  // plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
1161
+ // A review without usable rows ends needs-human (or, with engine.review_incomplete: degraded, merges flagged).
883
1162
  await label("in-review");
884
1163
  const shadow = conformance !== "enforce";
885
- let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
1164
+ let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false;
1165
+ const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
1166
+ 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).";
886
1167
  async function amend() {
887
1168
  const a = applyAmendments(frozen, result?.amendments, issueText);
888
1169
  const sig = JSON.stringify(a.applied);
@@ -900,21 +1181,24 @@ export async function runCore(o) {
900
1181
  const diff = String(await git(["diff", `${base}...HEAD`]));
901
1182
  const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
902
1183
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
903
- const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
904
- let d = null, retry = null;
905
- const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
1184
+ const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
1185
+ let d = null, first = null;
906
1186
  for (let t = 0; t < 2; t++) {
1187
+ // #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
1188
+ const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
907
1189
  await renew();
908
1190
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
909
- prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, skeleton, retry }) });
1191
+ prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
1192
+ : reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
910
1193
  await renew();
911
1194
  await attempt("review", ++reviewN, rr);
912
- review = parseReview(rr?.text);
1195
+ const parsed = parseReview(rr?.text);
1196
+ review = repair ? mergeReviewRows(first, parsed) : parsed;
1197
+ first = review;
913
1198
  d = decide(review, ctx);
1199
+ if (d.review) review = d.review;
914
1200
  ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
915
1201
  if (!d.state.startsWith("review.")) return d;
916
- // #4160: the second run is told exactly what was missing, instead of getting the same prompt again
917
- retry = d.state === "review.incomplete" ? { problems: d.failures.map((x) => x.detail), previous: review ? JSON.stringify(review) : String(rr?.text ?? "") } : null;
918
1202
  }
919
1203
  return d;
920
1204
  }
@@ -925,64 +1209,104 @@ export async function runCore(o) {
925
1209
  files = await changedFiles();
926
1210
  const amendProblems = await amend();
927
1211
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
928
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified });
1212
+ const hotspots = await hotspotsOf({ base, paths: files });
1213
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
929
1214
  const pre = [...pc.failures, ...amendProblems];
930
1215
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
931
1216
  if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
932
1217
  // shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
933
- const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.kind)) : [];
934
- if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard };
1218
+ const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
1219
+ if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
1220
+ // #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
1221
+ if (triggerMode !== "off") {
1222
+ let t = null;
1223
+ try { t = computeTrigger({ files, riskPaths, structure, hotspots, gates: { precheckFailures: pre, checkCommandsFailing, ciFellBack }, contract: effective }); } catch { /* fall back to reviewing */ }
1224
+ const skip = triggerMode === "enforce" && t?.would_skip === true;
1225
+ ev({ event: "REVIEW_TRIGGER", mode: triggerMode, would_skip: t ? t.would_skip : false, reasons: t ? t.reasons.map((r) => r.code) : ["trigger.error"], skipped: skip });
1226
+ if (skip) {
1227
+ triggerSkipped = true;
1228
+ return { state: "conforms", by: "trigger", failures: [], ...(shadow ? { would: "conforms", wouldFail: [], reviewState: null } : {}) };
1229
+ }
1230
+ triggerSkipped = false;
1231
+ }
935
1232
  const d = await reviewOnce();
936
1233
  if (!shadow) return { ...d, by: "review" };
937
1234
  // shadow: record what enforce would have done; block only on what the previous reviewer blocked on
938
1235
  const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
939
1236
  const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
1237
+ if (d.state.startsWith("review.")) return { ...d, by: "review", would, wouldFail, reviewState: d.state };
940
1238
  const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
941
- return { state: d.state === "review.unparseable" ? d.state : blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
1239
+ return { state: blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
942
1240
  }
943
- /** Shape the base-sync path expects: null when no usable review, else {fail, verdict, reason, failures}. */
1241
+ /** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
944
1242
  const doReview = async () => {
945
1243
  const d = await gate();
946
- if (d.state.startsWith("review.")) return null;
1244
+ if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
947
1245
  return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
948
1246
  };
949
- for (;;) {
1247
+ let degradedReq = null;
1248
+ if (isSplit) {
1249
+ // #4211: location-only change proved by refactor-verify; no conformance reviewer (the optional review is not spent)
950
1250
  if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
951
- const outcome = await gate();
952
- verdict = outcome;
953
- if (outcome.state === "conforms") break;
954
- const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
955
- if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
956
- if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
957
- if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
958
- if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
959
- resumes++;
960
- T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
961
- await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
962
- await upsertStage("implementation"); // the published note follows the code after every resume
963
- }
964
- // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
965
- const toFile = pendingTracking(effective);
966
- if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
967
- if (shadow) {
968
- const wf = verdict.wouldFail ?? [];
969
- ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
970
- await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
971
- T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
972
- await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
1251
+ T.review = "skipped (refactor-verify passed: location-only change)";
973
1252
  } else {
974
- T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
975
- await post("FORGE:REVIEW", renderReview(review, effective));
1253
+ for (;;) {
1254
+ if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
1255
+ const outcome = await gate();
1256
+ verdict = outcome;
1257
+ if (outcome.state === "conforms") break;
1258
+ const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1259
+ if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
1260
+ if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
1261
+ degraded = { state: outcome.state, missing: outcome.missing ?? [] };
1262
+ T.decisions.push(`Review degraded (${outcome.state} after the targeted re-ask${degraded.missing.length ? `: ${degraded.missing.join(", ")}` : ""}); merging with label ${REVIEW_DEGRADED_LABEL} and a tracked follow-up`);
1263
+ break;
1264
+ }
1265
+ if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
1266
+ if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
1267
+ if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
1268
+ resumes++;
1269
+ T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
1270
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
1271
+ await upsertStage("implementation"); // the published note follows the code after every resume
1272
+ }
1273
+ // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
1274
+ const toFile = pendingTracking(effective);
1275
+ if (toFile.length) await track(toFile, "accepted by the conformance review");
1276
+ degradedReq = degraded ? { id: "REVIEW", quote: `conformance review ${degraded.state}: re-run the review`, disposition: "defer", why: `${REVIEW_DEGRADED_LABEL}: rows still missing for ${degraded.missing.join(", ") || "the verdict"}` } : null;
1277
+ if (degradedReq) await track([degradedReq], "the conformance review stayed incomplete");
1278
+ if (triggerSkipped) {
1279
+ T.decisions.push("Conformance reviewer skipped by review.trigger enforce: no trigger reason (risk path, package edge, hotspot, failed or ambiguous gate)");
1280
+ if (shadow) {
1281
+ shadowWould = verdict.would;
1282
+ ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
1283
+ await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: [] });
1284
+ }
1285
+ T.review = [TRIGGER_SKIP, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
1286
+ await post("FORGE:REVIEW", triggerSkipNote);
1287
+ } else if (shadow) {
1288
+ const wf = verdict.wouldFail ?? [];
1289
+ shadowWould = verdict.would;
1290
+ ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
1291
+ await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
1292
+ T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
1293
+ 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) }));
1294
+ } else {
1295
+ T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
1296
+ await post("FORGE:REVIEW", renderReview(review, effective));
1297
+ }
976
1298
  }
977
1299
  ev({ event: "PHASE_COMMIT", phase: "review" });
978
- const closing = closePlan(shadow ? { requirements: [] } : effective);
1300
+ const closing = closePlan(effective);
1301
+ if (degradedReq?.tracked && !closing.tracked.includes(degradedReq.tracked)) closing.tracked.push(degradedReq.tracked);
979
1302
 
980
1303
  // ---- 5. push + PR ----
981
1304
  files = await changedFiles();
982
1305
  await gitPush(["push", "-u", "origin", branch]);
983
1306
  laneGuard(lane, "open a PR");
984
1307
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
985
- "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
1308
+ "--title", `${iss.title} (#${issue})`, "--body", isSplit ? (o.refactorModule ?? (await import("./refactor.mjs"))).renderSplitPrBody({ issue, ...split })
1309
+ : renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
986
1310
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
987
1311
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
988
1312
  ev({ event: "PR_OPENED", pr });
@@ -990,6 +1314,11 @@ export async function runCore(o) {
990
1314
  try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
991
1315
  catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
992
1316
  }
1317
+ if (degraded) {
1318
+ try { await ghw(["label", "create", REVIEW_DEGRADED_LABEL, "-R", repo, "--force", "--color", "FBCA04", "--description", "Merged with an incomplete conformance review. Managed by ForgeDock."]); } catch { /* exists, or no rights: the edit below says */ }
1319
+ try { await ghw(["pr", "edit", String(pr), "-R", repo, "--add-label", REVIEW_DEGRADED_LABEL]); }
1320
+ catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not label PR #${pr} ${REVIEW_DEGRADED_LABEL}; nothing merged, rerun to retry`; throw e; }
1321
+ }
993
1322
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
994
1323
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
995
1324
  let localHead = String(await git(["rev-parse", "HEAD"])).trim();
@@ -1025,6 +1354,13 @@ export async function runCore(o) {
1025
1354
  try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
1026
1355
  return String(j.mergeable ?? "").toUpperCase() === "MERGEABLE" && String(j.mergeStateStatus ?? "").toUpperCase() === "BLOCKED";
1027
1356
  };
1357
+ /** #4266: one mergeability read inside the CI wait: "conflict", "mergeable", or null (unknown / unreadable / other). */
1358
+ const waitMergeState = async () => {
1359
+ let j;
1360
+ try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return null; }
1361
+ const m = String(j.mergeable ?? "").toUpperCase(), st = String(j.mergeStateStatus ?? "").toUpperCase();
1362
+ return m === "CONFLICTING" || st === "DIRTY" ? "conflict" : m === "MERGEABLE" ? "mergeable" : null;
1363
+ };
1028
1364
  let blockStartedAt = null, blockPollIndex = 0;
1029
1365
  const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
1030
1366
  const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
@@ -1040,6 +1376,10 @@ export async function runCore(o) {
1040
1376
  const before = await gitOut(["rev-parse", "HEAD"]);
1041
1377
  let clean = true, files = [];
1042
1378
  try { await git(["merge", "--no-edit", base]); } catch { clean = false; files = await conflictFiles(); }
1379
+ if (isSplit && !clean) { // #4211: no builder resolves a split's conflicts; a fresh run re-splits on the new base
1380
+ await gitOut(["merge", "--abort"]);
1381
+ 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);
1382
+ }
1043
1383
  let how, tests = "not required (clean merge)", rereview = "not required (conflict-free base merge)";
1044
1384
  if (clean) {
1045
1385
  how = "clean merge of the base into the branch";
@@ -1059,8 +1399,13 @@ export async function runCore(o) {
1059
1399
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
1060
1400
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
1061
1401
  let ciFixes = 0;
1402
+ if (isSplit && after !== before) { // #4211: the merged head must still be a pure move against the new base
1403
+ const rv = await (o.refactorModule ?? (await import("./refactor.mjs"))).reverifySplit({ wt, base, target: split.target, testCmd: split.testCmd, runTmp, git });
1404
+ if (!rv.ok) return await needsHuman(`refactor:split no longer verifies after merging ${base}: ${rv.reason}`, rv.code);
1405
+ rereview = "refactor-verify re-run against the new base: ok";
1406
+ }
1062
1407
  if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
1063
- if (!clean || ciFixes) {
1408
+ if (!isSplit && (!clean || ciFixes)) {
1064
1409
  let rv = await doReview();
1065
1410
  if (rv?.fail) {
1066
1411
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
@@ -1069,7 +1414,7 @@ export async function runCore(o) {
1069
1414
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
1070
1415
  rv = await doReview();
1071
1416
  }
1072
- if (!rv) return await needsHuman("Reviewer returned no parseable verdict after base-sync.", OUTCOME_CODES.REVIEW_NO_VERDICT);
1417
+ if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
1073
1418
  if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1074
1419
  rereview = `${rv.verdict}: ${rv.reason || "no concrete failure"}`;
1075
1420
  }
@@ -1080,6 +1425,34 @@ export async function runCore(o) {
1080
1425
  return null;
1081
1426
  }
1082
1427
  const NR_TESTS = "not reported";
1428
+ /**
1429
+ * #4240: a required check failed at merge (any verify mode). One CI fix round in the builder session with the failed
1430
+ * logs, shared with ciVerify's verify.max_rounds budget; the fixed head is re-reviewed and pushed, then the wait restarts.
1431
+ * Returns null to wait again, or a terminal result (rounds exhausted, no session, no new commit, review failure).
1432
+ */
1433
+ async function mergeFixRound(checks) {
1434
+ const jobs = checks.map((c) => c.name);
1435
+ if (ciRounds >= vcfg.maxRounds || !sessionId) {
1436
+ return await needsHuman(`Required check(s) failed on PR #${pr}: ${jobs.join(", ")}${ciRounds ? ` (after ${ciRounds} CI fix round(s))` : ""}`, OUTCOME_CODES.CI_RED);
1437
+ }
1438
+ const failures = await collectFailedLogs(io, checks);
1439
+ ciRounds++;
1440
+ T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
1441
+ ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
1442
+ const before = await gitOut(["rev-parse", "HEAD"]);
1443
+ await builder(++attemptN, ciFailurePrompt(failures, vcfg.mode), sessionId);
1444
+ await upsertStage("implementation");
1445
+ result = (await readResult()) ?? result;
1446
+ if ((await gitOut(["rev-parse", "HEAD"])) === before) {
1447
+ return await needsHuman(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED);
1448
+ }
1449
+ const rv = await doReview(); // never merge an unreviewed head
1450
+ if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
1451
+ if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1452
+ await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
1453
+ localHead = await gitOut(["rev-parse", "HEAD"]);
1454
+ return null;
1455
+ }
1083
1456
  /** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
1084
1457
  async function deleteMergedBranch() {
1085
1458
  const skip = (why) => { ev({ event: "BRANCH_DELETE_SKIPPED", branch, why }); T.decisions.push(`Lane branch kept: ${why}`); };
@@ -1094,6 +1467,34 @@ export async function runCore(o) {
1094
1467
  catch (e) { ev({ event: "BRANCH_DELETE_FAILED", branch, where: "origin", error: String(e?.stderr ?? e?.message ?? e).slice(0, 300) }); }
1095
1468
  }
1096
1469
 
1470
+ /**
1471
+ * forge#4165: one STRUCTURE_DELTA telemetry record per merged run, from scripts/structure-delta.sh (the only place the
1472
+ * delta is computed) plus one local `git diff --numstat` for growth and the contract's decision provenance. Local git
1473
+ * only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
1474
+ */
1475
+ async function recordStructure(pr) {
1476
+ const d = (await structureOf()) ?? {};
1477
+ const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
1478
+ const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
1479
+ .map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);
1480
+ const pairs = Array.isArray(d.import_pairs) ? d.import_pairs : [], sum = (k) => pairs.reduce((n, x) => n + (Number(x?.[k]) || 0), 0);
1481
+ const edge = (e) => ({ from: e?.from, to: e?.to });
1482
+ const s = {
1483
+ deps_added: (d.package_edges_added ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
1484
+ deps_removed: (d.package_edges_removed ?? []).map((e) => ({ ...edge(e), kind: e?.kind, manifest: e?.manifest })),
1485
+ new_edges: pairs.filter((x) => x?.files_added > 0).map((x) => ({ ...edge(x), file: Array.isArray(x.files) ? x.files[0] ?? null : null, files: x.files_added })),
1486
+ removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
1487
+ top_growth,
1488
+ decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })),
1489
+ };
1490
+ T.structure = s;
1491
+ const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
1492
+ edges_added: s.deps_added.length, edges_removed: s.deps_removed.length, import_files_added: sum("files_added"), import_files_removed: sum("files_removed"),
1493
+ boundary_changed: Boolean(d.boundary_config_changed?.changed), boundary_loosened: Boolean(d.boundary_config_changed?.loosens) };
1494
+ ev(rec);
1495
+ await emit(rec);
1496
+ }
1497
+
1097
1498
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
1098
1499
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
1099
1500
  for (;;) {
@@ -1103,22 +1504,40 @@ export async function runCore(o) {
1103
1504
  // merge right after review
1104
1505
  T.merge = [policyText, "merged right after review"];
1105
1506
  } else {
1106
- const startedAt = now();
1107
- let pollIndex = 0, gateHead = null;
1507
+ let startedAt = now();
1508
+ let pollIndex = 0, gateHead = null, conflicted = false, notStartedLogged = false;
1108
1509
  for (;;) {
1109
1510
  await renew();
1511
+ let states = [];
1512
+ // a rollup of another head (the PR before a fix push lands) counts as pending, never as this head's result
1513
+ const current = (x) => !x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head));
1110
1514
  if (policy === "all") {
1111
1515
  const w = await io.waitCiGreen(pr, repo, 0).catch(() => ({ rc: 2, text: "" }));
1112
1516
  if (w.rc === 0) { gateHead = /CI_GATE_HEAD:\s*([0-9a-f]{7,40})/.exec(w.text ?? "")?.[1] ?? null; break; }
1113
1517
  if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
1114
1518
  } else {
1115
- let states = [];
1116
1519
  try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
1117
- const st = (n) => states.find((x) => x.name === n)?.state ?? "pending";
1520
+ const check = (n) => states.find((x) => x.name === n && current(x));
1521
+ const st = (n) => check(n)?.state ?? "pending";
1118
1522
  const failed = required.filter((n) => st(n) === "failure");
1119
- if (failed.length) return await needsHuman(`Required check(s) failed on PR #${pr}: ${failed.join(", ")}`, OUTCOME_CODES.CI_RED);
1523
+ if (failed.length) {
1524
+ const r = await mergeFixRound(failed.map((n) => ({ name: n, url: check(n)?.url ?? null })));
1525
+ if (r) return r;
1526
+ startedAt = now(); pollIndex = 0; // the fixed head gets the full wait budget
1527
+ continue;
1528
+ }
1120
1529
  if (required.every((n) => st(n) === "success")) break;
1121
1530
  }
1531
+ // #4266: a PR that turns conflicting gets no merge ref, so its pull_request workflows never start: base-sync now.
1532
+ const ms = await waitMergeState();
1533
+ if (ms === "conflict") { conflicted = true; break; }
1534
+ if (policy !== "all" && ms === "mergeable" && !notStartedLogged && now() - startedAt >= CI_NOT_STARTED_GRACE_MS
1535
+ && required.every((n) => !states.some((x) => x.name === n && current(x)))) {
1536
+ notStartedLogged = true;
1537
+ const minutes = Math.round((now() - startedAt) / 60000);
1538
+ ev({ event: "CI_NOT_STARTED", pr, checks: required, minutes });
1539
+ T.anomalies.push(`Required check(s) ${required.join(", ")} not started on PR #${pr} after ${minutes} min (missing workflow trigger?)`);
1540
+ }
1122
1541
  if (now() - startedAt >= ciMaxMinutes * 60000) {
1123
1542
  // Slow CI is never a needs-human: leave the PR open and resumable.
1124
1543
  ev({ event: "CI_PENDING_WAIT", state: "end", pr, result: "timeout" });
@@ -1128,6 +1547,10 @@ export async function runCore(o) {
1128
1547
  onProgress({ event: "phase_paused", phase: "merge", detail: `CI pending on PR #${pr}` });
1129
1548
  await sleep(ciPollDelayMs(pollIndex++, random));
1130
1549
  }
1550
+ if (conflicted) {
1551
+ const r = await baseSync("PR became conflicting while waiting for CI"); if (r) return r;
1552
+ continue; // wait again on the synced head with a fresh budget
1553
+ }
1131
1554
  if (gateHead && !(localHead.startsWith(gateHead) || gateHead.startsWith(localHead))) {
1132
1555
  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);
1133
1556
  }
@@ -1167,17 +1590,27 @@ export async function runCore(o) {
1167
1590
  const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
1168
1591
  }
1169
1592
  }
1593
+ try { await recordStructure(pr); } catch (e) { ev({ event: "STRUCTURE_DELTA_FAILED", error: String(e?.message ?? e).slice(0, 300) }); }
1170
1594
  await deleteMergedBranch();
1171
1595
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
1172
1596
  if (closing.tracked.length) {
1173
1597
  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 */ }
1174
1598
  }
1599
+ if (isSplit && split.rest) { // #4211: the rest of a large split is the next PR of the chain, planned already (no grouping call)
1600
+ const mod = o.refactorModule ?? (await import("./refactor.mjs"));
1601
+ const c = mod.renderChainIssue({ parent: issue, target: split.target, packageName: split.packageName, rest: split.rest, testCmd: mod.parseTestCmd(iss.body ?? "", []) });
1602
+ try {
1603
+ const out = String(await ghw(["issue", "create", "-R", repo, "--title", c.title, "--body", c.body, ...c.labels.flatMap((l) => ["--label", l]), ...(milestone ? ["--milestone", milestone] : [])]));
1604
+ T.decisions.push(`refactor:split chain: ${Object.keys(split.rest).length} module(s) left, filed ${/\/issues\/(\d+)/.exec(out)?.[1] ? `#${/\/issues\/(\d+)/.exec(out)[1]}` : out.trim().slice(0, 120)}`);
1605
+ } catch (e) { T.anomalies.push(`refactor:split chain issue not filed (the maintenance scan re-files the hotspot): ${String(e?.message ?? e).slice(0, 200)}`); }
1606
+ }
1175
1607
  await label("merged");
1176
- return await finish("merged", `PR #${pr}`, { pr });
1608
+ // forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
1609
+ return await finish("merged", `PR #${pr}`, { pr, structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
1177
1610
  } catch (e) {
1178
1611
  if (e?.deferUsageLimit) {
1179
1612
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
1180
- return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
1613
+ return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null, backendId: laneBackendId(process.env) }, OUTCOME_CODES.RATE_LIMITED);
1181
1614
  }
1182
1615
  if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
1183
1616
  if (e?.laneCode) return await needsHuman(e.message, e.laneCode);