forgedock 1.35.0 → 1.37.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/lean.mjs CHANGED
@@ -11,11 +11,12 @@ import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { readSelfReview, compareSelfReview } from "./selfreview.mjs";
12
12
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
13
13
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
14
- import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
14
+ import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
15
15
  import { createHash } from "node:crypto";
16
- import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
16
+ import { TRACKED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote } from "./conformance.mjs";
17
17
  import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
18
18
  import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
19
+ import { checksAt } from "./postdeploy-check.mjs";
19
20
  import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
20
21
  import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
21
22
  import { splitGate, rationaleLine } from "./decompose.mjs";
@@ -25,13 +26,19 @@ import { prepareToolchain } from "./toolchain.mjs";
25
26
  import { toolStats } from "./toolstats.mjs";
26
27
  import { readTestMap, readTestMapAtRef, mergeChecks, factsPath, TEST_MAP_FILE } from "./testmap.mjs";
27
28
  import { runChecks, runWithTimeoutRetry, planPrecheck, worktreeFingerprint, selfCheckState, quietFailure, CHECK_PLAN_FILE, SELF_CHECK_FILE, CHECK_LOG_DIR } from "./precheck.mjs";
28
- import { retrying, ghCacheRetry, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
29
+ import { retrying, ghCacheRetry, refreshAuthOnce, runRefreshScript, GH_AUTH_RE, GH_AUTH_RETRY_SECONDS, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
29
30
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS, recoveryEvidence } from "./render.mjs";
30
31
  import { parseVerifierConfig, pickVerifierModel, changedSymbols, callersOf, verifierBudget, verifierPrompt, parseVerifierFindings, findingsToFailures, renderVerifierNote, VERIFIER_SYSTEM } from "./verifier.mjs";
31
32
  import { computeTrigger, riskPathHits, TRIGGER_MODES } from "./review-trigger.mjs";
32
33
  import { hostCapabilityBlock } from "./execution.mjs";
33
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt } from "./prompts.mjs";
34
+
35
+ const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
36
+ /** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
37
+ export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
38
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
34
39
  import { postMergeCommands } from "./postmerge.mjs";
40
+ import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
41
+ import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
35
42
  import { freshFix, issueSummary, firstPassOf, sessionCostDelta } from "./fixround.mjs";
36
43
  import { parseBuilderRouting, routeBuilder, shouldEscalate } from "./routing.mjs";
37
44
  import { makeTurnBudget, TURN_BUDGET_DEFAULTS, scaledExtra, laneSize, recoverySize, wrapupGrant, budgetDigest } from "./budget.mjs";
@@ -39,18 +46,21 @@ import { pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog
39
46
  import { unjustified } from "./enumerating.mjs";
40
47
  import { addLaneWorktree, DEFAULT_SPARSE_MAX_KB } from "./worktree.mjs";
41
48
  import { parseVerify, runCiVerify, collectFailedLogs, capLogs, VERIFY_MODES, VERIFY_CHECK_NAME, verifyReport, ciGate, rerunFailedOnce, rerunTarget, splitFlaky } from "./verify.mjs";
42
- import { e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
49
+ import { e2eSpecs, e2eWanted, ownFailures, runE2eGate, NO_TRIGGER } from "./e2e.mjs";
43
50
  import { laneBackendId } from "./limits.mjs";
44
51
  import { SPLIT_LABEL } from "./refactor.mjs";
45
52
  import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
46
53
  import { priorityFromLabels } from "./sched.mjs";
54
+ import { assignArm } from "../experiment.mjs";
47
55
  import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
48
56
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
57
+ import { readDecisionIndex, withDecisionRows, recordDecisions } from "./decisions.mjs";
49
58
  import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
50
59
  import { isExternalKill } from "./hostkill.mjs";
51
60
  import { buildResumeDigest } from "./resume.mjs";
52
61
  import { lightEligibility } from "./light.mjs";
53
62
  import { untrackToolState, unstageToolState } from "./toolstate.mjs";
63
+ import { ensureSignedOff } from "./signoff.mjs";
54
64
  import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
55
65
 
56
66
  /**
@@ -168,6 +178,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
168
178
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
169
179
  * @param {string} [o.fixModel] model of fresh fix-round sessions (#4363, forge.yaml lean.fix_model); default the reviewer model
170
180
  * @param {object} [o.builderRouting] forge.yaml lean.builder_routing/builder_mid_model (#4394): {mode: on (default)|off, midModel}
181
+ * @param {string} [o.followupsDefaultMilestone] forge.yaml followups.default_milestone (#4664): milestone of deferred follow-ups whose parent's milestone has shipped and has no next one
171
182
  * @param {string} [o.sequenceRegenerate] forge.yaml lean.sequence.regenerate (#4386): snapshot generator for a schema-changing renumbered migration
172
183
  * @param {object} [o.baseMerge] forge.yaml lean.base_merge_* (#4588): {unionGlobs, jsonGlobs, generatedGlobs, generatedCommand} for deterministic base-merge conflict resolution
173
184
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
@@ -221,13 +232,15 @@ export async function runCore(o) {
221
232
  let sessionId = null;
222
233
 
223
234
  const ev = (e) => appendEvent(dir, issue, e, { repo }); // #4432: the run log is keyed by repo + issue
235
+ const expTag = o.experiment ? { experiment: o.experiment.id, arm: assignArm(o.experiment, issue) } : {}; // #4629: same arm for the whole run
224
236
  const emit = async (rec) => { try { await emitTelemetry?.({ ...rec, issue }); } catch { /* never fails a run */ } };
225
237
  let ghCache = null; // #4507: the run's own gh HTTP cache (set once the run tmp dir exists)
226
- const ghc = (args) => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache);
238
+ const authState = { refreshed: false }; // #4662: one token refresh at a time, shared by every gh call of the run
239
+ const ghc = (args) => refreshAuthOnce(() => ghCacheRetry((env) => io.gh(args, env ? { env } : undefined), ghCache), { refresh: io.refreshAuth ?? runRefreshScript, state: authState });
227
240
  const sleepFn = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
228
241
  /** gh writes (comment, pr create, issue edit/close): retried for 5xx and secondary rate limits (#4114). */
229
242
  const ghw = (args) => retrying(() => ghc(args), { transient: GH_TRANSIENT_RE, attempts: 4, sleep: sleepFn, random });
230
- const git = (args) => io.git(args, { cwd: wt });
243
+ const git = (args, o = {}) => io.git(args, { cwd: wt, ...o });
231
244
 
232
245
  const builderTriggers = []; // #4362: implement-attempt triggers in order, for the lane's first-pass flag
233
246
  const impl = { cost: 0, models: new Set() }; // #4397: implement spend and reported models, for the verifier
@@ -238,7 +251,7 @@ export async function runCore(o) {
238
251
  const c = sessionCostDelta(sessionCosts, { resume, sessionId: r?.sessionId ?? null, cost: num(r?.costUsd) });
239
252
  const rec = { event: "PHASE_ATTEMPT_USAGE", phase, attempt: n, trigger, ...(restartOf ? { restart_of: restartOf } : {}), usage: r?.usage ?? null, duration_ms: num(r?.durationMs) ?? 0,
240
253
  cost_usd: num(r?.costUsd), cost_usd_delta: c.delta, cost_usd_session: c.session, model: r?.model ?? null, turns: num(r?.turns), ...(mode ? { mode } : {}),
241
- ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
254
+ ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name, ...expTag }; // #4410, #4629
242
255
  if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
243
256
  ev(rec); await emit(rec);
244
257
  modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
@@ -247,9 +260,9 @@ export async function runCore(o) {
247
260
  usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
248
261
  usage.cache += (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
249
262
  }
250
- const post = async (marker, body) => {
263
+ const post = async (marker, body, on = issue) => {
251
264
  const marks = (Array.isArray(marker) ? marker : [marker]).map((m) => `<!-- ${m} -->`).join("\n");
252
- const out = String(await ghw(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
265
+ const out = String(await ghw(["issue", "comment", String(on), "-R", repo, "--body", capComment(`${marks}\n${body}`)])).trim();
253
266
  return /https?:\/\/\S+/.exec(out)?.[0] ?? null;
254
267
  };
255
268
  /** Best effort; `true` when the transition succeeded (#4337: the decomposed close needs to know). */
@@ -271,12 +284,12 @@ export async function runCore(o) {
271
284
  let leaseState = null, leaseTimer = null;
272
285
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
273
286
  const renew = async () => { if (io.projector && leaseState && !finishing) { /* never re-take a lease a finish (or signal) released */ try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
274
- async function releaseLease(reason, code = null) {
287
+ async function releaseLease(reason, code = null, mergedTo = null) {
275
288
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
276
289
  if (!io.projector || !leaseState) return;
277
290
  const terminal = ["merged", "invalid", "needs-human", "engine-error"].includes(reason);
278
291
  // forge#4275: the typed outcome code and failure classes ride on FORGE:STATE, so the next run's context pack reads them typed.
279
- const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort() } : {};
292
+ const typed = terminal ? { terminalCode: code ?? null, failureClasses: [...failureClasses].sort(), ...(reason === "merged" && mergedTo ? { mergedTo } : {}) } : {}; // #4711: where the change landed
280
293
  try { await writeLease(null, terminal ? { terminal: true, terminalReason: reason, ...typed } : {}); } catch { /* best effort */ }
281
294
  }
282
295
 
@@ -293,6 +306,7 @@ export async function runCore(o) {
293
306
  let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
294
307
  let pitMatched = null; // #4375: pitfalls in the pack that match the issue's files; null when no pack was compiled
295
308
  let factsSeen = []; // #4401: trusted comment bodies on the issue, so an identical FORGE:FACTS is never posted twice
309
+ let guardAddedMs = 0; // #4705 (R12): wall time the guard gate added, summed over the lane, carried on RUN_TERMINAL
296
310
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
297
311
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
298
312
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
@@ -417,7 +431,7 @@ export async function runCore(o) {
417
431
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
418
432
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
419
433
  conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
420
- context_pack: ctxCfg.mode, ...ctxAb, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
434
+ context_pack: ctxCfg.mode, ...ctxAb, ...expTag, explore: exploreCfg.mode, wall_ms: now() - t0, ...(guardAddedMs ? { guard_added_ms: guardAddedMs } : {}), model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
421
435
  ...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
422
436
  ev(term);
423
437
  await emit(term);
@@ -442,7 +456,7 @@ export async function runCore(o) {
442
456
  else { T.close = ["left open", "label needs-human"]; T.anomalies.push(`needs-human: ${String(detail).split("\n")[0].slice(0, 300)}`); }
443
457
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
444
458
  }
445
- await releaseLease(reason, code);
459
+ await releaseLease(reason, code, extra.mergedTo ?? null);
446
460
  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, model_ms: modelMs,
447
461
  light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
448
462
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
@@ -578,8 +592,15 @@ export async function runCore(o) {
578
592
  }
579
593
 
580
594
  /** git push with transient retry (#4114): 4 attempts; permission/protected-branch rejections fail at once. */
595
+ /** #4651: the commits a push would add are signed off with the lane identity first (placeholder or unsigned ones are rewritten; pushed ones never). */
596
+ const signOffUnpushed = async () => {
597
+ try {
598
+ const r = await ensureSignedOff({ git, lane, branch });
599
+ if (r.rewritten) { T.decisions.push(`Rewrote ${r.rewritten} unpushed commit(s) with the lane identity and a sign-off (placeholder author or no Signed-off-by, #4651)`); ev({ event: "DCO_SIGNOFF_REWRITE", count: r.rewritten }); }
600
+ } catch (e) { throw Object.assign(e, { laneCode: OUTCOME_CODES.CI_DCO }); }
601
+ };
581
602
  const gitPush = async (args) => {
582
- if (!args.includes("--delete")) await untrackToolState({ git, base, ev }); // #4590: never push .forge-lean/ or tool caches this branch added
603
+ if (!args.includes("--delete")) { await untrackToolState({ git, base, ev }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
583
604
  try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
584
605
  catch (e) {
585
606
  if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
@@ -663,6 +684,13 @@ export async function runCore(o) {
663
684
  p = restart() + await contextRestartNote(); resume = null; opts.restartOf ??= opts.trigger ?? null; opts.trigger = "context_restart";
664
685
  continue;
665
686
  }
687
+ if (e?.code === "CLI_LANE_LIMIT") { // #4690: the lane hit its own scope limit: a rerun hits it again, so typed needs-human (env.lane_limit), never engine-error
688
+ ev({ event: "CLI_LANE_LIMIT", limit: e.limit ?? null, unit: e.unit ?? null });
689
+ await checkpoint(`scope ${e.limit} limit`);
690
+ e.laneCode = OUTCOME_CODES.ENV_LANE_LIMIT;
691
+ e.message = `The lane hit its own ${e.limit === "memory" ? "MemoryMax (memory)" : "TasksMax (process count)"} scope limit and was stopped; work checkpointed to ${branch}. Raise engine.lane_limits.${e.limit === "memory" ? "memory_max" : "tasks_max"} in forge.yaml if the work is legitimate, or find the runaway step.`;
692
+ throw e;
693
+ }
666
694
  const hostKilled = isExternalKill(e); // #4518: SIGTERM/143 we did not send restarts like a timeout, and never ends engine-error
667
695
  if (!(hostKilled || e?.code === "CLI_IDLE" || e?.code === "CLI_TIMEOUT")) throw e;
668
696
  const idle = e.code === "CLI_IDLE";
@@ -775,6 +803,7 @@ export async function runCore(o) {
775
803
  const b = classifyBlock(r, issue);
776
804
  if (b.kind !== "dependency") return b;
777
805
  const open = [], blockers = [];
806
+ const lanePrs = lane ? laneMergedPrs(lane, io) : null;
778
807
  for (const d of b.issues) {
779
808
  let v;
780
809
  try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
@@ -794,6 +823,9 @@ export async function runCore(o) {
794
823
  }
795
824
  for (const [n, rec] of recs) {
796
825
  if (String(rec?.state).toUpperCase() !== "OPEN" || n === Number(issue) || open.includes(n)) continue;
826
+ let full = null; // #4711: the REST record carries the body (FORGE:STATE mergedTo) and the author association
827
+ if (lane) try { full = JSON.parse(String(await io.gh(["api", `repos/{owner}/{repo}/issues/${n}`])) || "null"); } catch { /* the PR-ancestry rule still applies */ }
828
+ if (lane && await dependencyOnLane(n, lane, lanePrs ? await lanePrs : null, io, full)) continue; // #4686: its change is on the lane: neither open nor a needs-human blocker
797
829
  open.push(n);
798
830
  if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
799
831
  }
@@ -810,6 +842,10 @@ export async function runCore(o) {
810
842
  const caps = hostCapabilityBlock(s, o.hostCaps);
811
843
  if (caps.length) return await needsHuman(`Remote sandbox lacks host capability ${caps.join(", ")} (Builder is blocked (credentials)): ${s}\n\nThe dispatching host has it; the lane is recovered locally.`, OUTCOME_CODES.ENV_CAPABILITY_REMOTE);
812
844
  }
845
+ if (b.kind === "human" && b.why === "credentials" && o.placement?.where === "remote" && missingToolBlock(s)) {
846
+ // #4685: a tool the sandbox lacks (docker, promtool, a Python module) is an environment gap the host recovers, never a credential.
847
+ return await needsHuman(`Remote sandbox lacks a validate tool (Builder is blocked (credentials)): ${s}\n\nNot a credential: the lane is recovered where the tool exists.`, OUTCOME_CODES.ENV_TOOL_MISSING);
848
+ }
813
849
  if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
814
850
  if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
815
851
  if (b.kind === "dependency") {
@@ -860,7 +896,9 @@ export async function runCore(o) {
860
896
  }
861
897
  // ---- lane (#4125): worked out from the issue before any lease, comment or worktree; fail closed ----
862
898
  {
863
- const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
899
+ const laneIo = { ...io, gh: (a, opts) => refreshAuthOnce(() => io.gh(a, opts), { refresh: io.refreshAuth ?? runRefreshScript, state: authState }) }; // #4662
900
+ const r = await resolveLane({ issue, repo, io: laneIo, cwd, branches, requested: requestedLane, override: laneOverride });
901
+ if (!r.ok && GH_AUTH_RE.test(r.detail ?? "")) return { terminalReason: "deferred", code: OUTCOME_CODES.GH_AUTH, detail: r.detail, retryInSeconds: GH_AUTH_RETRY_SECONDS }; // #4662: a dead token is not a lane problem
864
902
  if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
865
903
  lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
866
904
  if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
@@ -873,8 +911,13 @@ export async function runCore(o) {
873
911
  // ---- admission: Depends-on refusal, then lease ----
874
912
  {
875
913
  let dep = null;
876
- try { dep = await openDependency(issue, io); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
914
+ try { dep = await openDependency(issue, io, { lane }); } catch (e) { dep = { unreadable: true, detail: `issue ${issue} **Depends on** check failed: ${e?.message || e}` }; }
877
915
  if (dep?.unreadable) return { terminalReason: "dependency-unreadable", code: OUTCOME_CODES.DEPENDENCY_UNREADABLE, detail: dep.detail };
916
+ if (dep?.satisfied?.length) { // #4686: open on GitHub, but its PR is already on this lane
917
+ const names = dep.satisfied.map((x) => `#${x.issue} (PR #${x.pr}, merge commit ${x.sha.slice(0, 12)} on origin/${lane})`).join(", ");
918
+ T.decisions.push(`Dependency satisfied by lane merge: ${names}`);
919
+ try { await post("FORGE:DEPENDENCY", `## Dependency satisfied\n${names} merged into ${lane}; the issue stays open until promotion, so the **Depends on** edge is met.`); } catch { /* best effort */ }
920
+ }
878
921
  if (dep?.open?.length) {
879
922
  return { terminalReason: "dependency-open", code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: dep.open,
880
923
  detail: `issue ${issue} **Depends on** ${dep.open.map((n) => `#${n}`).join(", ")} which is still open; re-run once it is closed` };
@@ -910,9 +953,13 @@ export async function runCore(o) {
910
953
  }
911
954
  ghCache = io.ghCache?.(runTmp) ?? null;
912
955
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
956
+ await emit({ event: "RUN_START", run: `core_${issue}_${t0}`, lane, path: "core" }); // #4630: lifecycle event stream
913
957
  try {
914
958
  iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments,labels,updatedAt"]));
915
- } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
959
+ } catch (e) {
960
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode); // #4662
961
+ return finish("engine-error", `cannot read issue: ${e.message}`);
962
+ }
916
963
  const priorState = stateOf(iss.body); // #4400: the last run's end (the lease write keeps its terminal fields), read before this run ends
917
964
  if (String(iss.state).toUpperCase() === "CLOSED") {
918
965
  // #4337: a closed decomposed tracker is not "already done": its dependents wait on its sub-issues (the batch re-points
@@ -1205,9 +1252,9 @@ export async function runCore(o) {
1205
1252
  async function track(reqs, why) {
1206
1253
  const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
1207
1254
  if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
1208
- const items = followupItems(reqs);
1255
+ const items = followupItems(reqs, { now: now() });
1209
1256
  let filed;
1210
- try { filed = await io.fileFollowups({ parent: issue, repo, items }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1257
+ try { filed = await io.fileFollowups({ parent: issue, repo, items, milestone, defaultMilestone: o.followupsDefaultMilestone ?? null, lane }); } catch (e) { throw fail(`filing the follow-up issue failed: ${String(e?.message ?? e).slice(0, 200)}`); }
1211
1258
  for (const [i, r] of reqs.entries()) {
1212
1259
  const n = Number(filed?.[items[i].key]);
1213
1260
  if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
@@ -1216,7 +1263,8 @@ export async function runCore(o) {
1216
1263
  if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
1217
1264
  r.tracked = `#${n}`;
1218
1265
  }
1219
- T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
1266
+ const buckets = Object.entries(bucketTally(reqs)).map(([k, c]) => `${k}:${c}`).join(","); // #4724
1267
+ T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why}; buckets ${buckets})`);
1220
1268
  }
1221
1269
  /**
1222
1270
  * #4328: probe the host for the tools and modules a contract's validate commands need; #4393: then flag `$NAME`s the lane
@@ -1325,6 +1373,8 @@ export async function runCore(o) {
1325
1373
  sessionId = null;
1326
1374
  return { ...r, contract };
1327
1375
  }
1376
+ /** #4694: the engine appends guard-derived H preserve rows from the local index (one file read; the same contract when none apply). */
1377
+ const historyRows = (c) => { const w = withHistoryRows(c, { repo, issue }); const n = (w.preserve ?? []).length - (c.preserve ?? []).length; if (w !== c && n > 0) T.decisions.push(`History preserve rows: ${w.preserve.filter((p) => p.source === "guard").map((p) => p.id).join(", ")} from guards (#4694)`); return w; };
1328
1378
  let frozen = null, hash = null, planned = false, files = [], split = null;
1329
1379
  const pendingPits = []; // #4375: rework rounds whose gate has not passed again yet
1330
1380
  async function notePitfall(trigger, text) {
@@ -1421,11 +1471,12 @@ export async function runCore(o) {
1421
1471
  apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
1422
1472
  stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
1423
1473
  if (!lr.upgrade) {
1424
- lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
1474
+ lightDone = true; light = true; frozen = historyRows(lr.contract); hash = contractHash(contractJson(frozen));
1425
1475
  let body = renderContract(frozen, hash, { scope: scopeHash });
1426
1476
  if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
1427
1477
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
1428
1478
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
1479
+ await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
1429
1480
  ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
1430
1481
  T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
1431
1482
  try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
@@ -1500,13 +1551,17 @@ export async function runCore(o) {
1500
1551
  }
1501
1552
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
1502
1553
  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 } : {});
1503
- frozen = contract;
1554
+ frozen = historyRows(contract); // #4694 guards
1555
+ frozen = withDecisionRows(frozen, readDecisionIndex(repo)); // #4695: anchored decisions on the deliverable paths; one index read
1556
+ const hRows = (frozen.preserve ?? []).filter((p) => p.source === "decision");
1557
+ if (hRows.length) T.decisions.push(`Anchored decisions bind this lane: ${hRows.map((p) => `${p.id} (${p.decision})`).join(", ")}`);
1504
1558
  hash = contractHash(contractJson(frozen));
1505
1559
  let body = renderContract(frozen, hash, { scope: scopeHash });
1506
1560
  if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash }); // the JSON must survive the comment cap
1507
1561
  await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
1508
1562
  const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
1509
1563
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
1564
+ await emit({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
1510
1565
  T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
1511
1566
  } else {
1512
1567
  T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
@@ -1748,6 +1803,28 @@ export async function runCore(o) {
1748
1803
  else await gatePassed("sequence");
1749
1804
  }
1750
1805
  await migrationGate("hand-off");
1806
+ /** #4596: keys added to a non-en locale catalog as English copies: one translate round, then an anomaly. True when a fix round ran. */
1807
+ async function i18nGate(trigger) {
1808
+ if (!io.englishCopies || isSplit) return false;
1809
+ const found = async () => (await io.englishCopies({ cwd: wt, base }).catch(() => null)) ?? [];
1810
+ let cp = await found();
1811
+ const brief = () => cp.slice(0, 5).map((d) => `${d.path}:${d.key}`).join("; ");
1812
+ if (cp.length && sessionId) {
1813
+ ev({ event: "I18N_ENGLISH_COPIES", trigger, count: cp.length });
1814
+ T.decisions.push(`English copies in locale catalogs (${trigger}): ${brief()}; builder resumed once`);
1815
+ await fixRound("duplicate", i18nCopiesPrompt(cp, vcfg.mode), [], "duplicate", cp.map((d) => `english copy \`${d.key}\` in ${d.path}`).join("\n"));
1816
+ result = (await readResult()) ?? result;
1817
+ files = await changedFiles();
1818
+ const before = cp;
1819
+ cp = await found();
1820
+ if (cp.length) T.anomalies.push(`English copies remain in locale catalogs (${trigger}): ${brief()}`);
1821
+ else await gatePassed("i18n");
1822
+ return before.length > 0;
1823
+ }
1824
+ if (cp.length) T.anomalies.push(`English copies in locale catalogs (${trigger}): ${brief()}`);
1825
+ return false;
1826
+ }
1827
+ await i18nGate("hand-off");
1751
1828
  /** #4406: verify.post_merge (else the scoped typecheck) on the merged tree before the base-sync push; fix rounds up to verify.max_rounds. */
1752
1829
  async function postMergeGate(trigger) {
1753
1830
  const commands = io.runCommand && !isSplit ? postMergeCommands({ configured: vcfg.postMerge, scoped: (await io.postMergeChecks?.({ cwd: wt, base }).catch(() => [])) ?? [], mode: vcfg.mode }) : [];
@@ -1786,6 +1863,8 @@ export async function runCore(o) {
1786
1863
  async function ciVerify(trigger) {
1787
1864
  let fixes = 0;
1788
1865
  for (;;) {
1866
+ await untrackToolState({ git, base, ev }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
1867
+ await signOffUnpushed(); // #4651: likewise the sign-off rewrite
1789
1868
  const head = String(await git(["rev-parse", "HEAD"])).trim();
1790
1869
  if (head === ciVerifiedHead || ciFellBack) return { fixes };
1791
1870
  files = await changedFiles();
@@ -1875,16 +1954,21 @@ export async function runCore(o) {
1875
1954
  let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null, degraded = null, triggerSkipped = false, selfReviewCompared = false;
1876
1955
  const TRIGGER_SKIP = "skipped (review.trigger enforce: no risk trigger)";
1877
1956
  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).";
1957
+ let pendingDecisions = [];
1958
+ let baseTree = null; // #4695: the base file list, read once and only when a decision amendment needs it
1878
1959
  let genPaths = null; // forge#4519: maintenance.generated_paths from hotspots.sh (plan facts; one config-only call without them, never on light lanes)
1879
1960
  async function amend() {
1880
1961
  genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
1881
- const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths });
1962
+ const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
1963
+ baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\n").filter(Boolean) : null;
1964
+ const a = applyAmendments(frozen, result?.amendments, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null });
1882
1965
  const sig = JSON.stringify(a.applied);
1883
1966
  if (a.applied.length && sig !== lastAmend) {
1884
1967
  lastAmend = sig;
1885
1968
  await post("FORGE:AMENDMENT", renderAmendments(a.applied));
1886
1969
  T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
1887
1970
  }
1971
+ pendingDecisions = a.decisions; // #4737: persisted only once the PR merges
1888
1972
  effective = a.contract;
1889
1973
  return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
1890
1974
  }
@@ -1894,7 +1978,7 @@ export async function runCore(o) {
1894
1978
  const diff = String(await git(["diff", `${base}...HEAD`]));
1895
1979
  const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
1896
1980
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
1897
- const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE };
1981
+ const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), repoFiles: listed, ignore: IGNORE, e2e: { checks: vcfg.e2e?.checks ?? [], isSpec: (x) => e2eSpecs([x], vcfg.e2e).length > 0 } };
1898
1982
  let d = null, first = null;
1899
1983
  for (let t = 0; t < 3; t++) {
1900
1984
  // #4327: a third run only when the repaired reply is incomplete solely by evidence paths that did not resolve.
@@ -1979,6 +2063,23 @@ export async function runCore(o) {
1979
2063
  if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
1980
2064
  return out;
1981
2065
  }
2066
+ /** #4693: select the guards this diff intersects and rerun their base test files; zero cost when none is selected. Best effort: an error skips the gate. */
2067
+ let guardSel = [], guardNoted = ""; let guardEv = null; // #4705: this pass's guard_run event, emitted by the caller once the added wall time is known
2068
+ async function guardGate(files) {
2069
+ let sel = [], r = null; guardEv = null;
2070
+ try {
2071
+ const guards = await (o.loadGuards ?? loadGuards)({ repo, gh: (a) => io.gh(a) });
2072
+ const picked = guards.length ? await selectGuards({ guards, files, git: (a) => git(a), base }) : { selected: [], mergeBase: null };
2073
+ sel = picked.selected; guardSel = sel;
2074
+ if (!sel.length) return [];
2075
+ r = await runGuards({ selected: sel, mergeBase: picked.mergeBase, contract: effective, git: (a) => git(a), runCommand: (a) => io.runCommand(a), cwd: wt, env: laneEnv(), parallelism: Number(process.env.FORGE_HOST_PARALLELISM) || 2 });
2076
+ } catch (e) { T.anomalies.push(`Guard gate skipped: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}`); guardEv = { event: "guard_run", selected: sel.map((x) => x.guard.issue), ran: 0, broken: 0, skipped: 0, wall_ms: 0, error: true }; return []; }
2077
+ const note = `Guard gate selected ${sel.map((x) => `#${x.guard.issue} (${x.reason})`).join("; ")}${r.skipped.length ? `; skipped: ${r.skipped.join("; ")}` : ""}${r.failures.length && shadow ? "; shadow: would block" : ""}`;
2078
+ if (note !== guardNoted) { guardNoted = note; T.decisions.push(note); }
2079
+ guardEv = { event: "guard_run", selected: sel.map((x) => x.guard.issue), ran: r.runs.length, broken: r.failures.length, skipped: r.skipped.length, wall_ms: r.ms };
2080
+ for (const f of r.failures) ev({ event: "guard_broken", guard: f.guard, test: f.test });
2081
+ return r.failures;
2082
+ }
1982
2083
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
1983
2084
  async function gate() {
1984
2085
  await refreshIssue("conformance");
@@ -1996,9 +2097,16 @@ export async function runCore(o) {
1996
2097
  const gateHead = String(await git(["rev-parse", "HEAD"])).trim();
1997
2098
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
1998
2099
  const hotspots = await hotspotsOf({ base, paths: files }); // #4502
1999
- const validateRuns = await rerunFailedValidates(effective);
2100
+ const gateT0 = now(); let validateMs = 0;
2101
+ const [validateRuns, guardFailures] = await Promise.all([rerunFailedValidates(effective).finally(() => { validateMs = now() - gateT0; }), guardGate(files)]); // #4693: guards run beside the validate rerun
2102
+ // #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
2103
+ const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
2104
+ guardAddedMs += guardRunMs;
2105
+ if (guardEv) ev({ ...guardEv, added_wall_ms: guardRunMs });
2000
2106
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
2001
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
2107
+ // #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
2108
+ const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
2109
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures });
2002
2110
  // #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
2003
2111
  if (pc.superseded?.length) {
2004
2112
  ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
@@ -2170,6 +2278,7 @@ export async function runCore(o) {
2170
2278
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
2171
2279
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
2172
2280
  ev({ event: "PR_OPENED", pr });
2281
+ await emit({ event: "PR_OPENED", pr });
2173
2282
  if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
2174
2283
  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 })]); }
2175
2284
  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; }
@@ -2290,6 +2399,7 @@ export async function runCore(o) {
2290
2399
  if (pm.fixed) how += "; builder fixed post-merge check failures";
2291
2400
  dupFixed = await duplicateGate(`base-sync ${syncs}`);
2292
2401
  if (dupFixed) how += "; builder resolved duplicate exports";
2402
+ if (await i18nGate(`base-sync ${syncs}`)) how += "; builder translated English-copy keys";
2293
2403
  }
2294
2404
  const after = await gitOut(["rev-parse", "HEAD"]);
2295
2405
  if (soft && clean && after === before) { syncs--; sameBaseSyncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
@@ -2358,12 +2468,25 @@ export async function runCore(o) {
2358
2468
  T.decisions.push(`Required check(s) failed on PR #${pr} (${jobs.join(", ")}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
2359
2469
  ev({ event: "CI_FIX_ROUND", trigger: "merge", round: ciRounds, jobs, pr });
2360
2470
  const before = await gitOut(["rev-parse", "HEAD"]);
2471
+ const priorRows = Array.isArray(result?.validation) ? result.validation : [];
2361
2472
  await fixRound("ci", ciFailurePrompt(failures, vcfg.mode), [], "merge_ci", failureLog(failures));
2362
2473
  await upsertStage("implementation");
2363
2474
  result = (await readResult()) ?? result;
2364
2475
  if ((await gitOut(["rev-parse", "HEAD"])) === before) {
2365
2476
  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);
2366
2477
  }
2478
+ // #4708: the fix session may rewrite result.json without the validate rows recorded earlier: carry those forward (marked) onto disk, which gate() re-reads
2479
+ const have = new Set((Array.isArray(result?.validation) ? result.validation : []).map((x) => x?.id)), carried = priorRows.filter((x) => x?.id && !have.has(x.id));
2480
+ if (carried.length && io.fs.write) {
2481
+ result = { ...result, validation: [...(Array.isArray(result?.validation) ? result.validation : []), ...carried.map((x) => ({ ...x, carried: true }))] };
2482
+ try { await io.fs.write(`${wt}/${RESULT_FILE}`, `${JSON.stringify(result)}\n`); } catch { /* the gate reads what is on disk */ }
2483
+ T.decisions.push(`Validate result(s) ${carried.map((x) => x.id).join(", ")} carried forward across the CI fix round`);
2484
+ }
2485
+ // #4708: the fix head is CI-verified before its review, as in the base-sync retry (#4527), so validates left to CI are covered
2486
+ if (remoteVerify) { const v = await ciVerify("merge"); if (v.done) return v.done; }
2487
+ const rows = Array.isArray(result?.validation) ? result.validation : [], headNow = await gitOut(["rev-parse", "HEAD"]);
2488
+ const lost = (effective.validate ?? []).filter((v) => !rows.some((x) => x?.id === v.id) && !(remoteVerify && ciVerifiedHead === headNow)).map((v) => v.id);
2489
+ if (lost.length) return await needsHuman(`Validate result(s) ${lost.join(", ")} missing after a CI fix round on PR #${pr}: the earlier results were lost and none could be carried forward; nothing merged.`, OUTCOME_CODES.ENGINE_VALIDATE_RESULT_LOST);
2367
2490
  const rv = await doReview(); // never merge an unreviewed head
2368
2491
  if (!rv) return await needsHuman("Reviewer returned no parseable verdict after a CI fix round.", OUTCOME_CODES.REVIEW_NO_VERDICT);
2369
2492
  if (rv.env) return await envNeedsHuman(rv.env);
@@ -2405,7 +2528,7 @@ export async function runCore(o) {
2405
2528
  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 })),
2406
2529
  removed_edges: pairs.filter((x) => x?.files_removed > 0).map((x) => ({ ...edge(x), files: x.files_removed })),
2407
2530
  top_growth,
2408
- decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })),
2531
+ decisions: (Array.isArray(effective?.decisions) ? effective.decisions : []).filter((k) => k?.id).map((k) => ({ id: k.id, source: k.source ?? null })).concat((effective?.preserve ?? []).filter((p) => /^H\d+$/.test(p?.id) && p.source === "guard").map((p) => ({ id: p.id, source: p.source }))),
2409
2532
  };
2410
2533
  T.structure = s;
2411
2534
  const rec = { event: "STRUCTURE_DELTA", phase: "merge", pr, ...(milestone ? { milestone } : {}), ...s,
@@ -2442,6 +2565,7 @@ export async function runCore(o) {
2442
2565
  }
2443
2566
 
2444
2567
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
2568
+ let lastStates = null; // the last required-check poll, reused by the DCO gate (#4651)
2445
2569
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
2446
2570
  for (;;) {
2447
2571
  const c0 = await prConflicting();
@@ -2464,6 +2588,7 @@ export async function runCore(o) {
2464
2588
  if (w.rc === 1) return await needsHuman(`Remote CI failed on PR #${pr}:\n${String(w.text).slice(-2000)}`, OUTCOME_CODES.CI_RED);
2465
2589
  } else {
2466
2590
  try { states = (await io.checkStates(pr)) ?? []; } catch { states = []; }
2591
+ lastStates = states;
2467
2592
  const check = (n) => states.find((x) => x.name === n && current(x));
2468
2593
  const st = (n) => check(n)?.state ?? "pending";
2469
2594
  const failed = required.filter((n) => st(n) === "failure");
@@ -2524,6 +2649,10 @@ export async function runCore(o) {
2524
2649
  }
2525
2650
  }
2526
2651
  }
2652
+ // #4651: a failed DCO check blocks the merge even when it is not a required check on the lane branch (it would block the release PR)
2653
+ let dcoFailed = [];
2654
+ try { dcoFailed = ((lastStates ?? await io.checkStates?.(pr)) ?? []).filter((x) => /\bdco\b/i.test(String(x?.name ?? "")) && x.state === "failure" && (!x.head || !localHead || String(x.head).startsWith(localHead) || localHead.startsWith(String(x.head)))); } catch { dcoFailed = []; }
2655
+ if (dcoFailed.length) return await needsHuman(`DCO check failed on PR #${pr} (${[...new Set(dcoFailed.map((x) => x.name))].join(", ")}); a commit lacks a sign-off or has a placeholder author, which would block the release. Nothing merged.`, OUTCOME_CODES.CI_DCO);
2527
2656
  T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
2528
2657
  laneGuard(resumed?.base ?? lane, "merge");
2529
2658
  try {
@@ -2584,15 +2713,25 @@ export async function runCore(o) {
2584
2713
  } 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)}`); }
2585
2714
  }
2586
2715
  await label("merged");
2716
+ try { // #4695/#4737: FORGE:DECISION records and supersession are persisted only after the merge
2717
+ const rec = await recordDecisions(pendingDecisions, { issue, repo, post });
2718
+ if (rec.length) T.decisions.push(`FORGE:DECISION: ${rec.map((d) => d.id).join(", ")}`);
2719
+ } catch (e) { T.anomalies.push(`decision records not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2720
+ for (const sp of supersededIds(effective, guardSel)) { // #4693: the reviewer accepted the supersedes amendment; tell the guarded issue
2721
+ try { await ghw(["issue", "comment", sp.guard.slice(1), "-R", repo, "--body", supersessionComment({ guard: sp.guard, pr, why: sp.why })]); ev({ event: "supersedes_accepted", guard: sp.guard, pr }); T.decisions.push(`Guard ${sp.guard} superseded: ${sp.why}`); } catch (e) { T.anomalies.push(`supersession of ${sp.guard} not posted: ${String(e?.message ?? e).slice(0, 160)}`); }
2722
+ }
2723
+ try { const g = await (o.guardAfterMerge ?? guardAfterMerge)({ io, repo, issue, pr, cwd: wt, base }); if (g?.posted) T.decisions.push(`FORGE:GUARD posted: ${g.guard.tests.length} test file(s), ${g.guard.hunks.length} hunk(s)`); else if (g?.skipped) ev({ event: "GUARD_SKIPPED", reason: g.reason }); } catch { /* the guard is best effort */ }
2587
2724
  // forge#4161: the merge-time structure record and the milestone ride on the lane result for orchestrate's drift fold.
2588
2725
  await gatePassed("merge_ci", "ci_red"); // #4375: merged means the required checks are green after a CI fix round
2589
- return await finish("merged", `PR #${pr}`, { pr, ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
2726
+ let mergedTo = null; // #4711: FORGE:STATE records the lane and merge commit, so a dependent's check needs no PR search
2727
+ if (lane) try { const sha = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeCommit"])) || "{}").mergeCommit?.oid; if (typeof sha === "string" && sha) mergedTo = { lane, pr, sha }; } catch { /* the PR-ancestry rule still applies */ }
2728
+ return await finish("merged", `PR #${pr}`, { pr, ...(mergedTo ? { mergedTo } : {}), ...(partial ? { close: "partial" } : {}), structure: T.structure ?? null, ...(milestone ? { milestone } : {}), ...(lane ? { lane } : {}) });
2590
2729
  } catch (e) {
2591
2730
  if (e?.deferUsageLimit) {
2592
2731
  const when = e.resetAt || (Number.isFinite(e.resetAtEpochMs) ? new Date(e.resetAtEpochMs).toISOString() : "unknown");
2593
2732
  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);
2594
2733
  }
2595
- if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
2734
+ if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode, ...(e.retryInSeconds ? { retryInSeconds: e.retryInSeconds } : {}) }, e.deferCode);
2596
2735
  // #4505: a turn overrun is mechanical: a non-human end the batch recovers, needs-human only once a budget recovery failed too
2597
2736
  if (e?.laneCode === OUTCOME_CODES.BUDGET_EXCEEDED && !(recover?.final && recover.code === OUTCOME_CODES.BUDGET_EXCEEDED)) {
2598
2737
  return finish("budget-exceeded", e.message, { code: OUTCOME_CODES.BUDGET_EXCEEDED, branch, ...(recover ? { recovery: true } : {}) }, OUTCOME_CODES.BUDGET_EXCEEDED);