forgedock 1.26.0 → 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 (42) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -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 +78 -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.mjs +76 -10
  15. package/bin/lean/conformance.test.mjs +32 -3
  16. package/bin/lean/effects.mjs +96 -11
  17. package/bin/lean/lean.mjs +326 -33
  18. package/bin/lean/lean.test.mjs +618 -10
  19. package/bin/lean/ledger.mjs +67 -0
  20. package/bin/lean/ledger.test.mjs +65 -0
  21. package/bin/lean/orchestrate.mjs +9 -0
  22. package/bin/lean/prompts.mjs +42 -8
  23. package/bin/lean/render.mjs +1 -1
  24. package/bin/lean/verify.mjs +113 -0
  25. package/bin/lean/verify.test.mjs +95 -0
  26. package/commands/adopt.md +3 -0
  27. package/commands/audit.md +3 -8
  28. package/commands/forgedock-init.md +118 -6
  29. package/commands/review-pr.md +2 -2
  30. package/docs/CONFIG.md +52 -3
  31. package/docs/OUTCOME-CODES.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/bootstrap-validate.mjs +287 -0
  34. package/scripts/check-devdocs-freshness.sh +120 -0
  35. package/scripts/check-devdocs-freshness.test.sh +120 -0
  36. package/scripts/ci-local/run.mjs +13 -2
  37. package/scripts/ci-local.test.sh +13 -1
  38. package/scripts/northstar-docs.test.sh +68 -0
  39. package/scripts/review-prepare.sh +11 -9
  40. package/scripts/review-prepare.test.sh +14 -0
  41. package/scripts/structure-delta.sh +150 -0
  42. package/scripts/structure-delta.test.sh +135 -0
package/bin/lean/lean.mjs CHANGED
@@ -10,20 +10,28 @@ import { hostname as osHostname } from "node:os";
10
10
  import { appendEvent } from "../engine/runlog.mjs";
11
11
  import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
- import { openDependency, isTrustedComment } from "../engine/phases.mjs";
13
+ import { openDependency, isTrustedComment, decomposedSubissues, isDecomposedRecord } from "../engine/phases.mjs";
14
14
  import { createHash } from "node:crypto";
15
- import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking } from "./conformance.mjs";
15
+ import { TRACKED, validateContract, applyAmendments, precheck, parseReview, decide, closePlan, followupItems, pendingTracking, reviewSkeleton, unnamedEdges, structureNote } from "./conformance.mjs";
16
16
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
17
17
  import { resolveLane, isDefaultLane } from "./lane.mjs";
18
+ import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
18
19
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
19
20
  import { renderInvestigation, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, renderAmendments, renderShadowReview, parseContractComment, contractJson, capComment, sanitizeMd, MAX_COMMENT_CHARS } from "./render.mjs";
20
- 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 } from "./prompts.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";
21
23
 
22
24
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
23
25
  /** forge.yaml engine.conformance: "shadow" (default) posts and logs the gate's verdict without enforcing it; "enforce" blocks on it. */
24
26
  export const CONFORMANCE_MODES = Object.freeze(["shadow", "enforce"]);
25
27
  /** In shadow mode only what the previous two-question reviewer blocked on still blocks: a requirement not done, a regression. */
26
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"];
27
35
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
28
36
  const num = (v) => (Number.isFinite(v) ? v : null);
29
37
  export const MAX_SUBISSUES = 8;
@@ -55,6 +63,15 @@ export function classifyBlock(result, issue) {
55
63
  return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
56
64
  }
57
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
+
58
75
  /**
59
76
  * @param {object} o
60
77
  * @param {number} o.issue
@@ -69,13 +86,17 @@ export function classifyBlock(result, issue) {
69
86
  * @param {string} [o.reviewerModel] default claude-sonnet-5-5
70
87
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
71
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)
72
90
  */
73
91
  export async function runCore(o) {
74
92
  const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
75
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;
76
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";
77
98
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
78
- 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
79
100
  const branch = `feat/issue-${issue}-lean`;
80
101
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
81
102
  const t0 = now();
@@ -121,7 +142,7 @@ export async function runCore(o) {
121
142
  const leaseTtlMs = o.leaseTtlMs ?? 25 * 60000; // below the projector's 30 min honour cap
122
143
  let leaseState = null, leaseTimer = null;
123
144
  const writeLease = (lease, extra = {}) => io.projector.writeState(issue, { ...leaseState, ...extra, lease });
124
- 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 */ } } };
125
146
  async function releaseLease(reason) {
126
147
  if (leaseTimer) { clearInterval(leaseTimer); leaseTimer = null; }
127
148
  if (!io.projector || !leaseState) return;
@@ -131,11 +152,91 @@ export async function runCore(o) {
131
152
 
132
153
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
133
154
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
134
- let done = null, wtCreated = false;
155
+ let done = null, finishing = null, wtCreated = false, salvagedRef = null;
135
156
  const runId = `${issue}-${t0}`;
136
157
  let runTmp = null;
137
- async function finish(reason, detail = "", extra = {}, codeOverride = null) {
138
- 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
139
240
  const code = codeOverride ?? codeForTerminalReason(reason);
140
241
  ev({ event: "RUN_TERMINAL", reason, code });
141
242
  await emit({ event: "RUN_TERMINAL", reason, code });
@@ -150,11 +251,6 @@ export async function runCore(o) {
150
251
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
151
252
  }
152
253
  await releaseLease(reason);
153
- if (runTmp) { try { await io.runTmp.remove(runTmp); } catch { /* best effort */ } runTmp = null; } // every terminal, including engine-error and deferred
154
- if (wtCreated) {
155
- try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* best effort */ }
156
- if (reason === "invalid" && !extra.pr) { try { await io.git(["branch", "-D", branch], { cwd }); } catch { /* best effort */ } }
157
- }
158
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 };
159
255
  return done;
160
256
  }
@@ -232,7 +328,9 @@ export async function runCore(o) {
232
328
  async function callRunner(args) {
233
329
  let pauses = 0;
234
330
  for (;;) {
235
- 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 } : {}) } }); }
236
334
  catch (e) {
237
335
  const epoch = Number.isFinite(e?.resetAtEpochMs) ? e.resetAtEpochMs : null;
238
336
  if (!(e?.usageLimit === true || epoch !== null)) throw e;
@@ -264,11 +362,13 @@ export async function runCore(o) {
264
362
  const ahead = String(await git(["diff", "--name-only", `${base}...HEAD`])).trim();
265
363
  if (ahead) await gitPush(["push", "-u", "origin", branch]);
266
364
  ev({ event: "CHECKPOINT", reason, dirty: Boolean(dirty), pushed: Boolean(ahead) });
267
- } 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; }
268
367
  }
269
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.";
270
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.";
271
- 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, re-run every validate command and write the result file. Do not start over.";
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.`;
272
372
 
273
373
  /** Continuous admission (#4115): while the builder runs, pause its process group when the host is over the ceiling, resume when under. */
274
374
  function startGovernor(control) {
@@ -293,7 +393,7 @@ export async function runCore(o) {
293
393
  }
294
394
 
295
395
  /** A timeout restart starts a fresh session; by default it continues the implementation against the frozen contract. */
296
- const implementRestart = () => builderImplementPrompt() + CONTINUE_NOTE.replace("BASE", lane);
396
+ const implementRestart = () => builderImplementPrompt(vcfg.mode) + CONTINUE_NOTE.replace("BASE", lane);
297
397
  async function builder(n, prompt, resume, live = false, restart = implementRestart) {
298
398
  let timeoutRetries = 0, p = prompt;
299
399
  for (;;) {
@@ -316,8 +416,11 @@ export async function runCore(o) {
316
416
  await renew();
317
417
  const control = {};
318
418
  const governor = startGovernor(control);
319
- 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,
320
422
  idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits }));
423
+ call.then(() => {}, () => {}).finally(() => { if (spawned) ledgerDo((l) => l.release("pid", spawned)); });
321
424
  let r;
322
425
  call.catch(() => governor.stop());
323
426
  if (live && io.fs.stat) {
@@ -334,6 +437,7 @@ export async function runCore(o) {
334
437
  } else { try { r = await call; } finally { /* governor stops below */ } }
335
438
  await governor.stop();
336
439
  await renew();
440
+ await registerReported();
337
441
  if (r?.sessionId) sessionId = r.sessionId;
338
442
  await attempt("implement", n, r);
339
443
  onProgress({ event: "phase_exit", phase: "build", status: "committed" });
@@ -341,6 +445,17 @@ export async function runCore(o) {
341
445
  }
342
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; } };
343
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
+ }
344
459
 
345
460
  /** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
346
461
  async function resolveBlock(r) {
@@ -353,8 +468,20 @@ export async function runCore(o) {
353
468
  return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
354
469
  }
355
470
  if (String(v?.state).toUpperCase() !== "OPEN") continue;
356
- open.push(d);
357
- 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
+ }
358
485
  }
359
486
  if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
360
487
  return { kind: "dependency", issues: open, blockers };
@@ -381,7 +508,8 @@ export async function runCore(o) {
381
508
  try {
382
509
  for (const sub of b.subissues) {
383
510
  const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
384
- 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] : [])]));
385
513
  const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
386
514
  if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
387
515
  filed.push(n);
@@ -404,7 +532,7 @@ export async function runCore(o) {
404
532
  {
405
533
  const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
406
534
  if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
407
- lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
535
+ lane = r.lane; base = `origin/${lane}`; defaults = r.defaults; milestone = r.milestone ?? null;
408
536
  if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
409
537
  }
410
538
  /** Last guard before a PR is created or merged: the default branch is never a lane. */
@@ -436,9 +564,17 @@ export async function runCore(o) {
436
564
  leaseTimer.unref?.();
437
565
  }
438
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));
439
572
  if (io.runTmp) {
440
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));
441
576
  try { runTmp = await io.runTmp.make(issue, runId); } catch { runTmp = null; }
577
+ if (runTmp && runTmp !== planned) ledgerDo((l) => l.register("tmp", runTmp));
442
578
  }
443
579
  ev({ event: "RUN_START", run: `core_${issue}_${t0}`, issue, lane, path: "core", version: ENGINE_VERSION, forge_version: ENGINE_VERSION, repo });
444
580
  let iss;
@@ -465,6 +601,10 @@ export async function runCore(o) {
465
601
  return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
466
602
  isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
467
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");
468
608
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
469
609
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
470
610
  // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
@@ -504,6 +644,7 @@ export async function runCore(o) {
504
644
  keepLocal = containsRemote;
505
645
  }
506
646
  } catch { /* no local branch */ }
647
+ ledgerDo((l) => { l.register("branch", branch); l.register("worktree", wt); });
507
648
  if (keepLocal) {
508
649
  await addWorktree(["worktree", "add", wt, branch]);
509
650
  resumed = resumed ?? { pr: null, local: true };
@@ -524,11 +665,25 @@ export async function runCore(o) {
524
665
  const fromCheckpoint = lastSubject.startsWith("wip(forge): checkpoint");
525
666
  const hasCommits = resumed ? (await changedFiles()).length > 0 : false;
526
667
  // Requirements may only be quoted from the issue and trusted comments: an untrusted commenter cannot add a contract requirement.
527
- const trustedComments = (iss.comments ?? []).filter((c) => {
668
+ const trustedOf = (i) => (i.comments ?? []).filter((c) => {
528
669
  const login = String(c?.author?.login ?? c?.user?.login ?? "");
529
670
  return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
530
671
  });
531
- const issueText = [iss.title, iss.body ?? "", ...trustedComments.map((c) => c?.body ?? "")].join("\n\n");
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
+ }
532
687
  /** A typed block (with one corrective resume for an untyped one) ends the run; returns the terminal or null. */
533
688
  async function endIfBlocked() {
534
689
  let block = result?.status === "blocked" ? await resolveBlock(result) : null;
@@ -596,11 +751,13 @@ export async function runCore(o) {
596
751
  const blocked = await endIfBlocked();
597
752
  if (blocked) return blocked;
598
753
  let contract = await readContract();
754
+ await refreshIssue("contract validation");
599
755
  let vc = validateContract(contract, issueText);
600
756
  if (!vc.ok && sessionId) {
601
757
  T.decisions.push(`Contract rejected (${vc.problems[0]}); resumed once`);
602
758
  await builder(++attemptN, contractCorrectionPrompt(vc.problems), sessionId, false, () => builderPlanPrompt(issue));
603
759
  contract = await readContract();
760
+ await refreshIssue("contract validation");
604
761
  vc = validateContract(contract, issueText);
605
762
  }
606
763
  await flushStages(result ?? { status: "fixed" }, ["investigation"]); // always posted: FORGE:INVESTIGATION/VALIDATE are read downstream
@@ -618,7 +775,7 @@ export async function runCore(o) {
618
775
  }
619
776
  T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
620
777
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
621
- const implementPrompt = builderImplementPrompt() + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane));
778
+ const implementPrompt = builderImplementPrompt(vcfg.mode) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "");
622
779
  liveKeys = ["implementation"];
623
780
  await builder(++attemptN, implementPrompt, planned ? sessionId : null, true);
624
781
  result = await readResult();
@@ -632,9 +789,97 @@ export async function runCore(o) {
632
789
  T.implement = [`${files.length} file(s)`, branch];
633
790
  T.decisions.push(`Builder verdict: ${result?.status ?? "none"}`);
634
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
+ }
820
+ }
821
+ const structureLine = structureNote(structure);
822
+
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
+
635
879
  // ---- 4. conformance: typed amendments, deterministic precheck, structured review; the engine computes the verdict ----
636
880
  // enforce: every failure class blocks (one builder resume, then needs-human). shadow: the verdict is computed and posted,
637
- // but only what the previous reviewer enforced blocks (a review that finds a requirement not implemented, or a regression).
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).
638
883
  await label("in-review");
639
884
  const shadow = conformance !== "enforce";
640
885
  let effective = frozen, review = null, lastAmend = "", resumes = 0, verdict = null;
@@ -656,29 +901,37 @@ export async function runCore(o) {
656
901
  const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
657
902
  const listed = new Set(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean));
658
903
  const ctx = { contract: effective, changedFiles: files, issueText, pathExists: (x) => listed.has(x), ignore: IGNORE };
659
- let d = null;
904
+ let d = null, retry = null;
905
+ const skeleton = reviewSkeleton({ contract: effective, changedFiles: files, ignore: IGNORE }); // #4160: every required row, pre-filled
660
906
  for (let t = 0; t < 2; t++) {
661
907
  await renew();
662
908
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
663
- prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base }) });
909
+ prompt: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, skeleton, retry }) });
664
910
  await renew();
665
911
  await attempt("review", ++reviewN, rr);
666
912
  review = parseReview(rr?.text);
667
913
  d = decide(review, ctx);
668
914
  ev({ event: "CONFORMANCE", round: reviewN, state: d.state, failures: d.failures.map((x) => x.code), mode: shadow ? "shadow" : "enforce" });
669
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;
670
918
  }
671
919
  return d;
672
920
  }
673
921
  /** Precheck then review, on the current result and changed files (also the base-sync re-review). */
674
922
  async function gate() {
923
+ await refreshIssue("conformance");
675
924
  result = (await readResult()) ?? result;
676
925
  files = await changedFiles();
677
926
  const amendProblems = await amend();
678
- const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE });
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 });
679
929
  const pre = [...pc.failures, ...amendProblems];
680
930
  if (pre.length) ev({ event: "PRECHECK", failures: pre.map((f) => f.code) });
681
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 };
682
935
  const d = await reviewOnce();
683
936
  if (!shadow) return { ...d, by: "review" };
684
937
  // shadow: record what enforce would have done; block only on what the previous reviewer blocked on
@@ -694,6 +947,7 @@ export async function runCore(o) {
694
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 };
695
948
  };
696
949
  for (;;) {
950
+ if (remoteVerify) { const v = await ciVerify("build"); if (v.done) return v.done; }
697
951
  const outcome = await gate();
698
952
  verdict = outcome;
699
953
  if (outcome.state === "conforms") break;
@@ -704,7 +958,7 @@ export async function runCore(o) {
704
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);
705
959
  resumes++;
706
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`);
707
- await builder(++attemptN, conformanceResumePrompt(outcome.failures), sessionId);
961
+ await builder(++attemptN, conformanceResumePrompt(outcome.failures, vcfg.mode), sessionId);
708
962
  await upsertStage("implementation"); // the published note follows the code after every resume
709
963
  }
710
964
  // Deferrals the reviewer accepted are filed now, before any PR: the batched follow-up must exist and be linked.
@@ -765,6 +1019,13 @@ export async function runCore(o) {
765
1019
  }
766
1020
  return "unknown";
767
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;
768
1029
  const conflictFiles = async () => (await gitOut(["diff", "--name-only", "--diff-filter=U"])).split("\n").map((x) => x.trim()).filter(Boolean);
769
1030
  const unresolved = async () => (await conflictFiles()).length > 0 || (await gitOut(["grep", "-n", "-e", "^<<<<<<< ", "-e", "^>>>>>>> "])) !== ""
770
1031
  || (await gitOut(["diff", "--check"])) !== "" || (await gitOut(["rev-parse", "-q", "--verify", "MERGE_HEAD"])) !== "";
@@ -784,7 +1045,7 @@ export async function runCore(o) {
784
1045
  how = "clean merge of the base into the branch";
785
1046
  } else {
786
1047
  T.decisions.push(`Base-sync ${syncs}: merging ${base} conflicts in ${files.join(", ") || "unknown files"}; builder resolves`);
787
- 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.`;
788
1049
  await builder(++attemptN, prompt, sessionId ?? null);
789
1050
  if (await unresolved()) {
790
1051
  await gitOut(["merge", "--abort"]);
@@ -797,11 +1058,13 @@ export async function runCore(o) {
797
1058
  const after = await gitOut(["rev-parse", "HEAD"]);
798
1059
  if (soft && clean && after === before) { syncs--; return null; } // UNKNOWN state and nothing to merge: not a conflict
799
1060
  if (after !== before) await gitPush(["push", "origin", branch]); // plain push of a merge commit: never force
800
- 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) {
801
1064
  let rv = await doReview();
802
1065
  if (rv?.fail) {
803
1066
  T.decisions.push(`Base-sync re-review FAIL: builder resumed with the reason`);
804
- await builder(++attemptN, conformanceResumePrompt(rv.failures), sessionId ?? null);
1067
+ await builder(++attemptN, conformanceResumePrompt(rv.failures, vcfg.mode), sessionId ?? null);
805
1068
  if (await unresolved()) return await needsHuman("Base-sync re-review fix left conflict markers.", OUTCOME_CODES.BASE_CONFLICT);
806
1069
  if ((await gitOut(["rev-parse", "HEAD"])) !== after) await gitPush(["push", "origin", branch]);
807
1070
  rv = await doReview();
@@ -817,6 +1080,19 @@ export async function runCore(o) {
817
1080
  return null;
818
1081
  }
819
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
+ }
820
1096
 
821
1097
  // ---- 7. (sync) wait per policy, (sync) merge at the reviewed head ----
822
1098
  // A rerun on an existing PR goes through the same conflict check, so a conflicting PR from an earlier run recovers.
@@ -871,10 +1147,27 @@ export async function runCore(o) {
871
1147
  await sleepFn(Math.min(60000, 1000 * 2 ** (mergeRetries - 1)) + Math.floor(random() * 250));
872
1148
  continue;
873
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
+ }
874
1166
  if (!MERGE_CONFLICT_RE.test(msg)) throw e;
875
1167
  const r = await baseSync("gh pr merge reported a conflict"); if (r) return r;
876
1168
  }
877
1169
  }
1170
+ await deleteMergedBranch();
878
1171
  await ghw(["issue", "close", String(issue), "-R", repo, "--reason", "completed"]).catch(() => {});
879
1172
  if (closing.tracked.length) {
880
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 */ }