forgedock 1.29.1 → 1.30.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 (38) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +94 -36
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +17 -12
  9. package/bin/lean/context.test.mjs +36 -5
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +189 -43
  14. package/bin/lean/lean.test.mjs +305 -6
  15. package/bin/lean/orchestrate.mjs +104 -85
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +21 -4
  19. package/bin/lean/prefilter.test.mjs +24 -1
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +75 -0
  24. package/bin/lean/sequence.mjs +102 -0
  25. package/bin/lean/sequence.test.mjs +112 -0
  26. package/bin/lean/testmap.mjs +43 -6
  27. package/bin/lean/testmap.test.mjs +38 -1
  28. package/bin/lean/verify.mjs +7 -2
  29. package/bin/lean/verify.test.mjs +8 -2
  30. package/commands/orchestrate.md +3 -1
  31. package/docs/CONFIG.md +8 -1
  32. package/docs/OUTCOME-CODES.md +6 -4
  33. package/docs/RELEASE-NOTES.md +10 -0
  34. package/package.json +1 -1
  35. package/scripts/acceptance-red-green.sh +13 -4
  36. package/scripts/acceptance-red-green.test.sh +14 -0
  37. package/scripts/test-map.sh +63 -17
  38. package/scripts/test-map.test.sh +55 -0
package/bin/lean/lean.mjs CHANGED
@@ -10,20 +10,23 @@ import { hostname as osHostname } from "node:os";
10
10
  import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
- import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
13
+ import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
14
14
  import { createHash } from "node:crypto";
15
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, parseReview, decide, mergeReviewRows, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
15
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
16
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
17
  import { namedPaths } from "./prefilter.mjs";
18
+ import { preflightProbes, preflightProblems } from "./preflight.mjs";
18
19
  import { resolveLane, isDefaultLane } from "./lane.mjs";
19
20
  import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
20
21
  import { repoKey } from "../runner.mjs";
21
22
  import { prepareToolchain } from "./toolchain.mjs";
22
- import { readTestMap, readTestMapAtRef, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
23
+ import { readTestMap, readTestMapAtRef, selectCommands, scopeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
24
+ import { runChecks } from "./precheck.mjs";
23
25
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
24
26
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
25
27
  import { computeTrigger, TRIGGER_MODES } from "./review-trigger.mjs";
26
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM } from "./prompts.mjs";
28
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
29
+ import { unjustified } from "./enumerating.mjs";
27
30
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
28
31
  import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport } from "./verify.mjs";
29
32
  import { laneBackendId } from "./limits.mjs";
@@ -41,7 +44,11 @@ export const RECOVER_HOW = Object.freeze({
41
44
  [OUTCOME_CODES.BASE_CONFLICT]: "the PR conflicts with origin/BASE. Merge origin/BASE, resolve every conflict keeping both sides' intent, re-run the validate commands and push new commits only",
42
45
  [OUTCOME_CODES.BUILDER_BLOCK_UNTYPED]: "an earlier run escalated an in-scope technical choice. Do not block on it: pick the option most consistent with the issue, the repo's conventions and prior merged decisions, and name the alternative you did not take in your summary",
43
46
  [OUTCOME_CODES.CONTRACT_INVALID]: "the issue was edited while the earlier run planned. Plan against the current issue text",
47
+ [OUTCOME_CODES.ENV_TOOL_MISSING]: "a validate command could not run because the host lacks the tool or module named in the earlier run's detail; the change may already be correct. Do not edit the deliverables for it and never install anything globally: run the same check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts), record that exit under the same validate id and name the command you used in your summary",
48
+ [OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]: "the repro harness could not run the contract's repro_test on head (timeout, missing runner or dependencies); the change may already be correct. Do not edit the deliverables or stub dependencies for it and never install anything globally: run the repro command through the repo's own toolchain and installed dependencies, and when it cannot run in this checkout, fix the repro_test command (not the code) so it does",
44
49
  });
50
+ /** #4323/#4334: precheck failure codes that are environment errors (state "environment"), never a builder resume. */
51
+ const ENV_CODES = Object.freeze([OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
45
52
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
46
53
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
47
54
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
@@ -120,7 +127,9 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
120
127
  * @param {"needs-human"|"degraded"} [o.reviewIncomplete] shadow only: what an incomplete review after the re-ask does (default needs-human)
121
128
  * @param {object} [o.reviewTrigger] forge.yaml review.trigger/risk_paths (#4273): {mode: shadow (default)|enforce|off, riskPaths: globs}
122
129
  * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
123
- * @param {object} [o.precheck] {commands}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240)
130
+ * @param {object} [o.precheck] {commands, maxMinutes}: forge.yaml learned.test_commands, run in the worktree before the PR (#4240);
131
+ * maxMinutes: lean.precheck.max_minutes, the round budget (#4331, else verify.precheck_total_seconds)
132
+ * @param {boolean} [o.closeParent] forge.yaml decomposition.close_parent (#3736): close a decomposed parent at decomposition (default false)
124
133
  * @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
125
134
  * @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
126
135
  * @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
@@ -135,7 +144,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
135
144
  */
136
145
  export async function runCore(o) {
137
146
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
138
- 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;
147
+ toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
139
148
  const now = io.now ?? (() => Date.now());
140
149
  const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
141
150
  if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
@@ -174,7 +183,8 @@ export async function runCore(o) {
174
183
  const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
175
184
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
176
185
  };
177
- const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); } catch { /* labels are best effort */ } };
186
+ /** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
187
+ const label = async (state) => { try { await retrying(() => io.transitionLabel(issue, state), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random }); return true; } catch { /* labels are best effort */ return false; } };
178
188
  const usageLine = () => `cost $${usage.cost_usd.toFixed(4)}, tokens ${usage.input} in / ${usage.output} out / ${usage.cache} cache, wall ${Math.round((now() - t0) / 60000 * 10) / 10} min`;
179
189
 
180
190
  // ---- lease (same FORGE:STATE lease the full path uses) ----
@@ -218,6 +228,7 @@ export async function runCore(o) {
218
228
  const heavyCfg = o.heavy ?? {}, heavyOn = Boolean(io.heavy) && heavyCfg.enabled !== false;
219
229
  let heavyEnv = null, heavyWaitsFile = null, heavyQueueMax = 0, stage = "plan", iss;
220
230
  const waits = {};
231
+ let precheckMs = 0, precheckDeferred = 0, precheckRan = false; // #4331: pre-PR check wall time and deferrals, for the batch report
221
232
  const addWait = (phase, ms, queued = 0) => {
222
233
  if (Number.isFinite(ms) && ms > 0) waits[phase] = (waits[phase] ?? 0) + ms;
223
234
  if (Number.isInteger(queued)) heavyQueueMax = Math.max(heavyQueueMax, queued);
@@ -343,7 +354,7 @@ export async function runCore(o) {
343
354
  }
344
355
  await releaseLease(reason, code);
345
356
  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,
346
- ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...extra };
357
+ ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}), ...extra };
347
358
  return done;
348
359
  }
349
360
  /** #4297/#4316: a recovery lane that ended unmerged keeps needs-human with this evidence (shared text in render.mjs). */
@@ -425,18 +436,25 @@ export async function runCore(o) {
425
436
  if (!io.host?.cores) return null;
426
437
  try { return String(hostParallelism({ cores: io.host.cores(), lanes: io.host.activeLanes?.() })); } catch { return null; }
427
438
  }
439
+ /**
440
+ * #4325: the lane env every engine-run command shares with the builder: shared package-manager stores (#4220), the run
441
+ * TMPDIR (#4104), FORGE_HOST_PARALLELISM (#4182) and the pinned toolchain (#4163); the engine's own keys win over the stores.
442
+ */
443
+ function laneEnv() {
444
+ let deps = {};
445
+ try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
446
+ const par = verifyParallelism();
447
+ return { ...deps, ...(runTmp ? { TMPDIR: runTmp } : {}), ...(par ? { FORGE_HOST_PARALLELISM: par } : {}), ...(toolchain?.env ?? {}) };
448
+ }
428
449
  /** 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). */
429
450
  async function callRunner(args) {
430
451
  let pauses = 0;
431
452
  for (;;) {
432
453
  // #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
433
454
  const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
434
- const par = verifyParallelism(); // #4182
435
- let deps = {}; // #4220: shared package-manager stores; the engine's own keys win
436
- try { deps = io.deps?.env?.() ?? {}; } catch { deps = {}; }
437
455
  // #4169: heavy shims first on PATH; the phase/issue/priority label each heavy step's slot and wait record.
438
456
  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()) } : {};
439
- 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 } }); }
457
+ try { return await io.runner({ ...args, env: { ...laneEnv(), FORGE_DOCKER_LABELS: labels, ...(runTmp ? { CLAUDE_CODE_TMPDIR: runTmp } : {}), ...(toolchain?.env ?? {}), ...heavy } }); }
440
458
  catch (e) {
441
459
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
442
460
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -506,7 +524,8 @@ export async function runCore(o) {
506
524
  let facts = null; // #4272: path of an optional .forge/repo-facts.json (worktree, then main checkout), cited in builder prompts
507
525
  const tcOpts = () => ({ toolchain: toolchain?.summary ?? null, sparse, ...(facts ? { facts } : {}) });
508
526
  let hotFacts = null; // forge#4195: scripts/hotspots.sh facts for the files the issue names, read once before the plan call
509
- const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + CONTINUE_NOTE.replace("BASE", lane);
527
+ let enumList = []; // #4322: tests enumerating the members of the paths the issue names, read once before the plan call
528
+ const implementRestart = () => builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + CONTINUE_NOTE.replace("BASE", lane);
510
529
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
511
530
  let timeoutRetries = 0, p = prompt;
512
531
  for (;;) {
@@ -580,11 +599,13 @@ export async function runCore(o) {
580
599
  try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
581
600
  return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
582
601
  }
583
- if (String(v?.state).toUpperCase() !== "OPEN") continue;
584
- // #4153: an open decomposed parent resolves to its open sub-issues; it is satisfied once they all close.
585
- const subs = isDecomposedRecord(v) ? await decomposedSubissues(d, io) : null;
602
+ // #4153 / #4337: a decomposed parent, open or closed, resolves to its open sub-issues; it is satisfied once they all
603
+ // close. Without a usable trail the parent itself is the dependency, even when closed (fail closed).
604
+ const decomposed = isDecomposedRecord(v) && ["OPEN", "CLOSED"].includes(String(v?.state).toUpperCase());
605
+ if (!decomposed && String(v?.state).toUpperCase() !== "OPEN") continue;
606
+ const subs = decomposed ? await decomposedSubissues(d, io) : null;
586
607
  const recs = [];
587
- if (!subs) recs.push([d, v]);
608
+ if (!subs) recs.push([d, { ...v, state: "OPEN" }]);
588
609
  else for (const n of subs) {
589
610
  try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
590
611
  return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
@@ -629,8 +650,16 @@ export async function runCore(o) {
629
650
  }
630
651
  } catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
631
652
  T.decisions.push(`Builder block: split into ${refs(filed)}`);
632
- try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); } catch { /* best effort */ }
633
- await label("decomposed");
653
+ let marked = false;
654
+ try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); marked = true; } catch { /* best effort */ }
655
+ // #4337: with decomposition.close_parent, the tracker closes only once its trail and label exist: dependents then wait on the sub-issues, not on it.
656
+ if (await label("decomposed") && marked && closeParent) {
657
+ try {
658
+ await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned", "--comment",
659
+ `Decomposed into ${refs(filed)}; this tracker is closed and its dependents wait on the sub-issues. A trusted \`FORGE:DECOMPOSED:REVOKED\` reopens it.`]);
660
+ T.decisions.push(`Closed the decomposed parent (not planned); tracked by ${refs(filed)}`);
661
+ } catch { /* best effort: the parent stays open, as before #4337 */ }
662
+ }
634
663
  return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
635
664
  }
636
665
  return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
@@ -695,7 +724,13 @@ export async function runCore(o) {
695
724
  try {
696
725
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
697
726
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
698
- if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
727
+ if (String(iss.state).toUpperCase() === "CLOSED") {
728
+ // #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
729
+ // them). A trusted revocation reopens it and the run continues. An unreadable trail lists none (fail closed).
730
+ if (!isDecomposedRecord(iss)) return finish("invalid", "issue already closed");
731
+ if (await reopenRevokedDecomposition(issue, io)) iss.state = "OPEN";
732
+ else return finish("decomposed", "closed decomposed tracker; dependents wait on its sub-issues", { code: OUTCOME_CODES.DECOMPOSED, subissues: (await decomposedSubissues(issue, io)) ?? [] });
733
+ }
699
734
 
700
735
  try {
701
736
  // ---- 1. claim ----
@@ -830,7 +865,7 @@ export async function runCore(o) {
830
865
  if (typeof envSetup === "string" && envSetup.trim()) {
831
866
  let r;
832
867
  if (!io.runSetup) r = { exit: 1, stderr: "no setup effect is configured" };
833
- 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) }; } }
868
+ else { try { r = await heavyStep("setup", () => io.runSetup({ cwd: wt, command: envSetup, env: laneEnv() })); } catch (e) { r = { exit: 1, stderr: String(e?.message ?? e) }; } }
834
869
  ev({ event: "ENV_SETUP", exit: r?.exit ?? 1 });
835
870
  if (r?.exit !== 0) {
836
871
  const tail = String(r?.stderr || r?.stdout || "").trim().slice(-800);
@@ -864,7 +899,7 @@ export async function runCore(o) {
864
899
  if (reproOn() && t === "none" && !reproNoted) { reproNoted = true; T.decisions.push(`repro_test: none (${String(c.repro_test_reason ?? "").slice(0, 200)})`); }
865
900
  return null;
866
901
  }
867
- const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command) })).catch(() => null);
902
+ const rg = await heavyStep("precheck", () => io.redGreen({ cwd: wt, base, command: String(t.command), env: laneEnv() })).catch(() => null);
868
903
  const words = rg?.base && rg?.head ? rg : { base: "UNRUNNABLE", head: "UNRUNNABLE" };
869
904
  ev({ event: "REPRO_RED_GREEN", ...words });
870
905
  T.decisions.push(`repro_test ${t.path ?? ""}${t.id ? ` ${t.id}` : ""}: BASE=${words.base} HEAD=${words.head}`);
@@ -953,6 +988,23 @@ export async function runCore(o) {
953
988
  }
954
989
  T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
955
990
  }
991
+ /** #4328: probe the host for the tools and modules a contract's validate commands need; the misses, [] without io.probeCommand. */
992
+ async function preflightValidates(contract) {
993
+ if (!io.probeCommand || !contract || typeof contract !== "object") return [];
994
+ const files = String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean);
995
+ const { probes, dropped } = preflightProbes(contract, { repoHas: repoTracks(files) });
996
+ if (dropped) T.anomalies.push(`Validate preflight probed ${probes.length} tool(s); ${dropped} more not probed`);
997
+ const env = laneEnv(), missing = new Set(), misses = []; // #4325: the env the validate commands run with
998
+ for (const p of probes) {
999
+ if (p.needs && missing.has(p.needs)) continue;
1000
+ let r;
1001
+ try { r = await io.probeCommand({ command: p.probe, cwd: wt, timeoutMs: 15000, env }); } catch { r = { rc: 127 }; }
1002
+ if (r?.rc === 0 || r?.rc === 124) continue; // a timed-out probe is no evidence either way
1003
+ if (p.kind === "command") missing.add(p.tool);
1004
+ misses.push(p);
1005
+ }
1006
+ return misses;
1007
+ }
956
1008
  /**
957
1009
  * The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. `humanAfter`
958
1010
  * (#4265): a trusted comment that the engine did not post came after it.
@@ -1050,9 +1102,10 @@ export async function runCore(o) {
1050
1102
  }
1051
1103
  await label("building");
1052
1104
  hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
1105
+ enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
1053
1106
  facts = await factsPath([wt, cwd], io.fs?.exists);
1054
1107
  const ctxPack = frozen ? null : await contextPackStep();
1055
- const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1108
+ const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1056
1109
  if (!frozen) {
1057
1110
  await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote(), null, true, planPrompt);
1058
1111
  planned = true;
@@ -1074,12 +1127,23 @@ export async function runCore(o) {
1074
1127
  let contract = await readContract();
1075
1128
  await refreshIssue("contract validation");
1076
1129
  let vc = validateContract(contract, issueText, { repro: reproOn() });
1077
- if (!vc.ok && sessionId) {
1078
- T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
1079
- await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, planPrompt);
1130
+ const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
1131
+ let misses = missed;
1132
+ if ((!vc.ok || missed.length) && sessionId) {
1133
+ const problems = [...vc.problems, ...preflightProblems(missed)];
1134
+ T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
1135
+ await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt);
1080
1136
  contract = await readContract();
1081
1137
  await refreshIssue("contract validation");
1082
1138
  vc = validateContract(contract, issueText, { repro: reproOn() });
1139
+ if (missed.length) misses = await preflightValidates(contract);
1140
+ }
1141
+ if (missed.length) {
1142
+ ev({ event: "VALIDATE_PREFLIGHT", misses: misses.map((m) => ({ ids: m.ids, tool: m.tool, kind: m.kind })), corrected: misses.length === 0 });
1143
+ if (misses.length) {
1144
+ T.anomalies.push(`Validate preflight: the host lacks ${misses.map((m) => m.tool).join(", ")} after the contract correction (advisory; a failed rerun ends env.tool_missing)`);
1145
+ T.decisions.push(`Validate preflight left ${misses.length} miss(es) after one correction; contract frozen anyway`);
1146
+ }
1083
1147
  }
1084
1148
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
1085
1149
  if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID, issueEdited ? { issueEdited: true } : {});
@@ -1096,7 +1160,7 @@ export async function runCore(o) {
1096
1160
  }
1097
1161
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1098
1162
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1099
- const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1163
+ const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote();
1100
1164
  liveKeys = ["implementation"];
1101
1165
  stage = "implement";
1102
1166
  await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
@@ -1121,24 +1185,29 @@ export async function runCore(o) {
1121
1185
  const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
1122
1186
  // #4297: its commands run, so the map comes from the base ref (else the operator's checkout), never the lane worktree
1123
1187
  const testMap = io.runCommand ? (await readTestMapAtRef(git, base)) ?? (await readTestMap([cwd], io.fs?.read)) : null;
1124
- // #4315: the map's selection is merged into the learned commands (deduped): a partial map never skips a learned suite
1125
- const pickChecks = () => { const m = testMap ? selectCommands(testMap, files) : []; return { ...mergeChecks(m, learnedCommands), selected: m.length }; };
1188
+ // #4331: a learned command the map covers runs only when its globs match a changed file; unmapped ones always run (#4315)
1189
+ const pickChecks = () => { const m = scopeChecks(testMap, files, learnedCommands); return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0 }; };
1126
1190
  let picked = pickChecks();
1127
1191
  // #4270: the last result of each deterministic gate, as computed below; only reportVerify (verify.check_run) reads it
1128
1192
  const verifyFacts = { commands: null, structure: null, undeclaredEdges: [], docs: null, precheck: null, amendments: [], hotspots: false };
1129
- if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length} learned` : ""}`);
1193
+ if (picked.source !== "learned") T.decisions.push(`Pre-PR checks selected from ${TEST_MAP_FILE}: ${picked.selected} of ${testMap.entries.length} entries${learnedCommands.length ? `, plus ${learnedCommands.length - picked.dropped.length} learned` : ""}${picked.dropped.length ? `; ${picked.dropped.length} mapped learned command(s) not matching the changed files left to CI` : ""}`);
1130
1194
  if (picked.commands.length && io.runCommand) {
1195
+ precheckRan = true;
1131
1196
  for (let round = 0; ; round++) {
1132
1197
  await renew();
1133
1198
  if (round) picked = pickChecks();
1134
1199
  const precheckCommands = picked.commands;
1135
- const results = [];
1136
- for (const command of precheckCommands) {
1137
- let r;
1138
- try { r = await heavyStep("precheck", () => io.runCommand({ command, cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000 })); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
1139
- results.push({ command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
1140
- }
1141
- ev({ event: "PRECHECK_COMMANDS", round, results: results.map(({ command, rc }) => ({ command, rc })), ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1200
+ // #4318/#4331: one round has a total budget (lean.precheck.max_minutes, else verify.precheck_total_seconds); the
1201
+ // commands run concurrently up to the host worker cap under ONE heavy slot; the rest are deferred to CI, not failed
1202
+ const maxMin = Number(o.precheck?.maxMinutes), totalMs = (maxMin > 0 ? maxMin * 60 : Number(vcfg.precheckTotalSeconds) || 600) * 1000;
1203
+ const parallelism = Number(verifyParallelism()) || 1;
1204
+ const round$ = await heavyStep("precheck", () => runChecks({ commands: precheckCommands, parallelism, budgetMs: totalMs, perCommandMs: vcfg.precheckTimeoutSeconds * 1000, now,
1205
+ run: ({ command, timeoutMs }) => io.runCommand({ command, cwd: wt, timeoutMs, env: laneEnv() }) }));
1206
+ const { results, deferred } = round$;
1207
+ precheckMs += round$.ms; precheckDeferred += deferred.length;
1208
+ if (deferred.length) T.anomalies.push(`deferred-to-ci: pre-PR check budget ${totalMs / 1000} s reached; left to CI: ${deferred.map((c) => `\`${c}\``).join(", ")}`);
1209
+ ev({ event: "PRECHECK_COMMANDS", round, results: [...results.map(({ command, rc, ms }) => ({ command, rc, ms })), ...deferred.map((command) => ({ command, status: "deferred-to-ci" }))],
1210
+ ms: round$.ms, parallelism, ...(picked.source !== "learned" ? { source: picked.source } : {}) });
1142
1211
  verifyFacts.commands = results.map(({ command, rc }) => ({ command, rc }));
1143
1212
  const failed = results.filter((r) => r.rc !== 0);
1144
1213
  if (!failed.length) { checkCommandsFailing = false; if (round) T.decisions.push(`Declared check commands pass after ${round} builder fix round(s)`); break; }
@@ -1203,6 +1272,38 @@ export async function runCore(o) {
1203
1272
  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);
1204
1273
  }
1205
1274
  }
1275
+ // ---- 3d. enumerating tests (#4322): new symbols that tests listing their siblings do not name; one resume, then an
1276
+ // anomaly (CI stays the gate). Sequence numbers the base already uses: renumbered at hand-off, still colliding = base.conflict.
1277
+ if (io.enumeratingMissing && !isSplit) {
1278
+ const missingNow = async () => unjustified((await io.enumeratingMissing({ cwd: wt, base }).catch(() => null)) ?? [], result?.not_members);
1279
+ let missing = await missingNow();
1280
+ if (missing.length && sessionId) {
1281
+ ev({ event: "ENUMERATING_MISSING", count: missing.length });
1282
+ T.decisions.push(`Enumerating test(s) miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}; builder resumed once`);
1283
+ await builder(++attemptN, missingEntriesPrompt(missing, vcfg.mode), sessionId);
1284
+ await upsertStage("implementation");
1285
+ result = (await readResult()) ?? result;
1286
+ missing = await missingNow();
1287
+ }
1288
+ if (missing.length) T.anomalies.push(`Enumerating test(s) still miss new symbol(s): ${missing.slice(0, 5).map((m) => `${m.symbol} → ${m.test}`).join(", ")}`);
1289
+ }
1290
+ /** #4322: null (nothing to do), {done} (terminal), or {renumbered: true}. Fetches the lane, so the check sees its tip. */
1291
+ async function sequenceHandoff(trigger) {
1292
+ if (!io.sequenceCheck || isSplit) return null;
1293
+ try { await git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
1294
+ const collisions = (await io.sequenceCheck({ cwd: wt, base }).catch(() => null)) ?? [];
1295
+ if (!collisions.length) return null;
1296
+ const before = (await io.sequenceSnapshot?.({ cwd: wt, base }).catch(() => null)) ?? [];
1297
+ ev({ event: "SEQUENCE_COLLISION", trigger, files: collisions.map((c) => c.lane) });
1298
+ T.decisions.push(`Sequence number(s) taken on ${lane} (${trigger}): ${collisions.filter((c) => c.base.length).map((c) => `${c.lane} vs ${c.base.join(", ")}`).join("; ")}; builder renumbers`);
1299
+ await builder(++attemptN, sequencePrompt(collisions, lane, vcfg.mode), sessionId ?? null);
1300
+ result = (await readResult()) ?? result;
1301
+ const v = (await io.sequenceVerify?.({ cwd: wt, base, before }).catch(() => null)) ?? { ok: false, problems: ["renumber could not be verified"] };
1302
+ if (!v.ok) return { done: await needsHuman(`Sequence-numbered files still collide with ${lane} after the builder renumbered (${trigger}); nothing merged:\n${v.problems.slice(0, 10).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.BASE_CONFLICT) };
1303
+ files = await changedFiles();
1304
+ return { renumbered: true };
1305
+ }
1306
+ { const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
1206
1307
  const structureLine = structureNote(structure);
1207
1308
 
1208
1309
  // ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
@@ -1292,7 +1393,12 @@ export async function runCore(o) {
1292
1393
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1293
1394
  const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
1294
1395
  let d = null, first = null;
1295
- for (let t = 0; t < 2; t++) {
1396
+ for (let t = 0; t < 3; t++) {
1397
+ // #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
1398
+ if (t === 2) {
1399
+ if (d.state !== "review.incomplete" || !evidencePathOnly(d.failures.map((f) => f.detail))) break;
1400
+ T.decisions.push(`Review incomplete only by unresolved evidence paths (${(d.missing ?? []).join(", ")}); reviewer re-asked once more`);
1401
+ }
1296
1402
  // #4160: a reply with rows missing is re-asked for those ids only; an unparseable one gets the full prompt again.
1297
1403
  const repair = first && (d.state === "review.incomplete" || d.state === "review.inconsistent");
1298
1404
  await renew();
@@ -1311,6 +1417,21 @@ export async function runCore(o) {
1311
1417
  }
1312
1418
  return d;
1313
1419
  }
1420
+ /** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
1421
+ async function rerunFailedValidates(contract) {
1422
+ if (!io.runCommand) return null;
1423
+ const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
1424
+ const env = laneEnv();
1425
+ for (const v of contract.validate ?? []) {
1426
+ const r = runs.find((x) => x?.id === v.id);
1427
+ if (!r || r.exit === undefined || r.exit === 0) continue;
1428
+ let x;
1429
+ try { x = await heavyStep("precheck", () => io.runCommand({ command: String(v.command), cwd: wt, timeoutMs: vcfg.precheckTimeoutSeconds * 1000, env })); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
1430
+ out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
1431
+ }
1432
+ if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
1433
+ return out;
1434
+ }
1314
1435
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
1315
1436
  async function gate() {
1316
1437
  await refreshIssue("conformance");
@@ -1319,14 +1440,20 @@ export async function runCore(o) {
1319
1440
  const amendProblems = await amend();
1320
1441
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1321
1442
  const hotspots = await hotspotsOf({ base, paths: files });
1322
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective) });
1443
+ const validateRuns = await rerunFailedValidates(effective);
1444
+ const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1445
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas });
1323
1446
  Object.assign(verifyFacts, { precheck: pc.failures, amendments: amendProblems, hotspots: Boolean(hotspots && hotspots.enabled !== false && Array.isArray(hotspots.files)) });
1324
- const pre = [...pc.failures, ...amendProblems];
1447
+ // #4323/#4334: a missing host tool or module, or a repro test the harness could not run, is an environment error:
1448
+ // never a conformance failure, never a builder resume
1449
+ const envFails = pc.failures.filter((f) => ENV_CODES.includes(f.code));
1450
+ const pre = [...pc.failures.filter((f) => !ENV_CODES.includes(f.code)), ...amendProblems];
1325
1451
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
1326
1452
  if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
1327
1453
  // shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
1328
1454
  const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.code)) : [];
1329
1455
  if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard, would: "nonconforming", wouldFail: pre, reviewState: null };
1456
+ if (envFails.length) return { state: "environment", by: "precheck", failures: envFails };
1330
1457
  // #4273: deterministic review trigger; off computes nothing, shadow logs and always reviews, enforce skips when untriggered
1331
1458
  if (triggerMode !== "off") {
1332
1459
  let t = null;
@@ -1371,9 +1498,21 @@ export async function runCore(o) {
1371
1498
  }
1372
1499
  ev({ event: "VERIFY", head, conclusion: r.conclusion, gates: r.gates.map((g) => ({ id: g.id, ok: g.ok })), posted, url, ...(error ? { error } : {}) });
1373
1500
  }
1374
- /** Shape the base-sync path expects: {unusable: state} when no usable review, else {fail, verdict, reason, failures}. */
1501
+ /** #4323: a validate command the host could not run ends needs-human env.tool_missing (recoverable), never a builder resume. */
1502
+ // #4334: a repro test the harness could not run on head (and nothing else) ends env.repro_unrunnable (recoverable)
1503
+ async function envNeedsHuman(failures) {
1504
+ if (failures.every((f) => f.code === OUTCOME_CODES.ENV_REPRO_UNRUNNABLE)) {
1505
+ T.anomalies.push("Environment error: the repro harness could not run the repro test on head");
1506
+ return await needsHuman(`Environment error (not a conformance failure): the repro harness could not run the repro test on head; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nRun the repro command through the repo's own toolchain and installed dependencies, then rerun.`, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE);
1507
+ }
1508
+ const tools = [...new Set(failures.filter((f) => f.tool).map((f) => f.tool))].join(", ");
1509
+ T.anomalies.push(`Environment error: validate command(s) could not run, the host is missing ${tools}`);
1510
+ return await needsHuman(`Environment error (not a conformance failure): the host is missing ${tools}, so validate command(s) could not run; no builder resume was spent and nothing merged:\n${failures.map((f) => `- ${f.code}: ${f.detail}`).join("\n")}\n\nInstall it on the host or run the check through the repo's own toolchain, then rerun.`, OUTCOME_CODES.ENV_TOOL_MISSING);
1511
+ }
1512
+ /** Shape the base-sync path expects: {unusable: state} when no usable review, {env: failures} for a missing host tool, else {fail, verdict, reason, failures}. */
1375
1513
  const doReview = async () => {
1376
1514
  const d = await gate();
1515
+ if (d.state === "environment") return { env: d.failures };
1377
1516
  if (d.state.startsWith("review.")) return { unusable: d.state, failures: d.failures };
1378
1517
  return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
1379
1518
  };
@@ -1391,6 +1530,7 @@ export async function runCore(o) {
1391
1530
  if (outcome.state === "conforms") break;
1392
1531
  const explained = explainFailures(outcome.failures, effective); // #4265: name the unmet requirement, not only the code
1393
1532
  const list = explained.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
1533
+ if (outcome.state === "environment") return await envNeedsHuman(outcome.failures);
1394
1534
  if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
1395
1535
  if (shadow && reviewIncomplete === "degraded" && (outcome.state === "review.incomplete" || outcome.state === "review.inconsistent")) {
1396
1536
  degraded = { state: outcome.state, missing: outcome.missing ?? [] };
@@ -1523,7 +1663,7 @@ export async function runCore(o) {
1523
1663
  how = "clean merge of the base into the branch";
1524
1664
  } else {
1525
1665
  T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
1526
- const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge. Do not push.`;
1666
+ const prompt = `Merging ${base} into this branch conflicts in: ${files.join(", ") || "(see git status)"}. Resolve the conflicts preserving both this fix's intent and the base's changes, ${syncChecks(vcfg.mode)}, and commit the merge.${sequenceSyncNote(files)} Do not push.`;
1527
1667
  await builder(++attemptN, prompt, sessionId ?? null);
1528
1668
  if (await unresolved()) {
1529
1669
  await gitOut(["merge", "--abort"]);
@@ -1533,6 +1673,9 @@ export async function runCore(o) {
1533
1673
  const r2 = await readResult();
1534
1674
  tests = Array.isArray(r2?.tests) && r2.tests.length ? r2.tests.map((t) => (typeof t === "string" ? t : `${t.command}: ${t.result}`)).join("; ") : NR_TESTS;
1535
1675
  }
1676
+ const sq = await sequenceHandoff(`base-sync ${syncs}`); // #4322: a clean text merge can still bring a taken number
1677
+ if (sq?.done) return sq.done;
1678
+ if (sq?.renumbered) { how += "; builder renumbered sequence-numbered files"; }
1536
1679
  const after = await gitOut(["rev-parse", "HEAD"]);
1537
1680
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
1538
1681
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
@@ -1543,14 +1686,16 @@ export async function runCore(o) {
1543
1686
  rereview = "refactor-verify re-run against the new base: ok";
1544
1687
  }
1545
1688
  if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
1546
- if (!isSplit && (!clean || ciFixes)) {
1689
+ if (!isSplit && (!clean || sq?.renumbered || ciFixes)) {
1547
1690
  let rv = await doReview();
1691
+ if (rv.env) return await envNeedsHuman(rv.env);
1548
1692
  if (rv?.fail) {
1549
1693
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
1550
1694
  await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
1551
1695
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
1552
1696
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
1553
1697
  rv = await doReview();
1698
+ if (rv.env) return await envNeedsHuman(rv.env);
1554
1699
  }
1555
1700
  if (rv.unusable) return await needsHuman(`Reviewer returned no usable review after base-sync (${rv.unusable}).`, UNUSABLE_REVIEW_CODES[rv.unusable]);
1556
1701
  if (rv.fail) return await needsHuman(`Review FAIL after base-sync: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
@@ -1587,6 +1732,7 @@ export async function runCore(o) {
1587
1732
  }
1588
1733
  const rv = await doReview(); // never merge an unreviewed head
1589
1734
  if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
1735
+ if (rv.env) return await envNeedsHuman(rv.env);
1590
1736
  if (rv.fail) return await needsHuman(`Review FAIL after a CI fix round: ${rv.reason}`, OUTCOME_CODES.REVIEW_FAILED);
1591
1737
  await gitPush(["push", "origin", branch]); // plain push of the fix commit: never force
1592
1738
  localHead = await gitOut(["rev-parse", "HEAD"]);