forgedock 1.28.0 → 1.29.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 (53) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +71 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +333 -0
  7. package/bin/lean/context.test.mjs +279 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +171 -35
  10. package/bin/lean/lean.test.mjs +413 -7
  11. package/bin/lean/orchestrate.mjs +80 -3
  12. package/bin/lean/prefilter.mjs +2 -2
  13. package/bin/lean/prefilter.test.mjs +3 -0
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +230 -0
  16. package/bin/lean/render.mjs +25 -5
  17. package/bin/lean/testmap.mjs +84 -0
  18. package/bin/lean/testmap.test.mjs +58 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/orchestrate.md +52 -6
  26. package/docs/CONFIG.md +15 -0
  27. package/docs/OUTCOME-CODES.md +14 -0
  28. package/docs/RELEASE-NOTES.md +9 -0
  29. package/package.json +1 -1
  30. package/scripts/check-spec-markers.sh +1 -0
  31. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  32. package/scripts/fixtures/test-map/go/Makefile +5 -0
  33. package/scripts/fixtures/test-map/go/README.md +1 -0
  34. package/scripts/fixtures/test-map/go/go.mod +3 -0
  35. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  36. package/scripts/fixtures/test-map/go/main.go +3 -0
  37. package/scripts/fixtures/test-map/node/package.json +10 -0
  38. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  39. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  40. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  41. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  42. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  43. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  44. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  45. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  46. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  47. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  48. package/scripts/repo-facts.sh +146 -0
  49. package/scripts/repo-facts.test.sh +106 -0
  50. package/scripts/test-map.sh +211 -0
  51. package/scripts/test-map.test.sh +165 -0
  52. package/scripts/wait-ci-green.sh +10 -4
  53. package/scripts/wait-ci-green.test.sh +9 -0
@@ -175,3 +175,32 @@ export function codeForTerminalReason(reason) {
175
175
  default: return UNCLASSIFIED;
176
176
  }
177
177
  }
178
+
179
+ /**
180
+ * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
181
+ * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
182
+ */
183
+ export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID]);
184
+ /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
185
+ export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
186
+
187
+ /**
188
+ * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
189
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `contract.invalid` after a mid-run issue
190
+ * edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
191
+ * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
192
+ */
193
+ export function classifyNeedsHuman(row) {
194
+ if (!row || typeof row !== "object") return "terminal";
195
+ const outcome = row.outcome ?? row.terminalReason;
196
+ if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
197
+ if (outcome !== "needs-human") return "terminal";
198
+ if (HUMAN_CODES.includes(row.code)) return "human";
199
+ if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
200
+ return RECOVERABLE_CODES.includes(row.code) ? "recoverable" : "terminal";
201
+ }
202
+
203
+ /** The code a recovery lane works on: a conflicting `ci-pending` is recovered as a base conflict. */
204
+ export function recoveryCodeOf(row) {
205
+ return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
206
+ }
@@ -61,12 +61,13 @@ export function makeIo() {
61
61
  return stdout;
62
62
  };
63
63
  // CI wait seam (scripts/wait-ci-green.sh, argv only, no shell). Resolves { rc, text } for any exit code.
64
- const waitCiGreen = async (pr, repo, timeoutSec) => {
64
+ // `ignore` (forge#4270): check names dropped before deciding (`--ignore-check`); omitted, the argv is unchanged.
65
+ const waitCiGreen = async (pr, repo, timeoutSec, { ignore = [] } = {}) => {
65
66
  const script = fileURLToPath(new URL("../scripts/wait-ci-green.sh", import.meta.url));
66
67
  try {
67
68
  // The engine owns the backoff and the budget: each call is ONE read (no in-script polling, no settle hold;
68
69
  // the re-dispatched phase re-runs the full gate before any merge).
69
- const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec)],
70
+ const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec), ...ignore.flatMap((n) => ["--ignore-check", String(n)])],
70
71
  { maxBuffer: 10 * 1024 * 1024, timeout: (timeoutSec + 120) * 1000, env: { ...process.env, FORGE_CI_SETTLE: "0" } });
71
72
  return { rc: 0, text: stdout };
72
73
  } catch (e) { return { rc: Number.isInteger(e?.code) ? e.code : 2, text: String(e?.stdout ?? "") }; }
@@ -111,6 +112,27 @@ export function readReviewIncomplete(cwd = process.cwd()) {
111
112
  return ["needs-human", "degraded"].includes(v) ? v : undefined;
112
113
  } catch { return undefined; }
113
114
  }
115
+ const warnedContextPack = new Set();
116
+ const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
117
+ /**
118
+ * forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
119
+ * (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
120
+ * `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
121
+ * (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
122
+ */
123
+ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
124
+ try {
125
+ const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
126
+ let mode = String(e.context_pack ?? "").trim().toLowerCase();
127
+ if (mode && !["shadow", "on", "off"].includes(mode)) {
128
+ warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
129
+ mode = "";
130
+ }
131
+ const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
132
+ ...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}) };
133
+ return Object.keys(out).length ? out : undefined;
134
+ } catch { return undefined; }
135
+ }
114
136
  /**
115
137
  * forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
116
138
  * list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
@@ -206,7 +228,8 @@ export function readMergeWait(cwd = process.cwd()) {
206
228
  }
207
229
  /**
208
230
  * forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
209
- * ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the section is absent or unreadable.
231
+ * ` skip_paths` comma-separated, ` max_rounds`, ` check_run: true` (#4270)) for the core path; undefined when the
232
+ * section is absent or unreadable.
210
233
  */
211
234
  export function readVerify(cwd = process.cwd()) {
212
235
  try {
@@ -614,7 +637,7 @@ export function lastLocalRun(dir) {
614
637
  */
615
638
  export async function runFromCli(argv, deps = {}) {
616
639
  const issue = parseInt(argv[0], 10);
617
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--retry] [--wait-lease]");
640
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
618
641
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
619
642
  const lane = flag(argv, "--lane");
620
643
  if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
@@ -623,7 +646,7 @@ export async function runFromCli(argv, deps = {}) {
623
646
  const backend = flag(argv, "--backend");
624
647
  const model = flag(argv, "--model");
625
648
  // Boolean flag: only counts at a flag position, never as another flag's value.
626
- const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason"].includes(argv[i - 1]));
649
+ const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason", "--recover", "--recover-attempt"].includes(argv[i - 1]));
627
650
  // forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
628
651
  // holder's lease expires or becomes reclaimable. Same flag-position guard.
629
652
  const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
@@ -636,12 +659,18 @@ export async function runFromCli(argv, deps = {}) {
636
659
  }
637
660
  // The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
638
661
  if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
662
+ // #4298: recovery lanes (#4267) exist only on the core path; the legacy full path would silently ignore --recover.
663
+ if (enginePath === "full" && argv.includes("--recover")) throw new Error("--recover is not supported with --path full (recovery lanes run on the core path only)");
639
664
  const io = deps.io ?? makeIo();
640
665
  const resultFile = flag(argv, "--result-file");
641
666
  // #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
642
667
  const placementWhere = flag(argv, "--placement");
643
668
  const placement = placementWhere === "local" || placementWhere === "remote"
644
669
  ? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
670
+ // #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`).
671
+ const recoverCode = flag(argv, "--recover");
672
+ const recoverAttempt = Number(flag(argv, "--recover-attempt"));
673
+ const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
645
674
  const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
646
675
  try {
647
676
  await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
@@ -704,9 +733,9 @@ export async function runFromCli(argv, deps = {}) {
704
733
  let lres;
705
734
  try {
706
735
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
707
- onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}),
736
+ onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
708
737
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
709
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
738
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
710
739
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
711
740
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
712
741
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1041,7 +1070,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1041
1070
  sink.batch?.(batch);
1042
1071
  sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
1043
1072
  const dir = deps.dir ?? runDir();
1044
- const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary } = await import("./lean/orchestrate.mjs");
1073
+ const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
1045
1074
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1046
1075
  const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
1047
1076
  const prefilter = argv.includes("--no-prefilter") ? null
@@ -1061,15 +1090,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1061
1090
  if (deps.leanModule) {
1062
1091
  const { runCore } = deps.leanModule;
1063
1092
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1064
- lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1065
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
1093
+ lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1094
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
1066
1095
  } else {
1067
1096
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1068
1097
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
1069
- const placementOf = new Map();
1070
- const laneArgs = (n, ln) => { const pl = placementOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
1071
- ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : [])]; };
1072
- const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => { if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n); return l.runOne(n, ln, ctx); }, adopt: l.adopt });
1098
+ // #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
1099
+ const placementOf = new Map(), recoverOf = new Map();
1100
+ const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
1101
+ ...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
1102
+ const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
1103
+ if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
1104
+ if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
1105
+ return l.runOne(n, ln, ctx);
1106
+ }, adopt: l.adopt });
1073
1107
  const execMod = await import("./lean/execution.mjs");
1074
1108
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1075
1109
  hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
@@ -1196,6 +1230,23 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1196
1230
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1197
1231
  ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1198
1232
  ...(remote ? { remote } : {}),
1233
+ // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1234
+ ...(orch.recovery?.maxAttempts ? { recovery: orch.recovery } : {}),
1235
+ decide: deps.decide ?? (async (d) => {
1236
+ if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
1237
+ const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
1238
+ try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
1239
+ }),
1240
+ // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1241
+ relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
1242
+ try {
1243
+ await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`]);
1244
+ await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
1245
+ } catch { /* best effort: the batch row keeps the evidence */ }
1246
+ }),
1247
+ conflicting: deps.conflicting ?? (async (_n, pr) => {
1248
+ try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1249
+ }),
1199
1250
  onCrossLane: (e) => {
1200
1251
  crossLane.push(e);
1201
1252
  sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
@@ -1205,7 +1256,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1205
1256
  : async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
1206
1257
  onUpdate: (r) => sink.row(r),
1207
1258
  });
1208
- const report = ["\n" + formatTable(rows)];
1259
+ const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
1209
1260
  const pfLine = formatPrefilterSummary(rows);
1210
1261
  if (pfLine) report.push(pfLine);
1211
1262
  let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
@@ -1328,6 +1379,8 @@ export async function schedFromCli(argv, deps = {}) {
1328
1379
  * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
1329
1380
  * `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
1330
1381
  * `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
1382
+ * `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
1383
+ * issue for a mechanical needs-human, default 1, 0 turns recovery off.
1331
1384
  */
1332
1385
  export function readOrchestration(cwd = process.cwd()) {
1333
1386
  let o = {};
@@ -1335,7 +1388,9 @@ export function readOrchestration(cwd = process.cwd()) {
1335
1388
  const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
1336
1389
  const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
1337
1390
  const gp = int(o.global_parallel, null);
1338
- return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null };
1391
+ const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
1392
+ return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
1393
+ recovery: { maxAttempts: recoveryMax } };
1339
1394
  }
1340
1395
 
1341
1396
  /**
@@ -203,9 +203,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
203
203
  assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
204
204
  assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
205
205
  assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
206
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null });
206
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } });
207
207
  writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
208
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4 });
208
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 } });
209
209
  });
210
210
 
211
211
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -8,6 +8,7 @@
8
8
  * "PASS") is incomplete, and incomplete never merges.
9
9
  */
10
10
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
11
+ import { noComments } from "./context.mjs";
11
12
 
12
13
  export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
13
14
  /** Dispositions the engine files as a tracked follow-up issue before anything merges. */
@@ -40,6 +41,29 @@ export function inIssue(quote, issueText) {
40
41
  if (q.length < 3) return false;
41
42
  return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
42
43
  }
44
+ // Scan-based (not a one-pass regex), so nested or unclosed markers cannot survive into the scope hash.
45
+ const stripHtmlComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
46
+ /**
47
+ * #4265: the human scope of an issue: title, body and trusted comments without the engine's own annotations (HTML comment
48
+ * blocks such as FORGE:STATE, and comments the engine posted, which start with "<!-- FORGE:"). Its hash is the edit signal
49
+ * and the provenance of a frozen contract; quote validation keeps using the full text.
50
+ * #4298 (SEC-5): EVERY HTML comment is cut, human ones too. GitHub does not render them, so they are not the scope a
51
+ * human reads, and an edit that only touches a hidden comment is not a scope edit.
52
+ */
53
+ export function scopeText(iss, trustedComments = []) {
54
+ const human = (trustedComments ?? []).map((c) => String(c?.body ?? "")).filter((b) => !b.trimStart().startsWith("<!-- FORGE:"));
55
+ return [iss?.title ?? "", stripHtmlComments(iss?.body), ...human.map(stripHtmlComments)].join("\n\n");
56
+ }
57
+ /** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
58
+ export function explainFailures(failures, contract) {
59
+ const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
60
+ return (failures ?? []).map((f) => {
61
+ const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
62
+ const q = id && reqs.get(id);
63
+ if (!q) return f;
64
+ return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}")` };
65
+ });
66
+ }
43
67
  export const isTestPath = (p) => TEST_RE.test(String(p));
44
68
  export const isIncidentalPath = (p) => INCIDENTAL_RE.test(String(p));
45
69
  const cleanPath = (p) => String(p ?? "").trim().replace(/^\.\//, "");
@@ -71,6 +95,23 @@ export const isBugIssue = (labels) => (Array.isArray(labels) ? labels : []).some
71
95
  * Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. `repro` (#4271): a bug
72
96
  * lane with lean.repro_test on also needs repro_test: {path, id?, command}, or "none" with a repro_test_reason.
73
97
  */
98
+ /**
99
+ * A validate command that diffs against a remote branch tip (`git diff origin/x`, `git diff origin/x HEAD`) breaks as
100
+ * soon as the base moves on, even when the change is fine. Returns the remote ref it diffs against, or null when every
101
+ * `git diff` segment compares against the merge base (`origin/x...HEAD`, `$(git merge-base …)`) or no remote ref.
102
+ * Only revisions count: anything after a standalone `--` is a pathspec (`git diff HEAD -- origin/x.txt`, #4298).
103
+ */
104
+ export function movingBaseDiff(command) {
105
+ for (const whole of String(command ?? "").split(/&&|\|\||;|\|/)) {
106
+ const seg = whole.split(/(?:^|\s)--(?=\s|$)/)[0];
107
+ if (!/\bgit\b(?:\s+-C\s+\S+)?(?:\s+-c\s+\S+)*\s+diff\b/.test(seg)) continue;
108
+ const ref = /(?:^|[\s=])(origin\/[A-Za-z0-9._\/-]+)/.exec(seg)?.[1];
109
+ if (!ref || seg.includes("...") || /merge-base/.test(seg)) continue;
110
+ return ref.replace(/\.\.?$/, "");
111
+ }
112
+ return null;
113
+ }
114
+
74
115
  export function validateContract(c, issueText, { repro = false } = {}) {
75
116
  const problems = [];
76
117
  if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
@@ -97,7 +138,11 @@ export function validateContract(c, issueText, { repro = false } = {}) {
97
138
  else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
98
139
  }
99
140
  if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
100
- else for (const v of c.validate) if (!v?.id || blank(v?.command)) problems.push("validate entry without id or command");
141
+ else for (const v of c.validate) {
142
+ if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
143
+ const ref = movingBaseDiff(v.command);
144
+ if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
145
+ }
101
146
  if (repro) {
102
147
  const t = c.repro_test;
103
148
  if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
@@ -160,7 +205,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
160
205
  const leftToCi = !r || (r.ci === true && r.exit === undefined);
161
206
  if (ciVerified && leftToCi) continue;
162
207
  if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
163
- else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
208
+ else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
164
209
  }
165
210
  const st = Array.isArray(result?.requirements) ? result.requirements : [];
166
211
  for (const r of contract.requirements ?? []) {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, explainFailures } from "./conformance.mjs";
6
6
 
7
7
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
8
8
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -161,6 +161,21 @@ describe("conformance review: the engine computes the verdict from complete rows
161
161
  assert.equal(resolveEvidencePath("nope.js", files), null);
162
162
  assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
163
163
  });
164
+ it("a validate command that diffs against the moving origin/<lane> tip invalidates the contract; a merge-base diff does not", () => {
165
+ const withV = (command) => ({ ...C, validate: [{ id: "V4", command }] });
166
+ const bad = validateContract(withV("./scripts/sync.sh --check-only && git diff --quiet origin/staging -- infra/a infra/b"), ISSUE);
167
+ assert.equal(bad.ok, false);
168
+ assert.ok(bad.problems.some((x) => /^V4: `git diff` against the moving origin\/staging tip/.test(x) && x.includes("origin/staging...HEAD")), bad.problems.join("; "));
169
+ for (const ok of ["git diff --quiet origin/staging...HEAD -- infra/a", "git diff --quiet $(git merge-base HEAD origin/staging) -- infra/a", "node --test test/leak.test.js", "git log origin/staging..HEAD --oneline"]) {
170
+ assert.equal(validateContract(withV(ok), ISSUE).ok, true, ok);
171
+ }
172
+ });
173
+ it("movingBaseDiff finds the remote ref in any git diff segment, and ignores merge-base forms and other commands", () => {
174
+ assert.equal(movingBaseDiff("git diff --name-only origin/main HEAD"), "origin/main");
175
+ assert.equal(movingBaseDiff("git -C sub diff origin/milestone/foo | wc -l"), "origin/milestone/foo");
176
+ assert.equal(movingBaseDiff("git diff HEAD~1"), null);
177
+ assert.equal(movingBaseDiff(""), null);
178
+ });
164
179
  it("#4160: reviewSkeleton (object form) lists every required row, scope rows pre-filled with the engine's authorization", () => {
165
180
  const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
166
181
  const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
@@ -299,3 +314,40 @@ describe("repro_test on bug lanes (#4271)", () => {
299
314
  assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), precheck({ contract: C, result: res, changedFiles: files, repro: null }));
300
315
  });
301
316
  });
317
+
318
+ describe("#4265 issue scope and failure explanations", () => {
319
+ const iss = { title: "t", body: "Fix foo.\n\n<!-- FORGE:STATE\n{\"lease\":1}\n-->" };
320
+ const human = { body: "Also bar." };
321
+ it("scopeText ignores the engine's HTML-comment blocks and FORGE comments, keeps human text", () => {
322
+ const base = scopeText(iss, [human]);
323
+ assert.equal(scopeText({ ...iss, body: iss.body.replace("\"lease\":1", "\"lease\":2") }, [human, { body: "<!-- FORGE:CONTRACT -->\n## Builder Contract" }, { body: " <!-- FORGE:STARTED -->\nStarted" }]), base);
324
+ assert.match(base, /Fix foo\.[\s\S]*Also bar\./);
325
+ assert.doesNotMatch(base, /FORGE:STATE|lease/);
326
+ assert.notEqual(scopeText(iss, [human, { body: "Scope: also baz." }]), base);
327
+ assert.notEqual(scopeText({ ...iss, body: "Fix foo and qux." }, [human]), base);
328
+ });
329
+ it("#4298 SEC-5: an edit that only touches a human HTML comment is not a scope edit", () => {
330
+ const withNote = { ...iss, body: `${iss.body}\n<!-- note: maybe also qux -->` };
331
+ assert.equal(scopeText(withNote, [human]), scopeText({ ...withNote, body: withNote.body.replace("maybe also qux", "definitely qux") }, [human]));
332
+ assert.equal(scopeText(withNote, [human]), scopeText(iss, [human]));
333
+ });
334
+ it("#4298: movingBaseDiff reads only the revisions before `--`", () => {
335
+ assert.equal(movingBaseDiff("git diff HEAD -- origin/x.txt"), null);
336
+ assert.equal(movingBaseDiff("git diff --stat -- origin/foo/bar && echo ok"), null);
337
+ assert.equal(movingBaseDiff("git diff origin/main -- src"), "origin/main");
338
+ assert.equal(movingBaseDiff("git diff --name-only origin/main --"), "origin/main");
339
+ });
340
+ it("explainFailures appends the quote of the requirement a detail names, and leaves other details alone", () => {
341
+ const contract = { requirements: [{ id: "R1", quote: "foo must handle null" }, { id: "R2", quote: "x".repeat(300) }] };
342
+ const out = explainFailures([{ code: "c", detail: "R1 reported \"partial\"" }, { code: "c", detail: "R2 missing" }, { code: "c", detail: "validate V1 failed (exit 1)" }, { code: "c", detail: "R9 missing" }], contract);
343
+ assert.equal(out[0].detail, 'R1 reported "partial" ("foo must handle null")');
344
+ assert.ok(out[1].detail.endsWith('...")') && out[1].detail.length < 200);
345
+ assert.equal(out[2].detail, "validate V1 failed (exit 1)");
346
+ assert.equal(out[3].detail, "R9 missing");
347
+ assert.equal(out[0].code, "c");
348
+ });
349
+ it("a failed validate names its command", () => {
350
+ const r = precheck({ contract: { requirements: [], deliverables: [], validate: [{ id: "V1", command: "npm test" }] }, result: { validation: [{ id: "V1", exit: 2 }] }, changedFiles: [] });
351
+ assert.equal(r.failures[0].detail, "validate V1 (`npm test`) failed (exit 2)");
352
+ });
353
+ });