forgedock 1.31.0 → 1.32.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.
@@ -125,7 +125,7 @@ const warnedContextPack = new Set();
125
125
  const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
126
126
  /**
127
127
  * forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
128
- * (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
128
+ * (shadow|on|off|ab, #4444), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
129
129
  * `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
130
130
  * (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
131
131
  */
@@ -133,8 +133,8 @@ export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
133
133
  try {
134
134
  const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
135
135
  let mode = String(e.context_pack ?? "").trim().toLowerCase();
136
- if (mode && !["shadow", "on", "off"].includes(mode)) {
137
- warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
136
+ if (mode && !["shadow", "on", "off", "ab"].includes(mode)) {
137
+ warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off|ab); using the default, other context_pack_* keys kept`);
138
138
  mode = "";
139
139
  }
140
140
  const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
@@ -229,6 +229,13 @@ export function readPreconditionCmd(cwd = process.cwd()) {
229
229
  return typeof v === "string" && v.trim() ? v.trim() : null;
230
230
  } catch { return null; }
231
231
  }
232
+ /** #4425: forge.yaml `post_deploy.min_age` ("24h", "7d"; opt-in soak window for parked post-deploy follow-ups) in ms, or null. */
233
+ export async function readPostDeployMinAge(cwd = process.cwd()) {
234
+ try {
235
+ const { parseMinAge } = await import("./lean/prefilter.mjs");
236
+ return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.post_deploy?.min_age);
237
+ } catch { return null; }
238
+ }
232
239
  /** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
233
240
  export function readExplore(cwd = process.cwd()) {
234
241
  try {
@@ -842,7 +849,7 @@ export async function runFromCli(argv, deps = {}) {
842
849
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
843
850
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
844
851
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
845
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
852
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
846
853
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
847
854
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
848
855
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1019,7 +1026,8 @@ function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i
1019
1026
  * `forgedock orchestrate --cancel <batch-id>`
1020
1027
  * Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
1021
1028
  * A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
1022
- * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
1029
+ * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`. Every start, requeues included, first
1030
+ * checks GitHub (#4481, `makeDispatchCheck`): a closed issue or a merged `feat/issue-N-lean` PR ends the row without a lane.
1023
1031
  * Declared acceptance probes (#4399, `lean/probe.mjs`) are swept once at batch start unless `--no-probes`.
1024
1032
  * Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
1025
1033
  * mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
@@ -1294,7 +1302,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1294
1302
  const branches = readBranches(cwd);
1295
1303
  // #4389: the post-deploy gate's default branch is resolved and fetched once, only when a gated issue asks
1296
1304
  const prefilter = argv.includes("--no-prefilter") ? null
1297
- : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd) });
1305
+ : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv), defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd, configured: branches.default }), preconditionCmd: readPreconditionCmd(cwd), minAge: await readPostDeployMinAge(cwd) });
1298
1306
  const fetched = new Set();
1299
1307
  const laneOf = async (n) => {
1300
1308
  const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
@@ -1310,7 +1318,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1310
1318
  const { runCore } = deps.leanModule;
1311
1319
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1312
1320
  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,
1313
- 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) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1321
+ 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) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1314
1322
  } else {
1315
1323
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1316
1324
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1480,7 +1488,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1480
1488
  beforeAdmit,
1481
1489
  issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getCancelled: batch.readCancel, ...lanes,
1482
1490
  breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
1483
- ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1491
+ ...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
1484
1492
  ...(remote ? { remote } : {}),
1485
1493
  // #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
1486
1494
  ...(recoveryOn ? { recovery: orch.recovery } : {}),
@@ -1491,6 +1499,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1491
1499
  }),
1492
1500
  // A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
1493
1501
  relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
1502
+ // #4481: a requeued or resumed issue that was closed or whose lane PR merged meanwhile is not run again
1503
+ dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
1494
1504
  // #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
1495
1505
  issueLabels: labelNames,
1496
1506
  // #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
@@ -1550,6 +1560,38 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1550
1560
  return { rows, crossLane, batch: batch.id, reclaimed, ok };
1551
1561
  }
1552
1562
 
1563
+ /**
1564
+ * #4481: the dispatch-time "already done" check: `{outcome:"merged", pr}` when the issue's own lane PR
1565
+ * (`feat/issue-N-lean`) merged, `{outcome:"invalid"}` when the issue is closed without one, else null. Anything
1566
+ * unreadable (no state, non-JSON, a non-array PR list, a gh error) is null: the lane runs.
1567
+ * Release review #4500: an OPEN issue reopened after that PR merged (a failed acceptance probe, #4399, or an operator
1568
+ * reopening it for rework) is new work, so the lane runs; an unreadable reopen history also runs the lane.
1569
+ */
1570
+ export function makeDispatchCheck({ gh, repo }) {
1571
+ const json = async (args) => { try { return JSON.parse(String(await gh(args))); } catch { return null; } };
1572
+ /** Latest `reopened` event time (ms) on the issue, 0 when never reopened, null when unreadable. */
1573
+ const lastReopen = async (n) => {
1574
+ try {
1575
+ const out = String(await gh(["api", `repos/${repo}/issues/${n}/events`, "--paginate", "--jq", '.[] | select(.event == "reopened") | .created_at']));
1576
+ return out.split("\n").map((l) => Date.parse(l.trim())).filter(Number.isFinite).reduce((a, b) => Math.max(a, b), 0);
1577
+ } catch { return null; }
1578
+ };
1579
+ return async (n) => {
1580
+ const iss = await json(["issue", "view", String(n), "-R", repo, "--json", "state"]);
1581
+ const state = typeof iss?.state === "string" ? iss.state.toUpperCase() : null;
1582
+ const prs = await json(["pr", "list", "-R", repo, "--head", `feat/issue-${n}-lean`, "--state", "merged", "--json", "number,mergedAt", "--limit", "1"]);
1583
+ const pr = Array.isArray(prs) && Number.isInteger(prs[0]?.number) ? prs[0].number : null;
1584
+ if (pr !== null && state === "OPEN") {
1585
+ const mergedAt = Date.parse(prs[0]?.mergedAt ?? "");
1586
+ const reopened = await lastReopen(n);
1587
+ if (reopened === null || !Number.isFinite(mergedAt) || reopened > mergedAt) return null;
1588
+ }
1589
+ if (pr !== null) return { outcome: "merged", pr, detail: `PR #${pr} already merged` };
1590
+ if (state === "CLOSED") return { outcome: "invalid", detail: "issue closed" };
1591
+ return null;
1592
+ };
1593
+ }
1594
+
1553
1595
  /**
1554
1596
  * #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
1555
1597
  * never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
@@ -1834,7 +1876,7 @@ export async function triageFromCli(argv, deps = {}) {
1834
1876
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
1835
1877
  const { prefilterIssue, formatPrefilterSummary, formatParkedLine, lazyDefaultBranch } = await import("./lean/prefilter.mjs");
1836
1878
  const branches = readBranches();
1837
- const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd() }; // #4389
1879
+ const gate = { defaultBranch: lazyDefaultBranch({ io: leanIo, repo, cwd: process.cwd(), configured: branches.default }), preconditionCmd: readPreconditionCmd(), minAge: await readPostDeployMinAge() }; // #4389, #4425
1838
1880
  const parallel = flag(argv, "--parallel") == null ? 8 : parseInt(flag(argv, "--parallel"), 10);
1839
1881
  if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
1840
1882
  // #4141: bounded pool. Each lane is fetched once (memoized promise); output stays in input order.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * Re-read nudge (#4456): a Claude CLI PostToolUse hook the lean engine passes to builder sessions with `--settings`
6
+ * (bin/lean/effects.mjs rereadNudgeFlags). It counts reads per path for one session, using the #4410 telemetry's
7
+ * definition of a read (toolstats readPaths), and on exactly the REREAD_MIN-th read of a path it returns an
8
+ * `additionalContext` system reminder. Advisory only: it always exits 0 and an error produces no output.
9
+ * State: {path: count} in <tmpdir>/forgedock-reread/<session_id>.json (a per-run cache).
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { readPaths, REREAD_MIN } from "../lean/toolstats.mjs";
16
+
17
+ /** Paths one hook payload reads, resolved against the payload cwd so `sed -n … a.js` and Read `/abs/a.js` agree. */
18
+ export function payloadReads(payload) {
19
+ const inp = payload?.tool_input ?? {};
20
+ const path = inp.file_path ?? inp.path;
21
+ const u = { name: payload?.tool_name ?? null, path: typeof path === "string" ? path : null, cmd: typeof inp.command === "string" ? inp.command : null };
22
+ const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : null;
23
+ return [...new Set(readPaths(u).map((p) => (cwd ? resolve(cwd, p) : p)))];
24
+ }
25
+
26
+ /** Pure core: counts after this payload and the reminder to emit (null when no path just reached REREAD_MIN). */
27
+ export function nudgeFor(payload, counts = {}) {
28
+ const next = { ...counts }, hot = [];
29
+ for (const p of payloadReads(payload)) {
30
+ next[p] = (next[p] ?? 0) + 1;
31
+ if (next[p] === REREAD_MIN) hot.push(p);
32
+ }
33
+ const message = hot.length ? `${hot.join(", ")} ${hot.length > 1 ? "have" : "has"} now been read ${REREAD_MIN} times in this session: `
34
+ + "use your notes or the excerpt already in context; if you need a different part, read only that line range." : null;
35
+ return { counts: next, message };
36
+ }
37
+
38
+ export const stateFile = (sessionId, dir = join(tmpdir(), "forgedock-reread")) =>
39
+ join(dir, `${String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "session"}.json`);
40
+
41
+ /** One hook call: stdin JSON in, the hookSpecificOutput line (or nothing) out. Fails open. */
42
+ export function main(input, { dir } = {}) {
43
+ try {
44
+ const payload = JSON.parse(input);
45
+ if (!payload?.session_id) return "";
46
+ if (!payloadReads(payload).length) return "";
47
+ const file = stateFile(payload.session_id, dir);
48
+ let counts = {};
49
+ try { counts = JSON.parse(readFileSync(file, "utf8")) ?? {}; } catch { /* first read of this session */ }
50
+ const r = nudgeFor(payload, counts);
51
+ mkdirSync(dirname(file), { recursive: true });
52
+ writeFileSync(file, JSON.stringify(r.counts));
53
+ return r.message ? `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: r.message } })}\n` : "";
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
60
+ let input = "";
61
+ process.stdin.setEncoding("utf8");
62
+ process.stdin.on("data", (c) => { input += c; });
63
+ process.stdin.on("end", () => { process.stdout.write(main(input)); process.exitCode = 0; });
64
+ process.stdin.on("error", () => { process.exitCode = 0; });
65
+ }
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
11
- import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
11
+ import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman, makeDispatchCheck } from "../engine-cli.mjs";
12
12
 
13
13
  // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
14
14
  process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
@@ -586,3 +586,46 @@ describe("#4454: orchestrate's shutdown signal (schedd stop)", () => {
586
586
  assert.ok(Date.now() - t0 < 1000);
587
587
  });
588
588
  });
589
+
590
+ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", () => {
591
+ it("a requeued issue whose PR merged meanwhile ends merged without a second lane", async () => {
592
+ const { now, sleep } = fakeClock();
593
+ let runs = 0, checks = 0;
594
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep,
595
+ dispatchCheck: async () => (++checks === 1 ? null : { outcome: "merged", pr: 77, detail: "PR #77 already merged" }),
596
+ runOne: async () => { runs++; return { terminalReason: "deferred", code: "host.busy", retryInSeconds: 60 }; } });
597
+ assert.deepEqual([runs, checks], [1, 2], "checked on every start, the requeue included");
598
+ assert.deepEqual([rows[0].outcome, rows[0].pr], ["merged", 77]);
599
+ assert.match(rows[0].detail, /skipped at dispatch: PR #77 already merged/);
600
+ });
601
+
602
+ it("a closed issue ends invalid without a lane and its dependents still run; a throwing check runs the lane", async () => {
603
+ const ran = [];
604
+ const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "Depends on #1" : ""),
605
+ dispatchCheck: async (n) => (n === 1 ? { outcome: "invalid", detail: "issue closed" } : (() => { throw new Error("gh down"); })()),
606
+ runOne: async (n) => { ran.push(n); return { terminalReason: "merged", cost_usd: 0 }; } });
607
+ assert.deepEqual(ran, [2]);
608
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "invalid"], [2, "merged"]]);
609
+ });
610
+
611
+ it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
612
+ const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
613
+ if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
614
+ if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
615
+ assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
616
+ if (prs instanceof Error) throw prs;
617
+ return prs;
618
+ } });
619
+ const merged = '[{"number":12,"mergedAt":"2026-10-23T08:00:00Z"}]';
620
+ assert.deepEqual(await mk('{"state":"OPEN"}', merged)(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "open, never reopened: the merge is still closing it");
621
+ assert.deepEqual(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n")(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "reopened before the merge");
622
+ assert.equal(await mk('{"state":"OPEN"}', merged, "2026-10-22T08:00:00Z\n2026-10-23T09:00:00Z\n")(9), null, "reopened after the merge (failed probe, rework): the lane runs");
623
+ assert.equal(await mk('{"state":"OPEN"}', merged, new Error("api down"))(9), null, "unreadable reopen history: the lane runs");
624
+ assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
625
+ assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
626
+ assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
627
+ assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
628
+ assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
629
+ assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
630
+ });
631
+ });
@@ -21,7 +21,8 @@ import { parseFactsComment, factsLines, FACTS_RANK } from "./facts.mjs";
21
21
  // noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
22
22
  export { noComments };
23
23
 
24
- export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
24
+ /** #4444: `ab` is the experiment mode: each issue gets a stable arm, `on` or `shadow` (contextArm). */
25
+ export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off", "ab"]);
25
26
  export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
26
27
  export const DEFAULT_CONTEXT_BYTES = 6144;
27
28
  export const DEFAULT_LEDGER_DAYS = 30;
@@ -53,6 +54,16 @@ export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLane
53
54
  return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS), pitfallLanes: pos(pitfallLanes, DEFAULT_PITFALL_LANES) };
54
55
  }
55
56
 
57
+ /**
58
+ * #4444: the effective mode of one lane. In `ab` the arm comes from a stable hash of `repo#issue`, so every rerun of an
59
+ * issue stays in its arm and the arms are random, matched cohorts per lane type; any other mode passes through.
60
+ */
61
+ export function contextArm({ mode, repo = "", issue } = {}) {
62
+ if (mode !== "ab") return { mode, ab: false };
63
+ const byte = createHash("sha256").update(`${String(repo).toLowerCase()}#${issue}`).digest()[0];
64
+ return { mode: byte % 2 === 0 ? "on" : "shadow", ab: true };
65
+ }
66
+
56
67
  /** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
57
68
  export function parentOf(body) {
58
69
  const m = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?parent(?:\*\*|__)?[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*#(\d+)\b/im.exec(stripComments(body));
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
6
- import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
6
+ import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
7
7
 
8
8
  const REPO = "o/r";
9
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -358,6 +358,23 @@ describe("#4298 context-pack cache and shared names", () => {
358
358
  writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
359
359
  assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
360
360
  });
361
+
362
+ it("#4444: ab is a valid mode, and contextArm gives each issue a stable on|shadow arm", async () => {
363
+ const { readContextPack } = await import("../engine-cli.mjs");
364
+ const { tmpDir } = await import("../tests/_tmp.mjs");
365
+ const { writeFileSync } = await import("node:fs");
366
+ const d = tmpDir("ctxab-"), warns = [];
367
+ writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: ab\n");
368
+ assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "ab" });
369
+ assert.equal(warns.length, 0);
370
+ assert.equal(parseContextPackConfig({ mode: "AB" }).mode, "ab");
371
+ assert.equal(parseContextPackConfig({}).mode, "shadow", "the default stays shadow");
372
+ for (const mode of ["shadow", "on", "off"]) assert.deepEqual(contextArm({ mode, repo: "o/r", issue: 1 }), { mode, ab: false });
373
+ const arms = Array.from({ length: 40 }, (_, i) => contextArm({ mode: "ab", repo: "o/r", issue: i + 1 }));
374
+ assert.ok(arms.every((a) => a.ab && ["on", "shadow"].includes(a.mode)));
375
+ assert.ok(arms.some((a) => a.mode === "on") && arms.some((a) => a.mode === "shadow"), "both arms are populated");
376
+ assert.deepEqual(contextArm({ mode: "ab", repo: "O/R", issue: 7 }), contextArm({ mode: "ab", repo: "o/r", issue: 7 }), "stable per issue");
377
+ });
361
378
  });
362
379
 
363
380
  describe("#4375 recurring pitfalls in the context pack", () => {
@@ -23,6 +23,7 @@ import { scopedChecks } from "./checks.mjs";
23
23
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
24
24
  import { followupMap } from "./conformance.mjs";
25
25
  import { toolStats } from "./toolstats.mjs";
26
+ import { detectApiShape } from "./apishape.mjs";
26
27
 
27
28
  const pexec = promisify(execFile);
28
29
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -60,12 +61,18 @@ export function wrapLaneLimits(bin, args, limits) {
60
61
  export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
61
62
  : ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
62
63
 
64
+ /** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
65
+ export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
66
+ export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash",
67
+ hooks: [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }] }] } })];
68
+
63
69
  export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
64
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
70
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
65
71
  const bin = resolveClaudeCliBinary(cwd) || "claude";
66
72
  const caps = getClaudeCliCapabilities(cwd);
67
73
  // #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
68
- const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
74
+ const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : []),
75
+ ...(rereadNudge && !readOnly && !noTools ? rereadNudgeFlags() : [])]; // #4456: builder sessions only
69
76
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
70
77
  args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
71
78
  logger: { log() {} },
@@ -316,6 +323,10 @@ export function makeLeanIo({ repo }) {
316
323
  async structureDelta({ cwd, base }) {
317
324
  try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
318
325
  },
326
+ /** #4450: public-API-shape changes of base...HEAD in cwd (bin/lean/apishape.mjs); any failure is {status: "unknown"} (an upgrade). */
327
+ async apiShape({ cwd, base }) {
328
+ try { return await detectApiShape({ cwd, base, head: "HEAD" }); } catch (e) { return { status: "unknown", changes: [], unknown: [{ file: "*", lang: null, reason: String(e?.message ?? e).slice(0, 200) }] }; }
329
+ },
319
330
  /** #4375: the repo's learned-pitfall ledger, local only (~/.forge/pitfalls, FORGE_PITFALLS_DIR). */
320
331
  pitfalls: makePitfallStore(),
321
332
  /** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
package/bin/lean/lean.mjs CHANGED
@@ -45,8 +45,10 @@ import { writeHeavyShims, sumWaits, isHeavyProcess } from "./heavy.mjs";
45
45
  import { priorityFromLabels } from "./sched.mjs";
46
46
  import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule } from "./explore.mjs";
47
47
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
48
- import { parseContextPackConfig, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
48
+ import { parseContextPackConfig, contextArm, contextPackFor, renderContextRecord, laneType, priorAttempts, linkStateStamp, stateOf } from "./context.mjs";
49
49
  import { buildResumeDigest } from "./resume.mjs";
50
+ import { lightEligibility } from "./light.mjs";
51
+ import { runLightStep, runLightTests, lightBuildPrompt, upgradeNote, renderLiteReceipt } from "./lightlane.mjs";
50
52
 
51
53
  /**
52
54
  * #4267: what a recovery lane does per code. Codebase-agnostic: the repo's own checks and logs only. Keys stay in step
@@ -232,6 +234,7 @@ export async function runCore(o) {
232
234
  ...(num(r?.baselineTokens) != null ? { baseline_tokens: num(r.baselineTokens) } : {}), ...(r?.tools ? { tools: r.tools } : {}), lane_profile: laneProfile.name }; // #4410
233
235
  if (phase === "implement") { builderTriggers.push({ trigger, restart_of: restartOf }); impl.cost += c.delta ?? 0; if (r?.model && r.model !== "default") impl.models.add(r.model); tb.note(num(r?.turns), stage === "implement"); }
234
236
  ev(rec); await emit(rec);
237
+ modelMs += rec.duration_ms; // #4451: model time next to the lane's wall time
235
238
  const u = r?.usage ?? {};
236
239
  usage.cost_usd += c.delta ?? 0;
237
240
  usage.input += u.input_tokens ?? 0; usage.output += u.output_tokens ?? 0;
@@ -273,7 +276,11 @@ export async function runCore(o) {
273
276
  // Pipeline trajectory (posted once at the end): phase rows, decisions, anomalies.
274
277
  const T = { issue, validate: null, plan: null, implement: null, review: null, pr: null, merge: null, close: null, decisions: [], anomalies: [] };
275
278
  const failureClasses = new Set(); // forge#4275: conformance failure codes seen this run (gate failures, shadow would-fail)
276
- const ctxCfg = parseContextPackConfig(o.contextPack ?? {});
279
+ const ctxBase = parseContextPackConfig(o.contextPack ?? {});
280
+ const ctxArm = contextArm({ mode: ctxBase.mode, repo, issue }); // #4444: ab picks a stable on|shadow arm per issue
281
+ const ctxCfg = { ...ctxBase, mode: ctxArm.mode };
282
+ const ctxAb = ctxArm.ab ? { context_ab: true } : {};
283
+ if (ctxArm.ab) T.decisions.push(`Context pack A/B (#4444): arm ${ctxArm.mode}`);
277
284
  const exploreCfg = parseExploreConfig(o.explore ?? {}); // #4391
278
285
  if (exploreCfg.mode === "on") T.decisions.push(`Explorer subagent (on): model ${exploreCfg.model}`);
279
286
  let ctxLane = null; // forge#4275: rerun | child | first, set once the issue is read
@@ -282,6 +289,8 @@ export async function runCore(o) {
282
289
  let shadowWould = null; // forge#4165: the shadow gate's verdict, carried on RUN_TERMINAL (no telemetry write of its own)
283
290
  let riskEnforced = null, gateRan = false; // #4398: declared risk paths that made a shadow lane enforce (sticky); the gate ran
284
291
  let done = null, finishing = null, wtCreated = false, salvagedRef = null;
292
+ let light = false, modelMs = 0; // #4451: the light lane finished without an upgrade (skips structure/hotspot gates); summed session time
293
+ let lightVerdict = null, lightUpgrade = null; // #4453: the eligibility verdict and the upgrade reason codes, on the run result for the bench
285
294
  let recoverUnlabelled = false; // #4297: the recovery lane removed needs-human; a final ci-pending (or needsHuman()) puts it back
286
295
  const runId = `${issue}-${t0}`;
287
296
  let runTmp = null;
@@ -399,7 +408,7 @@ export async function runCore(o) {
399
408
  // forge#4165: pr, milestone and the conformance verdict ride on the existing RUN_TERMINAL write (verdict-mix queries).
400
409
  const term = { event: "RUN_TERMINAL", reason, code, ...(extra.pr ? { pr: extra.pr } : {}), ...(milestone ? { milestone } : {}),
401
410
  conformance_mode: conformance === "enforce" || riskEnforced ? "enforce" : "shadow", ...(shadowWould ? { shadow_would: shadowWould } : {}),
402
- context_pack: ctxCfg.mode, explore: exploreCfg.mode, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
411
+ context_pack: ctxCfg.mode, ...ctxAb, explore: exploreCfg.mode, wall_ms: now() - t0, model_ms: modelMs, light, ...(ctxLane ? { lane_type: ctxLane } : {}), ...(pitMatched != null ? { pitfalls_matched: pitMatched } : {}), ...(tb.record() ? { turns: tb.record() } : {}),
403
412
  ...(route ? { builder_tier: route.tier, builder_class: route.class, builder_escalated: escalated, builder_model: route.model ?? "default" } : {}) }; // forge#4275: the on/off A/B groups runs by these; #4394: per-tier stats
404
413
  ev(term);
405
414
  await emit(term);
@@ -424,7 +433,8 @@ export async function runCore(o) {
424
433
  try { await post(["FORGE:TRAJECTORY", REGISTER_MARKER], renderTrajectory({ ...T, usage: usageLine() })); } catch { /* best effort */ }
425
434
  }
426
435
  await releaseLease(reason, code);
427
- 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,
436
+ done = { terminalReason: reason, detail, branch, worktree: wt, cost_usd: usage.cost_usd, tokens: { input: usage.input, output: usage.output, cache: usage.cache }, wall_ms: now() - t0, model_ms: modelMs,
437
+ light, ...(lightVerdict ? { light_eligibility: lightVerdict } : {}), ...(lightUpgrade ? { light_upgrade: lightUpgrade } : {}), // #4453
428
438
  ...(Object.keys(waits).length ? { waits: { ...waits } } : {}), ...(heavyQueueMax ? { heavyQueueMax } : {}), ...(precheckRan ? { precheckMs, precheckDeferred } : {}),
429
439
  ...(firstPassOf(builderTriggers) != null ? { firstPass: firstPassOf(builderTriggers) } : {}), ...(tb.record() ? { turns: tb.record() } : {}), // #4392
430
440
  ...(route ? { builderRoute: { tier: route.tier, class: route.class, escalated, model: route.model ?? "default" } } : {}),
@@ -672,7 +682,7 @@ export async function runCore(o) {
672
682
  const governor = startGovernor(control);
673
683
  let spawned = null;
674
684
  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 })); } };
675
- const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg),
685
+ const call = Promise.resolve(callRunner({ role: "builder", issue, cwd: wt, model: model ?? laneModel(), resume, system: "", prompt, control, onSpawn, agents: explorerAgents(exploreCfg), rereadNudge: true,
676
686
  idleMs: Math.round(cliIdleMinutes * 60000), maxMs: Math.round(cliMaxMinutes * 60000), laneLimits, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}),
677
687
  busy: sessionBusy, busyMs: Math.round(cliBusyMinutes * 60000),
678
688
  onIdleExtend: (info) => ev({ event: "CLI_IDLE_EXTENDED", children: info.children, heavyQueue: info.heavyQueue }) }));
@@ -1117,7 +1127,7 @@ export async function runCore(o) {
1117
1127
  const inPack = Array.isArray(p.pitfalls) ? p.pitfalls : [];
1118
1128
  pitMatched = inPack.filter((x) => x.match === "files").length;
1119
1129
  // #4375: which pitfalls were in the pack (signatures only, run log only: never telemetry or a comment)
1120
- const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
1130
+ const rec = { event: "CONTEXT_PACK", mode: ctxCfg.mode, ...ctxAb, lane_type: ctxLane, sources: p.sources, found: p.found, bytes: p.bytes, items: p.items, truncated: p.truncated,
1121
1131
  hash: p.hash, cached: r.cached, ...(r.errors?.length ? { errors: r.errors } : {}) };
1122
1132
  ev({ ...rec, pitfalls: inPack }); await emit(rec);
1123
1133
  const seen = trustedComments.some((c) => String(c?.body ?? "").startsWith("<!-- FORGE:CONTEXT -->") && String(c.body).includes(`hash ${p.hash})`));
@@ -1356,13 +1366,55 @@ export async function runCore(o) {
1356
1366
  }
1357
1367
  }
1358
1368
  await label("building");
1359
- hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
1369
+ // #4450: the light lane (lean.light mode on, eligible, fresh lane): one builder session, derived contract, or an upgrade
1370
+ const elig = lightEligibility({ title: iss.title, body: iss.body ?? "", labels: iss.labels ?? [], predictedFiles: namedPaths(iss.body ?? ""), riskPaths, cfg: o.light ?? {} });
1371
+ ev({ event: "LIGHT_ELIGIBILITY", mode: elig.mode, verdict: elig.verdict, light: elig.light, reasons: elig.reasons });
1372
+ lightVerdict = elig.verdict;
1373
+ if (elig.mode !== "off") T.decisions.push(`Light lane ${elig.mode}: ${elig.verdict}${elig.reasons.length ? ` (${elig.reasons.map((x) => x.code).join(", ")})` : ""}`);
1374
+ // #4451: a light try skips the hotspots and the context pack; an upgrade computes them before the plan call
1375
+ const lightTry = elig.light && !frozen && !resumed && !salvagedRef && !recover;
1376
+ if (!lightTry) hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") });
1360
1377
  enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
1361
1378
  facts = await factsPath([wt, cwd], io.fs?.exists);
1362
- const ctxPack = frozen ? null : await contextPackStep();
1379
+ let ctxPack = frozen || lightTry ? null : await contextPackStep();
1363
1380
  const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
1381
+ let lightDone = false, lightCommits = false;
1382
+ if (lightTry) {
1383
+ liveKeys = ["implementation"]; stage = "implement";
1384
+ tb.setSize(laneSize({ labels: (iss?.labels ?? []).map((l) => l?.name ?? l) }));
1385
+ const lr = await runLightStep({ title: iss.title, issueText, riskPaths, cfg: o.light ?? {}, validate: (c) => validateContract(c, issueText, { repro: reproOn() }),
1386
+ build: () => builder(++attemptN, lightBuildPrompt(issue, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList), null, true, undefined, { trigger: "build" }),
1387
+ readResult, checkpoint: () => checkpoint("light build"), changedFiles, reproOf, numstat: () => git(["diff", "--numstat", `${base}...HEAD`]),
1388
+ runTests: async (cmds) => (io.runCommand ? runLightTests({ commands: cmds, parallelism: Number(verifyParallelism()) || 1, now, acquire: () => heavyAcquire("precheck"), // #4451: host-capped
1389
+ run: ({ command, workers }) => { const env = laneEnv(); return io.runCommand({ command, cwd: wt, env: env.FORGE_HOST_PARALLELISM ? { ...env, FORGE_HOST_PARALLELISM: String(workers) } : env }); } }) : null),
1390
+ fixTests: (failed) => builder(++attemptN, precheckFailurePrompt(capLogs(failed.map((f) => ({ ...f, log: f.output }))).map((f) => ({ command: f.command, rc: f.rc, output: f.log })), vcfg.mode), sessionId, false, undefined, { mode: "resume", trigger: "precheck" }),
1391
+ apiShape: () => (io.apiShape ? io.apiShape({ cwd: wt, base }).catch(() => null) : null) });
1392
+ stage = "plan"; liveKeys = ["investigation"]; result = (await readResult()) ?? result;
1393
+ if (!lr.upgrade) {
1394
+ lightDone = true; light = true; frozen = lr.contract; hash = contractHash(contractJson(frozen));
1395
+ let body = renderContract(frozen, hash, { scope: scopeHash });
1396
+ if (body.length > MAX_COMMENT_CHARS - 500) body = renderContract(frozen, hash, { compact: true, scope: scopeHash });
1397
+ await post(["FORGE:CONTRACT", "FORGE:PLAN"], body);
1398
+ ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: 0, derived: "light" });
1399
+ ev({ event: "LIGHT_DONE", files: lr.files.length, test_failures: lr.testFailures, repro: lr.repro, cost_usd: usage.cost_usd });
1400
+ T.decisions.push(`Light lane: one builder session, contract ${hash} derived from the diff and the test`);
1401
+ try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd, contractHash: hash })); } catch { /* #4452: the receipt is best effort */ }
1402
+ } else {
1403
+ lightCommits = lr.files.length > 0;
1404
+ ev({ event: "LIGHT_UPGRADE", reasons: lr.reasons, cost_usd: usage.cost_usd });
1405
+ lightUpgrade = lr.reasons.map((x) => x.code);
1406
+ T.decisions.push(`Light lane upgraded to the full lane: ${lr.reasons.map((x) => x.code).join(", ")}`);
1407
+ try { await post("FORGE:LITE", renderLiteReceipt({ lr, eligibility: elig, costUsd: usage.cost_usd })); } catch { /* #4452: the receipt is best effort */ }
1408
+ const d = buildResumeDigest({ state: null, comments: trustedComments, contract: null, files: lr.files, lane });
1409
+ resumeNote = d.text + upgradeNote(lr.reasons);
1410
+ result = null; // the light result.json must not read as the plan's verdict
1411
+ if (io.fs.write) { try { await io.fs.write(`${wt}/${RESULT_FILE}`, "{}\n"); } catch { /* the plan overwrites it */ } }
1412
+ hotFacts = await hotspotsOf({ paths: namedPaths(iss.body ?? "") }); // #4451: the full lane keeps its plan facts
1413
+ ctxPack = await contextPackStep();
1414
+ }
1415
+ }
1364
1416
  if (!frozen) {
1365
- await builder(++attemptN, planPrompt() + (hasCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
1417
+ await builder(++attemptN, planPrompt() + (hasCommits || lightCommits ? PLAN_RERUN_NOTE.replace("BASE", lane) : "") + recoverNote() + resumeNote, null, true, planPrompt, { trigger: "plan" });
1366
1418
  planned = true;
1367
1419
  result = await readResult();
1368
1420
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
@@ -1427,9 +1479,9 @@ export async function runCore(o) {
1427
1479
  ev({ event: "CONTRACT_FROZEN", hash, requirements: frozen.requirements.length, deferred: deferredN });
1428
1480
  T.decisions.push(`Contract frozen (${hash}): ${frozen.requirements.length} requirement(s), ${deferredN} deferred pending review`);
1429
1481
  } else {
1430
- T.validate = "resumed (existing branch)";
1482
+ T.validate = lightDone ? "light lane (no plan session)" : "resumed (existing branch)";
1431
1483
  }
1432
- T.plan = [`contract ${hash}`, `${frozen.requirements.length} requirement(s)`];
1484
+ T.plan = [`contract ${hash}${lightDone ? " (derived, light lane)" : ""}`, `${frozen.requirements.length} requirement(s)`];
1433
1485
  tb.setSize(laneSize({ contract: frozen, labels: (iss?.labels ?? []).map((l) => l?.name ?? l) })); // #4392
1434
1486
  if (io.fs.write) { try { await io.fs.write(`${wt}/${CONTRACT_FILE}`, `${contractJson(frozen)}\n`); } catch { /* the prompt still names it */ } }
1435
1487
  // #4364: what `forgedock check` needs from the operator's forge.yaml (gitignored, so not in the worktree)
@@ -1443,7 +1495,7 @@ export async function runCore(o) {
1443
1495
  const implementPrompt = builderImplementPrompt(vcfg.mode, tcOpts()) + hotspotBuildRule(hotFacts) + enumeratingBuildRule(enumList) + exploreBuildRule(exploreCfg) + (planned ? "" : (fromCheckpoint ? CONTINUE_NOTE : RERUN_NOTE).replace("BASE", lane)) + (salvagedRef ? SALVAGE_NOTE.replaceAll("REF", salvagedRef) : "") + recoverNote() + (planned ? "" : resumeNote);
1444
1496
  liveKeys = ["implementation"];
1445
1497
  stage = "implement";
1446
- await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
1498
+ if (!lightDone) await builder(++attemptN, implementPrompt, planned ? sessionId : null, true, undefined, { trigger: "build" });
1447
1499
  stage = "fix"; // later builder rounds fix precheck, CI, structure, docs or conformance findings
1448
1500
  result = await readResult();
1449
1501
  { // #4374: the builder's self-review against the shared rubric, log only
@@ -1530,7 +1582,7 @@ export async function runCore(o) {
1530
1582
  const d = await io.structureDelta({ cwd: wt, base }).catch(() => null);
1531
1583
  return d && typeof d === "object" && Array.isArray(d.package_edges_added) ? d : null; // `{}`: no workspace packages
1532
1584
  };
1533
- let structure = await structureOf();
1585
+ let structure = light ? null : await structureOf(); // #4451: no structure delta on a light lane
1534
1586
  if (structure) {
1535
1587
  const pairs = structure.import_pairs ?? [], sum = (k) => pairs.reduce((n, p) => n + (Number(p?.[k]) || 0), 0);
1536
1588
  const rec = { event: "STRUCTURE_DELTA", edges_added: structure.package_edges_added.length, edges_removed: (structure.package_edges_removed ?? []).length,
@@ -1872,7 +1924,7 @@ export async function runCore(o) {
1872
1924
  }
1873
1925
  const amendProblems = await amend();
1874
1926
  const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === String(await git(["rev-parse", "HEAD"])).trim();
1875
- const hotspots = await hotspotsOf({ base, paths: files });
1927
+ const hotspots = light ? null : await hotspotsOf({ base, paths: files }); // #4451
1876
1928
  const validateRuns = await rerunFailedValidates(effective);
1877
1929
  const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
1878
1930
  const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen });
@@ -2234,7 +2286,7 @@ export async function runCore(o) {
2234
2286
  * only: the single telemetry write is the run's one extra API call. Also feeds the trajectory's Structure table.
2235
2287
  */
2236
2288
  async function recordStructure(pr) {
2237
- const d = (await structureOf()) ?? {};
2289
+ const d = (light ? null : await structureOf()) ?? {}; // #4451
2238
2290
  const numstat = String(await git(["diff", "--numstat", "--no-renames", `${base}...HEAD`]).catch(() => ""));
2239
2291
  const top_growth = numstat.split("\n").map((l) => l.split("\t")).filter((x) => x.length >= 3 && /^\d+$/.test(x[0]) && Number(x[0]) > 0 && !x[2].startsWith(`${LEAN_DIR}/`))
2240
2292
  .map(([a, , file]) => ({ file, lines: Number(a) })).sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file)).slice(0, 5);