forgedock 1.25.1 → 1.27.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 (43) hide show
  1. package/bin/engine/outcome-codes.mjs +15 -0
  2. package/bin/engine/phases.mjs +38 -2
  3. package/bin/engine/telemetry-seam.mjs +6 -0
  4. package/bin/engine/telemetry-seam.test.mjs +4 -0
  5. package/bin/engine-cli.mjs +85 -20
  6. package/bin/engine.mjs +5 -1
  7. package/bin/forgedock.mjs +94 -30
  8. package/bin/init-detect.mjs +68 -3
  9. package/bin/init-enrich.mjs +3 -0
  10. package/bin/init-repo.mjs +213 -0
  11. package/bin/journey.mjs +278 -43
  12. package/bin/lean/batch.mjs +2 -1
  13. package/bin/lean/batch.test.mjs +49 -1
  14. package/bin/lean/conformance.e2e.test.mjs +232 -0
  15. package/bin/lean/conformance.mjs +304 -0
  16. package/bin/lean/conformance.test.mjs +192 -0
  17. package/bin/lean/effects.mjs +119 -11
  18. package/bin/lean/lean.mjs +533 -70
  19. package/bin/lean/lean.test.mjs +1065 -112
  20. package/bin/lean/ledger.mjs +67 -0
  21. package/bin/lean/ledger.test.mjs +65 -0
  22. package/bin/lean/orchestrate.mjs +9 -0
  23. package/bin/lean/prompts.mjs +94 -57
  24. package/bin/lean/render.mjs +52 -7
  25. package/bin/lean/verify.mjs +113 -0
  26. package/bin/lean/verify.test.mjs +95 -0
  27. package/commands/adopt.md +3 -0
  28. package/commands/audit.md +3 -8
  29. package/commands/forgedock-init.md +118 -6
  30. package/commands/review-pr.md +2 -2
  31. package/docs/CONFIG.md +53 -3
  32. package/docs/OUTCOME-CODES.md +14 -0
  33. package/package.json +1 -1
  34. package/scripts/bootstrap-validate.mjs +287 -0
  35. package/scripts/check-devdocs-freshness.sh +120 -0
  36. package/scripts/check-devdocs-freshness.test.sh +120 -0
  37. package/scripts/ci-local/run.mjs +13 -2
  38. package/scripts/ci-local.test.sh +17 -2
  39. package/scripts/northstar-docs.test.sh +68 -0
  40. package/scripts/review-prepare.sh +11 -9
  41. package/scripts/review-prepare.test.sh +14 -0
  42. package/scripts/structure-delta.sh +150 -0
  43. package/scripts/structure-delta.test.sh +135 -0
package/bin/lean/lean.mjs CHANGED
@@ -10,17 +10,33 @@ 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 } from "../engine/phases.mjs";
13
+ import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
14
+ import { createHash } from "node:crypto";
15
+ import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking, reviewSkeleton, unnamedEdges, structureNote } from "./conformance.mjs";
14
16
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
17
  import { resolveLane, isDefaultLane } from "./lane.mjs";
18
+ import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
16
19
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
17
- import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
18
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, parseVerdict, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
20
+ import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
21
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, ciFailurePrompt, ciUnavailablePrompt, syncChecks } from "./prompts.mjs";
22
+ import { parseVerify, runCiVerify, VERIFY_MODES } from "./verify.mjs";
19
23
 
20
24
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
25
+ /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
26
+ export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
27
+ /** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
28
+ const SHADOW_BLOCKS = [OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, OUTCOME_CODES.CONFORMANCE_REGRESSION];
29
+ /**
30
+ * Precheck failures that block in shadow mode too (#4160): the builder's own validate command failed, or it reported a
31
+ * contracted requirement as not done. These are the precheck's form of "a requirement not done". Missing validate results
32
+ * (often CI-only steps), scope creep and amendment problems stay recorded-only in shadow.
33
+ */
34
+ const SHADOW_PRE_BLOCKS = ["validate_failed", "requirement_unimplemented"];
21
35
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
22
36
  const num = (v) => (Number.isFinite(v) ? v : null);
23
37
  export const MAX_SUBISSUES = 8;
38
+ /** 12-hex sha256 of the canonical contract JSON text (renderContract embeds the same text). */
39
+ export const contractHash = (text) => createHash("sha256").update(String(text)).digest("hex").slice(0, 12);
24
40
 
25
41
  /**
26
42
  * Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
@@ -47,6 +63,15 @@ export function classifyBlock(result, issue) {
47
63
  return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
48
64
  }
49
65
 
66
+ /**
67
+ * Never a branch-deletion target (#4179): anything but the issue's own `feat/issue-<n>-lean`, and never main, master,
68
+ * staging, the lane, forge.yaml's default/staging branch, `milestone/*` or a default branch of the repo.
69
+ */
70
+ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, defaults = [] } = {}) {
71
+ if (typeof b !== "string" || b !== `feat/issue-${issue}-lean`) return true;
72
+ return ["main", "master", "staging", lane, branches?.default, branches?.staging].includes(b) || /^milestone\//.test(b) || isDefaultLane(b, defaults);
73
+ }
74
+
50
75
  /**
51
76
  * @param {object} o
52
77
  * @param {number} o.issue
@@ -60,13 +85,18 @@ export function classifyBlock(result, issue) {
60
85
  * @param {string} [o.builderModel] default: CLI default
61
86
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
62
87
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
88
+ * @param {"shadow"|"enforce"} [o.conformance] conformance gate mode (default shadow)
89
+ * @param {object} [o.verify] forge.yaml verify (verify.mjs parseVerify): mode local (default) | ci | hybrid (#4167)
63
90
  */
64
91
  export async function runCore(o) {
65
92
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
66
- ciMaxMinutes = 360, 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;
93
+ ciMaxMinutes = 360, conformance = "shadow", mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
67
94
  const now = io.now ?? (() => Date.now());
95
+ const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
96
+ if (!VERIFY_MODES.includes(vcfg.mode)) vcfg.mode = "local";
97
+ const remoteVerify = vcfg.mode !== "local";
68
98
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
69
- let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
99
+ let lane = null, base = null, defaults = [], milestone = null; // set by lane resolution, before the lease
70
100
  const branch = `feat/issue-${issue}-lean`;
71
101
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
72
102
  const t0 = now();
@@ -112,7 +142,7 @@ export async function runCore(o) {
112
142
  const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
113
143
  let leaseState = null, leaseTimer = null;
114
144
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
115
- const renew = async () => { if (io.projector && leaseState) { try { await writeLease(leaseObj()); } catch { /* best effort */ } } };
145
+ 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 */ } } };
116
146
  async function releaseLease(reason) {
117
147
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
118
148
  if (!io.projector || !leaseState) return;
@@ -122,11 +152,91 @@ export async function runCore(o) {
122
152
 
123
153
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
124
154
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
125
- let done = null, wtCreated = false;
155
+ let done = null, finishing = null, wtCreated = false, salvagedRef = null;
126
156
  const runId = `${issue}-${t0}`;
127
157
  let runTmp = null;
128
- async function finish(reason, detail = "", extra = {}, codeOverride = null) {
129
- if (done) return done; // idempotent: exactly one RUN_TERMINAL and one telemetry emit
158
+ // ---- lane resource ledger (#4179): every owned resource is registered before it is created, released after its teardown ----
159
+ let ledger = null, deleteLocalBranch = false;
160
+ const ledgerDo = (fn) => { if (!ledger) return; try { fn(ledger); } catch (e) { ev({ event: "LEDGER_ERROR", error: String(e?.message ?? e).slice(0, 300) }); } };
161
+ const protectedBranch = (b) => isProtectedLaneBranch(b, { issue, lane, branches, defaults });
162
+ /** Teardown from the ledger (#4179), on every exit path: builder pids, checkpoint of unpushed work, run tmp, worktree, branch, docker. */
163
+ async function teardown(reason, extra) {
164
+ for (const p of ledger ? ledger.entries("pid") : []) {
165
+ try { if (await io.killGroup?.(p.value, p.marker)) ledgerDo((l) => l.release("pid", p.value)); } catch { /* keep it listed */ }
166
+ }
167
+ // Never discard work silently: an unmerged lane commits and pushes what is left before its worktree goes.
168
+ const saved = !wtCreated || reason === "merged" || (await checkpoint(`exit (${reason})`));
169
+ if (runTmp) {
170
+ try { if ((await io.runTmp.remove(runTmp)) !== false) ledgerDo((l) => l.release("tmp", runTmp)); } catch { /* best effort */ }
171
+ runTmp = null;
172
+ } // every terminal, including engine-error and deferred
173
+ if (wtCreated && saved) {
174
+ let removed = false;
175
+ try { await io.git(["worktree", "remove", "--force", wt], { cwd }); removed = true; } catch { /* best effort */ }
176
+ try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
177
+ if (removed) ledgerDo((l) => l.release("worktree", wt));
178
+ let branchDone = true;
179
+ if ((reason === "invalid" && !extra.pr) || (reason === "merged" && deleteLocalBranch)) {
180
+ try { await io.git(["branch", "-D", branch], { cwd }); } catch { branchDone = reason !== "merged"; }
181
+ }
182
+ if (branchDone) ledgerDo((l) => l.release("branch", branch));
183
+ } else if (wtCreated) {
184
+ ev({ event: "TEARDOWN_KEPT", reason: "checkpoint failed: worktree and branch kept so no work is lost", worktree: wt });
185
+ }
186
+ for (const kind of ["container", "volume"]) {
187
+ for (const r of ledger ? ledger.entries(kind) : []) {
188
+ if (!io.docker) continue;
189
+ try { await io.docker(kind === "container" ? ["rm", "-f", String(r.value)] : ["volume", "rm", String(r.value)]); ledgerDo((l) => l.release(kind, r.value)); } catch { /* docker absent or busy: keep it listed */ }
190
+ }
191
+ }
192
+ ledgerDo((l) => { if (l.remove()) ev({ event: "LEDGER_CLEARED", path: l.path }); else ev({ event: "LEDGER_KEPT", path: l.path, resources: l.entries().map((r) => `${r.kind}:${r.value}`) }); });
193
+ }
194
+ /**
195
+ * #4162: save the worktree's uncommitted work (tracked, untracked and .forge-lean/) to refs/forge/salvage/<issue>/<ms>
196
+ * before an earlier run's worktree is removed at the start of this run (a run that died hard never ran its
197
+ * teardown checkpoint, #4179). It stages into the worktree's own index, which is discarded with the worktree, and
198
+ * the ref lives in the shared repo, so it survives the removal. Returns the ref, or null when there is nothing to save.
199
+ */
200
+ async function salvage(why) {
201
+ if (!io.fs?.exists || !(await io.fs.exists(wt))) return null;
202
+ const g = (a) => io.git(a, { cwd: wt });
203
+ try {
204
+ await g(["add", "-A"]);
205
+ if (await io.fs.exists(join(wt, LEAN_DIR))) await g(["add", "-f", "--", LEAN_DIR]);
206
+ const tree = String(await g(["write-tree"])).trim();
207
+ const headTree = String(await g(["rev-parse", "HEAD^{tree}"])).trim();
208
+ if (!/^[0-9a-f]{40,64}$/.test(tree) || tree === headTree) return null;
209
+ const commit = String(await g(["-c", "user.name=forgedock", "-c", "user.email=forgedock@localhost", "commit-tree", tree, "-p", "HEAD", "-m", `forge salvage #${issue}: ${why}`])).trim();
210
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) return null;
211
+ const ref = `refs/forge/salvage/${issue}/${now()}`;
212
+ await io.git(["update-ref", ref, commit], { cwd });
213
+ ev({ event: "WORKTREE_SALVAGED", ref, why });
214
+ T.anomalies.push(`Uncommitted work from ${why} saved to \`${ref}\` (inspect: \`git diff HEAD ${ref} --stat\`)`);
215
+ return ref;
216
+ } catch (e) {
217
+ ev({ event: "WORKTREE_SALVAGE_FAILED", why, error: String(e?.message ?? e).slice(0, 300) });
218
+ return null;
219
+ }
220
+ }
221
+ /** #4162: stop processes still running inside the worktree (an orphaned runner from a dead run) before it is removed. */
222
+ async function stopWorktreeProcs() {
223
+ if (!io.proc) return;
224
+ const procs = await Promise.resolve(io.proc.inDir(wt)).catch(() => []);
225
+ if (!procs.length) return;
226
+ for (const p of procs) io.proc.kill(p.pid, "SIGTERM");
227
+ for (let i = 0; i < 10 && procs.some((p) => io.proc.alive(p.pid)); i++) await sleep(500);
228
+ const left = procs.filter((p) => io.proc.alive(p.pid));
229
+ for (const p of left) io.proc.kill(p.pid, "SIGKILL");
230
+ ev({ event: "WORKTREE_PROCS_STOPPED", pids: procs.map((p) => p.pid), killed: left.map((p) => p.pid) });
231
+ T.anomalies.push(`Stopped ${procs.length} process(es) still running in the worktree from an earlier run: ${procs.map((p) => `${p.pid} ${p.cmd ?? ""}`.trim().slice(0, 80)).join("; ")}`);
232
+ }
233
+ function finish(...args) {
234
+ if (done) return Promise.resolve(done);
235
+ finishing ??= finishOnce(...args); // a signal during a normal finish waits for the same teardown
236
+ return finishing;
237
+ }
238
+ async function finishOnce(reason, detail = "", extra = {}, codeOverride = null) {
239
+ await teardown(reason, extra); // first: unpushed work is pushed while this run still holds the lease
130
240
  const code = codeOverride ?? codeForTerminalReason(reason);
131
241
  ev({ event: "RUN_TERMINAL", reason, code });
132
242
  await emit({ event: "RUN_TERMINAL", reason, code });
@@ -141,11 +251,6 @@ export async function runCore(o) {
141
251
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
142
252
  }
143
253
  await releaseLease(reason);
144
- if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
145
- if (wtCreated) {
146
- try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
147
- if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
148
- }
149
254
  done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, ...extra };
150
255
  return done;
151
256
  }
@@ -160,7 +265,6 @@ export async function runCore(o) {
160
265
  // ---- stage notes: investigation.md, plan.md, implementation.md become their own comments ----
161
266
  const STAGES = [
162
267
  { key: "investigation", file: INVESTIGATION_FILE, marker: ["FORGE:INVESTIGATION", "FORGE:VALIDATE"], render: (md, ctx) => renderInvestigation(md, ctx.result) },
163
- { key: "plan", file: PLAN_FILE, marker: ["FORGE:PLAN"], render: (md, ctx) => renderPlan(md, ctx.result, ctx.files) },
164
268
  { key: "implementation", file: IMPLEMENTATION_FILE, marker: ["FORGE:IMPLEMENTATION"], render: (md, ctx) => renderImplementation(md, ctx.result, { branch, files: ctx.files }) },
165
269
  ];
166
270
  const posted = {}; // key -> comment url (or "" when posted without a link)
@@ -176,10 +280,25 @@ export async function runCore(o) {
176
280
  const url = await post(st.marker, st.render(md, { result, files }));
177
281
  posted[st.key] = url ?? "";
178
282
  }
283
+ /** Re-render a stage note that changed after a resume: edit the posted comment in place; post it when it was never posted or the edit fails. */
284
+ async function upsertStage(key) {
285
+ const st = STAGES.find((x) => x.key === key);
286
+ const id = /#issuecomment-(\d+)/.exec(posted[key] ?? "")?.[1];
287
+ const res = await readResult(), md = await readStage(st);
288
+ if (id) {
289
+ const files = key === "implementation" ? await stageFiles() : [];
290
+ const marks = st.marker.map((m) => `<!-- ${m} -->`).join("\n");
291
+ try { await ghw(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${id}`, "-f", `body=${capComment(`${marks}\n${st.render(usableMd(md) ? md : null, { result: res, files })}`)}`]); return; } catch { /* fall back to a new comment */ }
292
+ }
293
+ delete posted[key];
294
+ await flushStages(res, [key]);
295
+ }
179
296
  /** Post stage notes in order that exist, are stable (mtime unchanged for stableMs) and not yet posted. */
180
297
  const seen = {};
298
+ let liveKeys = ["investigation"]; // which stage notes the running builder call may publish live (plan: investigation; implement: implementation)
181
299
  async function livePoll() {
182
300
  for (const st of STAGES) {
301
+ if (!liveKeys.includes(st.key)) continue;
183
302
  if (st.key in posted) continue;
184
303
  let m = null;
185
304
  try { m = await io.fs.stat(`${wt}/${st.file}`); } catch { m = null; }
@@ -209,7 +328,9 @@ export async function runCore(o) {
209
328
  async function callRunner(args) {
210
329
  let pauses = 0;
211
330
  for (;;) {
212
- try { return await io.runner({ ...args, ...(runTmp ? { env: { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } } : {}) }); }
331
+ // #4181: every Docker resource the builder creates carries the lane's ownership labels (same batch/lane as the ledger).
332
+ const labels = dockerLabels({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, issue, expires: now() + LEDGER_TTL_MS }).join(" ");
333
+ try { return await io.runner({ ...args, env: { FORGE_DOCKER_LABELS: labels, ...(runTmp ? { TMPDIR: runTmp, CLAUDE_CODE_TMPDIR: runTmp } : {}) } }); }
213
334
  catch (e) {
214
335
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
215
336
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -241,9 +362,13 @@ export async function runCore(o) {
241
362
  const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
242
363
  if (ahead) await gitPush(["push", "-u", "origin", branch]);
243
364
  ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
244
- } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); }
365
+ return true;
366
+ } catch (e) { ev({ event: "CHECKPOINT_FAILED", reason, error: String(e?.message ?? e).slice(0, 300) }); return false; }
245
367
  }
246
368
  const CONTINUE_NOTE = "\n\nThe branch already holds work from an earlier session that was interrupted (see the latest `wip(forge): checkpoint` commit). Continue from it: read `git log` and `git diff origin/BASE...HEAD`, do not start over.";
369
+ const PLAN_RERUN_NOTE = "\n\nThis is a rerun: the branch already holds commits from an earlier run. Read `git log` and `git diff origin/BASE...HEAD` to see what exists, but plan against the issue itself. This step is still planning only: do not edit code, do not commit, and do not write the result file unless the issue is invalid, already fixed or blocked.";
370
+ const SALVAGE_NOTE = "\n\nAn earlier run on this issue stopped with uncommitted work, saved at `REF` (not on the branch). Inspect it with `git diff HEAD REF --stat` and reuse what fits the contract (`git checkout REF -- <path>`) instead of rewriting it.";
371
+ const RERUN_NOTE = `\n\nThis is a rerun: the branch already holds commits from an earlier run. Read \`git log\` and \`git diff origin/BASE...HEAD\`, verify that work against the contract, complete what is missing, ${vcfg.mode === "ci" ? "re-run the light checks (the engine verifies on CI)" : "re-run every validate command"} and write the result file. Do not start over.`;
247
372
 
248
373
  /** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
249
374
  function startGovernor(control) {
@@ -267,7 +392,9 @@ export async function runCore(o) {
267
392
  return { async stop() { stopped = true; wake?.(); clearTimeout(t); await loop; if (paused) { control.resume?.(); paused = false; } } };
268
393
  }
269
394
 
270
- async function builder(n, prompt, resume, live = false) {
395
+ /** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
396
+ const implementRestart = () => builderImplementPrompt(vcfg.mode) + CONTINUE_NOTE.replace("BASE", lane);
397
+ async function builder(n, prompt, resume, live = false, restart = implementRestart) {
271
398
  let timeoutRetries = 0, p = prompt;
272
399
  for (;;) {
273
400
  try { return await builderOnce(n, p, resume, live); } catch (e) {
@@ -279,7 +406,7 @@ export async function runCore(o) {
279
406
  if (timeoutRetries++ >= 1) { e.deferCode = idle ? OUTCOME_CODES.CLI_IDLE : OUTCOME_CODES.CLI_TIMEOUT; e.deferDetail = `builder ${reason}; work checkpointed to ${branch}; rerun resumes from it. ${String(e.message).slice(0, 300)}`; throw e; }
280
407
  T.anomalies.push(`builder ${reason}: checkpointed, retried once`);
281
408
  T.decisions.push(`Builder ${reason}: uncommitted work checkpointed to the lane branch; retrying once from the checkpoint`);
282
- p = builderPrompt(issue) + CONTINUE_NOTE.replace("BASE", base); resume = null;
409
+ p = restart(); resume = null;
283
410
  }
284
411
  }
285
412
  }
@@ -289,8 +416,11 @@ export async function runCore(o) {
289
416
  await renew();
290
417
  const control = {};
291
418
  const governor = startGovernor(control);
292
- const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control,
419
+ let spawned = null;
420
+ const onSpawn = (child) => { if (Number.isSafeInteger(child?.pid) && !finishing) { spawned = child.pid; ledgerDo((l) => l.register("pid", child.pid, { marker: basename(String(child.spawnfile ?? "")) || null })); } };
421
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: builderModel, resume, system: "", prompt, control, onSpawn,
293
422
  idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
423
+ call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
294
424
  let r;
295
425
  call.catch(() => governor.stop());
296
426
  if (live && io.fs.stat) {
@@ -307,12 +437,25 @@ export async function runCore(o) {
307
437
  } else { try { r = await call; } finally { /* governor stops below */ } }
308
438
  await governor.stop();
309
439
  await renew();
440
+ await registerReported();
310
441
  if (r?.sessionId) sessionId = r.sessionId;
311
442
  await attempt("implement", n, r);
312
443
  onProgress({ event: "phase_exit", phase: "build", status: "committed" });
313
444
  return r;
314
445
  }
446
+ const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
315
447
  const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
448
+ /** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
449
+ async function registerReported() {
450
+ if (!ledger || finishing) return; // after teardown nothing new is owned
451
+ const res = (await readResult())?.resources;
452
+ if (!res || typeof res !== "object") return;
453
+ for (const [key, kind] of [["containers", "container"], ["volumes", "volume"]]) {
454
+ for (const v of Array.isArray(res[key]) ? res[key] : []) {
455
+ if (typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(v)) ledgerDo((l) => l.register(kind, v));
456
+ }
457
+ }
458
+ }
316
459
 
317
460
  /** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
318
461
  async function resolveBlock(r) {
@@ -325,8 +468,20 @@ export async function runCore(o) {
325
468
  return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
326
469
  }
327
470
  if (String(v?.state).toUpperCase() !== "OPEN") continue;
328
- open.push(d);
329
- if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(d);
471
+ // #4153: an open decomposed parent resolves to its open sub-issues; it is satisfied once they all close.
472
+ const subs = isDecomposedRecord(v) ? await decomposedSubissues(d, io) : null;
473
+ const recs = [];
474
+ if (!subs) recs.push([d, v]);
475
+ else for (const n of subs) {
476
+ try { recs.push([n, JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "state,labels"])))]); } catch (e) {
477
+ return { kind: "unreadable", detail: `sub-issue #${n} of decomposed dependency #${d} could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
478
+ }
479
+ }
480
+ for (const [n, rec] of recs) {
481
+ if (String(rec?.state).toUpperCase() !== "OPEN" || n === Number(issue) || open.includes(n)) continue;
482
+ open.push(n);
483
+ if ((rec.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(n);
484
+ }
330
485
  }
331
486
  if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
332
487
  return { kind: "dependency", issues: open, blockers };
@@ -353,7 +508,8 @@ export async function runCore(o) {
353
508
  try {
354
509
  for (const sub of b.subissues) {
355
510
  const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
356
- const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`]));
511
+ // #4153: the parent's milestone, so each sub-issue derives the parent's lane before any lane runs.
512
+ const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`, ...(milestone ? ["--milestone", milestone] : [])]));
357
513
  const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
358
514
  if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
359
515
  filed.push(n);
@@ -376,7 +532,7 @@ export async function runCore(o) {
376
532
  {
377
533
  const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
378
534
  if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
379
- lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
535
+ lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
380
536
  if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
381
537
  }
382
538
  /** Last guard before a PR is created or merged: the default branch is never a lane. */
@@ -408,14 +564,22 @@ export async function runCore(o) {
408
564
  leaseTimer.unref?.();
409
565
  }
410
566
 
567
+ if (io.ledger) {
568
+ try { ledger = io.ledger({ batch: o.batchId ?? "adhoc", lane: `lean-${issue}`, owner: agentId, issue, host: myHost }); } catch { ledger = null; }
569
+ }
570
+ // A signal (engine-cli SIGTERM/SIGINT) runs the same teardown and lease release as any other exit.
571
+ o.onTeardown?.((signal) => finish("deferred", `interrupted by ${signal}`, { code: OUTCOME_CODES.RUN_INTERRUPTED }, OUTCOME_CODES.RUN_INTERRUPTED));
411
572
  if (io.runTmp) {
412
573
  try { await io.runTmp.prune?.(); } catch { /* best effort */ }
574
+ const planned = io.runTmp.path?.(runId);
575
+ if (planned) ledgerDo((l) => l.register("tmp", planned));
413
576
  try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
577
+ if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
414
578
  }
415
579
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
416
580
  let iss;
417
581
  try {
418
- iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state"]));
582
+ iss = JSON.parse(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]));
419
583
  } catch (e) { return finish("engine-error", `cannot read issue: ${e.message}`); }
420
584
  if (String(iss.state).toUpperCase() === "CLOSED") return finish("invalid", "issue already closed");
421
585
 
@@ -437,6 +601,10 @@ export async function runCore(o) {
437
601
  return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
438
602
  isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
439
603
  }
604
+ // #4162: a run that died can leave its runner writing into this worktree, and uncommitted work in it. Stop the
605
+ // processes and save the work before the worktree is removed; the lease above makes this run the only one doing so.
606
+ await stopWorktreeProcs();
607
+ salvagedRef = await salvage("an earlier run");
440
608
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
441
609
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
442
610
  // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
@@ -476,6 +644,7 @@ export async function runCore(o) {
476
644
  keepLocal = containsRemote;
477
645
  }
478
646
  } catch { /* no local branch */ }
647
+ ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
479
648
  if (keepLocal) {
480
649
  await addWorktree(["worktree", "add", wt, branch]);
481
650
  resumed = resumed ?? { pr: null, local: true };
@@ -490,14 +659,33 @@ export async function runCore(o) {
490
659
  await io.fs.append(excl.startsWith("/") ? excl : `${wt}/${excl}`, `\n${LEAN_DIR}/\n`);
491
660
  const changedFiles = async () => String(await git(["diff", "--name-only", `${base}...HEAD`])).split("\n").map((s) => s.trim()).filter(Boolean);
492
661
 
493
- // ---- 3. builder ----
662
+ // ---- 3. builder: plan call -> frozen contract -> implement call in the same session ----
494
663
  let result = null, attemptN = 0, reviewN = 0;
495
664
  const lastSubject = resumed ? String(await git(["log", "-1", "--format=%s"]).catch(() => "")).trim() : "";
496
665
  const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
497
- if (!(resumed && (await changedFiles()).length > 0) || fromCheckpoint) {
498
- await label("building");
499
- await builder(++attemptN, builderPrompt(issue) + (fromCheckpoint ? CONTINUE_NOTE.replace("BASE", base) : ""), null, true);
500
- result = await readResult();
666
+ const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
667
+ // Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
668
+ const trustedOf = (i) => (i.comments ?? []).filter((c) => {
669
+ const login = String(c?.author?.login ?? c?.user?.login ?? "");
670
+ return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
671
+ });
672
+ const textOf = (i, tc) => [i.title, i.body ?? "", ...tc.map((c) => c?.body ?? "")].join("\n\n");
673
+ let trustedComments = trustedOf(iss);
674
+ let issueText = textOf(iss, trustedComments);
675
+ /** #4188: the builder reads the live issue, so quotes are checked against the current text, not the RUN_START snapshot. Best effort. */
676
+ async function refreshIssue(stage) {
677
+ let next;
678
+ try { next = JSON.parse(String(await ghc(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,comments"]))); } catch { return; }
679
+ if (!next || typeof next !== "object") return;
680
+ const tc = trustedOf(next), text = textOf(next, tc);
681
+ const old = contractHash(issueText), now = contractHash(text);
682
+ if (old === now) return;
683
+ iss = next; trustedComments = tc; issueText = text;
684
+ ev({ event: "ISSUE_EDITED", old, new: now, stage });
685
+ T.decisions.push(`Issue edited during the run (${old} -> ${now}); ${stage} uses the current text`);
686
+ }
687
+ /** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
688
+ async function endIfBlocked() {
501
689
  let block = result?.status === "blocked" ? await resolveBlock(result) : null;
502
690
  if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
503
691
  T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
@@ -505,70 +693,303 @@ export async function runCore(o) {
505
693
  result = await readResult();
506
694
  block = result?.status === "blocked" ? await resolveBlock(result) : null;
507
695
  }
508
- const files0 = await changedFiles();
696
+ return block ? await endBlocked(block, result.summary) : null;
697
+ }
698
+ /** File (or append to) the parent's one batched follow-up issue for these requirements, then verify each tracking issue is open. */
699
+ async function track(reqs, why) {
700
+ const fail = (detail) => Object.assign(new Error(detail), { deferCode: OUTCOME_CODES.FOLLOWUP_UNAVAILABLE, deferDetail: `${detail}; nothing merged, rerun to retry` });
701
+ if (!io.fileFollowups) throw fail("no follow-up filing effect is configured");
702
+ const items = followupItems(reqs);
703
+ let filed;
704
+ 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)}`); }
705
+ for (const [i, r] of reqs.entries()) {
706
+ const n = Number(filed?.[items[i].key]);
707
+ if (!Number.isSafeInteger(n) || n <= 0) throw fail(`no tracking issue was returned for ${r.id}`);
708
+ let st = null;
709
+ try { st = JSON.parse(String(await ghc(["issue", "view", String(n), "-R", repo, "--json", "number,state"]))); } catch { st = null; }
710
+ if (String(st?.state).toUpperCase() !== "OPEN") throw fail(`tracking issue #${n} for ${r.id} is not open or unreadable`);
711
+ r.tracked = `#${n}`;
712
+ }
713
+ T.decisions.push(`Tracked ${reqs.map((r) => `${r.id} (${r.disposition})`).join(", ")} in ${[...new Set(reqs.map((r) => r.tracked))].join(", ")} (${why})`);
714
+ }
715
+ /** The frozen contract posted by an earlier run of this issue, verified against its hash; null when absent. */
716
+ async function readFrozenContract() {
717
+ let out = "";
718
+ try { out = String(await ghc(["api", "--paginate", `repos/${repo}/issues/${issue}/comments`, "--jq", ".[] | {body, author_association, user: {type: .user.type, login: .user.login}}"])); } catch { return null; }
719
+ let found = null;
720
+ for (const l of out.split("\n")) {
721
+ let c; try { c = JSON.parse(l); } catch { continue; }
722
+ if (!String(c?.body ?? "").startsWith("<!-- FORGE:CONTRACT -->") || !isTrustedComment(c)) continue;
723
+ const fc = parseContractComment(c.body);
724
+ if (fc && contractHash(fc.text) === fc.hash) found = fc;
725
+ }
726
+ return found;
727
+ }
728
+
729
+ let frozen = null, hash = null, planned = false;
730
+ if (resumed) {
731
+ const fc = await readFrozenContract();
732
+ if (fc) { frozen = fc.contract; hash = fc.hash; T.decisions.push(`Rerun: frozen contract ${hash} recovered from the issue`); }
733
+ }
734
+ await label("building");
735
+ if (!frozen) {
736
+ await builder(++attemptN, builderPlanPrompt(issue) + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : ""), null, true, () => builderPlanPrompt(issue));
737
+ planned = true;
738
+ result = await readResult();
509
739
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
510
- const closing = ["invalid", "already-fixed"].includes(result?.status);
511
- await flushStages(result, ["investigation"]); // first stage note: the evidence comment for invalid/already-fixed
512
- T.validate = [result ? (result.status === "fixed" ? "CONFIRMED" : String(result.status).toUpperCase()) : "no result.json", `${evidence.length} evidence item(s)`];
513
- T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
514
- if (closing && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
515
- if (closing) {
740
+ const closes = ["invalid", "already-fixed"].includes(result?.status);
741
+ if (closes || result?.status === "blocked") await flushStages(result, ["investigation"]); // the evidence comment for invalid/already-fixed
742
+ T.validate = [result?.status ? String(result.status === "fixed" ? "CONFIRMED" : result.status).toUpperCase() : "CONFIRMED", `${evidence.length} evidence item(s)`];
743
+ if (closes && !evidence.length) return await needsHuman(`Builder reported "${result.status}" without evidence, so the issue is not closed automatically.\n\n${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_NO_EVIDENCE);
744
+ if (closes) {
516
745
  T.plan = "skipped"; T.implement = "skipped"; T.review = "skipped"; T.pr = "none"; T.merge = "n/a";
746
+ T.decisions.push(`Builder verdict: ${result.status}`);
517
747
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
518
748
  await label("invalid");
519
749
  return await finish("invalid", result.summary ?? result.status);
520
750
  }
521
- if (block) return await endBlocked(block, result.summary);
522
- if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
523
- await flushStages(result, ["plan", "implementation"]);
524
- T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
751
+ const blocked = await endIfBlocked();
752
+ if (blocked) return blocked;
753
+ let contract = await readContract();
754
+ await refreshIssue("contract validation");
755
+ let vc = validateContract(contract, issueText);
756
+ if (!vc.ok && sessionId) {
757
+ T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
758
+ await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
759
+ contract = await readContract();
760
+ await refreshIssue("contract validation");
761
+ vc = validateContract(contract, issueText);
762
+ }
763
+ await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
764
+ if (!vc.ok) return await needsHuman(`The builder's contract is still invalid after one correction:\n${vc.problems.slice(0, 12).map((x) => `- ${x}`).join("\n")}`, OUTCOME_CODES.CONTRACT_INVALID);
765
+ frozen = contract;
766
+ hash = contractHash(contractJson(frozen));
767
+ let body = renderContract(frozen, hash);
768
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true }); // the JSON must survive the comment cap
769
+ await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
770
+ const deferredN = frozen.requirements.filter((r) => TRACKED.includes(r.disposition)).length;
771
+ ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
772
+ T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
525
773
  } else {
526
- T.validate = "resumed (existing branch)"; T.plan = "skipped (resumed)"; T.implement = ["existing commits", branch];
527
- T.decisions.push("Open PR or remote branch found: builder skipped, reviewing existing commits");
774
+ T.validate = "resumed (existing branch)";
775
+ }
776
+ T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
777
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
778
+ const implementPrompt = builderImplementPrompt(vcfg.mode) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
779
+ liveKeys = ["implementation"];
780
+ await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
781
+ result = await readResult();
782
+ {
783
+ const blocked = await endIfBlocked();
784
+ if (blocked) return blocked;
785
+ }
786
+ let files = await changedFiles();
787
+ if (!files.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
788
+ await flushStages(result, ["implementation"]);
789
+ T.implement = [`${files.length} file(s)`, branch];
790
+ T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
791
+
792
+ // ---- 3b. structure delta (forge#4159): deterministic, before the conformance review; no reviewer run of its own ----
793
+ // A package.json edge between workspace packages the contract does not name blocks (one builder resume) in either
794
+ // conformance mode; import-pair growth and boundary-config loosening only add one line to the reviewer prompt.
795
+ const structureOf = async () => {
796
+ if (!io.structureDelta) return null;
797
+ const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
798
+ return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
799
+ };
800
+ let structure = await structureOf();
801
+ if (structure) {
802
+ const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
803
+ const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
804
+ import_files_added: sum("files_added"), import_files_removed: sum("files_removed"), boundary_changed: Boolean(structure.boundary_config_changed?.changed), boundary_loosened: Boolean(structure.boundary_config_changed?.loosens) };
805
+ ev(rec);
806
+ await emit({ ...rec, phase: "implement" });
807
+ const undeclared = () => unnamedEdges(applyAmendments(frozen, result?.amendments, issueText).contract, structure);
808
+ let edges = undeclared();
809
+ if (edges.length && sessionId) {
810
+ T.decisions.push(`Undeclared package edge(s) ${edges.map((e) => `${e.from}→${e.to}`).join(", ")}; builder resumed once`);
811
+ await builder(++attemptN, structurePrompt(edges, vcfg.mode), sessionId);
812
+ await upsertStage("implementation");
813
+ result = (await readResult()) ?? result;
814
+ structure = (await structureOf()) ?? structure;
815
+ edges = undeclared();
816
+ }
817
+ if (edges.length) {
818
+ return await needsHuman(`The change adds dependencies between workspace packages that the frozen contract does not name${sessionId ? ", after one builder resume" : ""}; nothing merged:\n${edges.slice(0, 15).map((e) => `- ${e.from} → ${e.to} (${e.kind}, \`${e.manifest}\`)`).join("\n")}\n\nAdd each to the contract with a reason, or remove it.`, OUTCOME_CODES.STRUCTURE_UNDECLARED_EDGE);
819
+ }
528
820
  }
821
+ const structureLine = structureNote(structure);
529
822
 
530
- // ---- 4. review (one retry through the same builder session) ----
823
+ // ---- 3c. verify by CI (#4167): ci|hybrid push the branch and run the repo's CI on GitHub runners, before review ----
824
+ // A red run resumes the builder with the failed jobs' logs (verify.max_rounds), slow CI ends ci-pending, and CI that
825
+ // cannot run falls back once to local validation. A head verified here lets the precheck accept validate ids left to CI.
826
+ let ciVerifiedHead = null, ciFellBack = false, ciRounds = 0;
827
+ const ciSeenRuns = [];
828
+ /** Verifies the current head; returns {done} with a terminal result, or {fixes} (builder CI-fix rounds that moved the head). */
829
+ async function ciVerify(trigger) {
830
+ let fixes = 0;
831
+ for (;;) {
832
+ const head = String(await git(["rev-parse", "HEAD"])).trim();
833
+ if (head === ciVerifiedHead || ciFellBack) return { fixes };
834
+ files = await changedFiles();
835
+ const v = await runCiVerify({ io, branch, head, changedFiles: files, verify: vcfg, ciMaxMinutes, now, sleep, random, exclude: ciSeenRuns,
836
+ push: () => gitPush(["push", "-u", "origin", branch]),
837
+ onPoll: async () => { await renew(); onProgress({ event: "phase_paused", phase: "verify", detail: `CI verification pending on ${branch}` }); } });
838
+ if (v.runId != null) ciSeenRuns.push(v.runId);
839
+ const rec = { event: "CI_VERIFY", trigger, mode: vcfg.mode, round: ciRounds, result: v.result, skipped: v.result === "skipped", run_url: v.runUrl ?? null, failures: v.failures.map((f) => f.job) };
840
+ ev(rec); await emit({ ...rec, phase: "verify" });
841
+ const row = (res) => { T.verify = [res, `mode ${vcfg.mode}, ${ciRounds} fix round(s)${v.runUrl ? `, ${v.runUrl}` : ""}`]; };
842
+ if (v.result === "pass" || v.result === "skipped") {
843
+ ciVerifiedHead = head;
844
+ row(v.result === "pass" ? `CI green at ${head.slice(0, 12)}` : "skipped (docs-only change)");
845
+ if (v.result === "skipped") T.decisions.push(`CI verification skipped: ${v.detail}`);
846
+ return { fixes };
847
+ }
848
+ if (v.result === "timeout") {
849
+ row("CI pending");
850
+ T.anomalies.push("CI verification still pending at the wait budget");
851
+ return { done: await finish("ci-pending", `CI verification of ${branch} still pending after ${ciMaxMinutes} min; rerun resumes the branch`) };
852
+ }
853
+ if (v.result === "unavailable") {
854
+ ciFellBack = true;
855
+ row("unavailable: local fallback");
856
+ T.decisions.push(`CI verification unavailable (${v.detail}); builder runs the validate commands locally`);
857
+ await builder(++attemptN, ciUnavailablePrompt(), sessionId ?? null);
858
+ await upsertStage("implementation");
859
+ result = (await readResult()) ?? result;
860
+ return { fixes: fixes + 1 };
861
+ }
862
+ // fail
863
+ const jobs = v.failures.map((f) => f.job).join(", ") || "unknown job";
864
+ row(`CI red (${jobs})`);
865
+ if (ciRounds >= vcfg.maxRounds || !sessionId) {
866
+ return { done: await needsHuman(`CI verification failed on \`${branch}\` at \`${head.slice(0, 12)}\` after ${ciRounds} fix round(s); nothing merged:\n${v.failures.slice(0, 10).map((f) => `- ${f.job}${f.url ? ` (${f.url})` : ""}`).join("\n")}`, OUTCOME_CODES.CI_RED) };
867
+ }
868
+ ciRounds++; fixes++;
869
+ T.decisions.push(`CI verification red (${jobs}); builder resumed with the failed logs (round ${ciRounds}/${vcfg.maxRounds})`);
870
+ await builder(++attemptN, ciFailurePrompt(v.failures, vcfg.mode), sessionId);
871
+ await upsertStage("implementation");
872
+ result = (await readResult()) ?? result;
873
+ if (String(await git(["rev-parse", "HEAD"])).trim() === head) {
874
+ return { done: await needsHuman(`CI verification failed on \`${branch}\` (${jobs}) and the builder made no new commit for it; nothing merged.`, OUTCOME_CODES.CI_RED) };
875
+ }
876
+ }
877
+ }
878
+
879
+ // ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
880
+ // enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
881
+ // but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression),
882
+ // plus the precheck's equivalent: a failed validate command or a requirement the builder reported as not done (#4160).
531
883
  await label("in-review");
532
- let review = null;
533
- const doReview = async () => {
884
+ const shadow = conformance !== "enforce";
885
+ let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
886
+ async function amend() {
887
+ const a = applyAmendments(frozen, result?.amendments, issueText);
888
+ const sig = JSON.stringify(a.applied);
889
+ if (a.applied.length && sig !== lastAmend) {
890
+ lastAmend = sig;
891
+ await post("FORGE:AMENDMENT", renderAmendments(a.applied));
892
+ T.decisions.push(`Contract amended: ${a.applied.map((x) => `${x.kind} ${x.target}`).join(", ")}`);
893
+ }
894
+ effective = a.contract;
895
+ return a.problems.map((d) => ({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, detail: d }));
896
+ }
897
+ const IGNORE = [`${LEAN_DIR}/`];
898
+ /** Up to two reviewer runs; a reply without usable rows (unparseable, incomplete, inconsistent) is re-asked, never charged to the builder. */
899
+ async function reviewOnce() {
900
+ const diff = String(await git(["diff", `${base}...HEAD`]));
901
+ const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
902
+ const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
903
+ const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
904
+ let d = null, retry = null;
905
+ const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
534
906
  for (let t = 0; t < 2; t++) {
535
- const diff = String(await git(["diff", `${base}...HEAD`]));
536
- // #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
537
- const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
538
907
  await renew();
539
908
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
540
- prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
909
+ prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, skeleton, retry }) });
541
910
  await renew();
542
911
  await attempt("review", ++reviewN, rr);
543
- const v = parseVerdict(rr?.text);
544
- if (v) return v;
912
+ review = parseReview(rr?.text);
913
+ d = decide(review, ctx);
914
+ ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
915
+ if (!d.state.startsWith("review.")) return d;
916
+ // #4160: the second run is told exactly what was missing, instead of getting the same prompt again
917
+ retry = d.state === "review.incomplete" ? { problems: d.failures.map((x) => x.detail), previous: review ? JSON.stringify(review) : String(rr?.text ?? "") } : null;
545
918
  }
546
- return null;
547
- };
548
- review = await doReview();
549
- if (!review) return await needsHuman("Reviewer returned no parseable verdict twice.", OUTCOME_CODES.REVIEW_NO_VERDICT);
550
- if (review.fail) {
551
- if (!sessionId) return await needsHuman(`Review FAIL and the builder session id is unknown, cannot resume: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
552
- T.decisions.push(`Review FAIL (round 1): builder resumed with the reason: ${String(review.reason).slice(0, 200)}`);
553
- await builder(++attemptN, resumeBuilderPrompt(review.reason), sessionId);
919
+ return d;
920
+ }
921
+ /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
922
+ async function gate() {
923
+ await refreshIssue("conformance");
554
924
  result = (await readResult()) ?? result;
555
- review = await doReview();
556
- if (!review) return await needsHuman("Reviewer returned no parseable verdict twice (re-review).", OUTCOME_CODES.REVIEW_NO_VERDICT);
557
- if (review.fail) return await needsHuman(`Review FAIL twice. Last reason: ${review.reason}`, OUTCOME_CODES.REVIEW_FAILED);
925
+ files = await changedFiles();
926
+ const amendProblems = await amend();
927
+ const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
928
+ const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified });
929
+ const pre = [...pc.failures, ...amendProblems];
930
+ if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
931
+ if (pre.length && !shadow) return { state: "nonconforming", by: "precheck", failures: pre };
932
+ // shadow: the builder's own failed checks block before any reviewer tokens are spent (#4160)
933
+ const hard = shadow ? pre.filter((f) => SHADOW_PRE_BLOCKS.includes(f.kind)) : [];
934
+ if (hard.length) return { state: "nonconforming", by: "precheck", failures: hard };
935
+ const d = await reviewOnce();
936
+ if (!shadow) return { ...d, by: "review" };
937
+ // shadow: record what enforce would have done; block only on what the previous reviewer blocked on
938
+ const would = d.state === "conforms" && !pre.length ? "conforms" : d.state.startsWith("review.") ? d.state : "nonconforming";
939
+ const wouldFail = [...pre, ...(d.state === "conforms" ? [] : d.failures)];
940
+ const blocking = d.state === "nonconforming" ? d.failures.filter((f) => SHADOW_BLOCKS.includes(f.code)) : [];
941
+ return { state: d.state === "review.unparseable" ? d.state : blocking.length ? "nonconforming" : "conforms", by: "review", failures: blocking, would, wouldFail, reviewState: d.state };
942
+ }
943
+ /** Shape the base-sync path expects: null when no usable review, else {fail, verdict, reason, failures}. */
944
+ const doReview = async () => {
945
+ const d = await gate();
946
+ if (d.state.startsWith("review.")) return null;
947
+ return { fail: d.state !== "conforms", verdict: d.state === "conforms" ? "CONFORMS" : "NONCONFORMING", reason: d.failures.map((f) => `${f.code}: ${f.detail}`).join("; "), failures: d.failures };
948
+ };
949
+ for (;;) {
950
+ if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
951
+ const outcome = await gate();
952
+ verdict = outcome;
953
+ if (outcome.state === "conforms") break;
954
+ const list = outcome.failures.slice(0, 15).map((f) => `- ${f.code}: ${f.detail}`).join("\n");
955
+ if (outcome.state === "review.unparseable") return await needsHuman(`The conformance reviewer returned no parseable JSON twice; nothing merged.`, OUTCOME_CODES.REVIEW_NO_VERDICT);
956
+ if (outcome.state === "review.incomplete") return await needsHuman(`The conformance review was incomplete twice (a review without complete rows cannot pass); nothing merged:\n${list}`, OUTCOME_CODES.REVIEW_INCOMPLETE);
957
+ if (outcome.state === "review.inconsistent") return await needsHuman(`The conformance reviewer twice said NONCONFORMING without a failing row; nothing merged.`, OUTCOME_CODES.REVIEW_INCONSISTENT);
958
+ if (resumes >= 1 || !sessionId) return await needsHuman(`The change does not conform to the issue and its frozen contract after ${resumes ? "one builder resume" : "the build (no session to resume)"} (${outcome.by}); nothing merged:\n${list}`, outcome.failures[0].code);
959
+ resumes++;
960
+ T.decisions.push(`Not conforming (${outcome.by}${outcome.inconsistent ? ", reviewer verdict contradicted its rows" : ""}): ${[...new Set(outcome.failures.map((f) => f.code))].join(", ")}; builder resumed once`);
961
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
962
+ await upsertStage("implementation"); // the published note follows the code after every resume
963
+ }
964
+ // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
965
+ const toFile = pendingTracking(effective);
966
+ if (!shadow && toFile.length) await track(toFile, "accepted by the conformance review");
967
+ if (shadow) {
968
+ const wf = verdict.wouldFail ?? [];
969
+ ev({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
970
+ await emit({ event: "CONFORMANCE_SHADOW", would: verdict.would, failures: wf.map((f) => f.code) });
971
+ T.review = [`shadow: would ${verdict.would === "conforms" ? "pass" : `block (${verdict.would})`}`, `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
972
+ await post("FORGE:REVIEW", renderShadowReview({ would: verdict.would, failures: wf, review: verdict.reviewState === "conforms" || verdict.reviewState === "nonconforming" ? review : null, contract: effective, unfiled: toFile }));
973
+ } else {
974
+ T.review = ["CONFORMS", `${reviewN} reviewer run(s), ${resumes + 1} round(s)`];
975
+ await post("FORGE:REVIEW", renderReview(review, effective));
558
976
  }
559
- T.review = [review.verdict, `${reviewN} reviewer run(s), ${attemptN > 1 ? "2 rounds" : "1 round"}`];
560
- await post("FORGE:REVIEW", renderReview(review));
561
977
  ev({ event: "PHASE_COMMIT", phase: "review" });
978
+ const closing = closePlan(shadow ? { requirements: [] } : effective);
562
979
 
563
980
  // ---- 5. push + PR ----
564
- const files = await changedFiles();
981
+ files = await changedFiles();
565
982
  await gitPush(["push", "-u", "origin", branch]);
566
983
  laneGuard(lane, "open a PR");
567
984
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
568
- "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
985
+ "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })])).trim();
569
986
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
570
987
  if (!Number.isInteger(pr)) return await needsHuman(`Could not parse the PR number from: ${prOut.slice(0, 200)}`);
571
988
  ev({ event: "PR_OPENED", pr });
989
+ if (resumed?.pr && closing.tracked.length) { // an existing PR still has to link the tracking issues visibly
990
+ try { await ghw(["pr", "edit", String(pr), "-R", repo, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), contract: effective, implementation: await readStage(STAGES[1]), result, gitFiles: files, tracked: closing.tracked })]); }
991
+ catch (e) { e.deferCode = OUTCOME_CODES.FOLLOWUP_UNAVAILABLE; e.deferDetail = `could not link the tracking issues on PR #${pr}; nothing merged, rerun to retry`; throw e; }
992
+ }
572
993
  T.pr = [`#${pr}`, resumed?.pr ? "existing" : "opened"];
573
994
  const prUrl = /https?:\/\/\S+/.exec(prOut)?.[0] ?? `https://github.com/${repo}/pull/${pr}`;
574
995
  let localHead = String(await git(["rev-parse", "HEAD"])).trim();
@@ -598,6 +1019,13 @@ export async function runCore(o) {
598
1019
  }
599
1020
  return "unknown";
600
1021
  };
1022
+ /** #4158: MERGEABLE but BLOCKED is a base branch policy block (ruleset / required checks pending), not a conflict. */
1023
+ const policyBlocked = async () => {
1024
+ let j;
1025
+ try { j = JSON.parse(String(await ghc(["pr", "view", String(pr), "-R", repo, "--json", "mergeable,mergeStateStatus"])) || "{}"); } catch { return false; }
1026
+ return String(j.mergeable ?? "").toUpperCase() === "MERGEABLE" && String(j.mergeStateStatus ?? "").toUpperCase() === "BLOCKED";
1027
+ };
1028
+ let blockStartedAt = null, blockPollIndex = 0;
601
1029
  const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
602
1030
  const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
603
1031
  || (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
@@ -617,7 +1045,7 @@ export async function runCore(o) {
617
1045
  how = "clean merge of the base into the branch";
618
1046
  } else {
619
1047
  T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
620
- 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, run the relevant tests, and commit the merge. Do not push.`;
1048
+ 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.`;
621
1049
  await builder(++attemptN, prompt, sessionId ?? null);
622
1050
  if (await unresolved()) {
623
1051
  await gitOut(["merge", "--abort"]);
@@ -630,11 +1058,13 @@ export async function runCore(o) {
630
1058
  const after = await gitOut(["rev-parse", "HEAD"]);
631
1059
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
632
1060
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
633
- if (!clean) {
1061
+ let ciFixes = 0;
1062
+ if (remoteVerify && after !== before) { const v = await ciVerify("base-sync"); if (v.done) return v.done; ciFixes = v.fixes; }
1063
+ if (!clean || ciFixes) {
634
1064
  let rv = await doReview();
635
1065
  if (rv?.fail) {
636
1066
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
637
- await builder(++attemptN, resumeBuilderPrompt(rv.reason), sessionId ?? null);
1067
+ await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
638
1068
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
639
1069
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
640
1070
  rv = await doReview();
@@ -650,6 +1080,19 @@ export async function runCore(o) {
650
1080
  return null;
651
1081
  }
652
1082
  const NR_TESTS = "not reported";
1083
+ /** After the merge (#4179): delete the lane branch on origin; finish() deletes it locally after the worktree is gone. */
1084
+ async function deleteMergedBranch() {
1085
+ const skip = (why) => { ev({ event: "BRANCH_DELETE_SKIPPED", branch, why }); T.decisions.push(`Lane branch kept: ${why}`); };
1086
+ if (protectedBranch(branch)) return skip("protected branch name");
1087
+ for (const side of ["--head", "--base"]) {
1088
+ let open;
1089
+ try { open = JSON.parse(String(await ghc(["pr", "list", "-R", repo, side, branch, "--state", "open", "--json", "number"])) || "[]"); } catch { return skip("open-PR check failed"); }
1090
+ if (open.length) return skip(`open PR #${open[0].number} uses it as ${side.slice(2)}`);
1091
+ }
1092
+ deleteLocalBranch = true;
1093
+ try { await io.git(["push", "origin", "--delete", branch], { cwd }); ev({ event: "BRANCH_DELETED", branch, where: "origin" }); }
1094
+ catch (e) { ev({ event: "BRANCH_DELETE_FAILED", branch, where: "origin", error: String(e?.stderr ?? e?.message ?? e).slice(0, 300) }); }
1095
+ }
653
1096
 
654
1097
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
655
1098
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
@@ -704,11 +1147,31 @@ export async function runCore(o) {
704
1147
  await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
705
1148
  continue;
706
1149
  }
1150
+ if (await policyBlocked()) {
1151
+ // Policy block: wait and retry at the reviewed head; never base-sync, never base.conflict (#4158).
1152
+ if (blockStartedAt === null) {
1153
+ blockStartedAt = now();
1154
+ T.decisions.push("merge blocked by base branch policy (ruleset/required checks); waiting");
1155
+ T.anomalies.push(`gh pr merge blocked by base branch policy: ${msg.split("\n")[0].slice(0, 120)}`);
1156
+ }
1157
+ if (now() - blockStartedAt >= ciMaxMinutes * 60000) {
1158
+ T.merge = [policyText, "blocked by base branch policy"];
1159
+ return await finish("ci-pending", `PR #${pr} blocked by base branch policy after ${ciMaxMinutes} min (checks pending or ruleset requirement unmet)`, { pr });
1160
+ }
1161
+ onProgress({ event: "phase_paused", phase: "merge", detail: `PR #${pr} blocked by base branch policy` });
1162
+ await sleep(ciPollDelayMs(blockPollIndex++, random));
1163
+ await renew();
1164
+ continue;
1165
+ }
707
1166
  if (!MERGE_CONFLICT_RE.test(msg)) throw e;
708
1167
  const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
709
1168
  }
710
1169
  }
1170
+ await deleteMergedBranch();
711
1171
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
1172
+ if (closing.tracked.length) {
1173
+ try { await post("FORGE:TRACKED", `## Remaining work tracked\nClosed as completed by PR #${pr}. The contracted deferred and post-deploy requirements are tracked in ${closing.tracked.join(", ")}.`); } catch { /* the PR body links them too */ }
1174
+ }
712
1175
  await label("merged");
713
1176
  return await finish("merged", `PR #${pr}`, { pr });
714
1177
  } catch (e) {