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.
@@ -85,17 +85,55 @@ export function contextPackAb(runs) {
85
85
  for (const mode of ["on", "off", "shadow"]) {
86
86
  const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
87
87
  out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
88
- cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)) };
88
+ cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)), ...perMerge(rs, lt) };
89
89
  }
90
90
  }
91
+ out.recovery = recoveryVsFirst(runs);
91
92
  return out;
92
93
  }
93
94
 
95
+ const round4 = (v) => Math.round(v * 1e4) / 1e4;
96
+ /**
97
+ * #4444: per-merge A/B metrics of one cell: builder turns and cache-read over the cell's runs divided by its merges (null
98
+ * without a merge), and rework rate: runs that were remediated, followed an earlier merge, or are a rerun lane, over n.
99
+ */
100
+ function perMerge(rs, lt) {
101
+ const merged = rs.filter((r) => r.reason === "merged").length;
102
+ const sum = (f) => rs.reduce((x, r) => x + (Number.isFinite(f(r)) ? f(r) : 0), 0);
103
+ const reworked = rs.filter((r) => r.remediated || (r.reworkedMerges?.length ?? 0) > 0 || lt === "rerun").length;
104
+ return { merged, turns_per_merge: merged ? round4(sum((r) => r.builderTurns) / merged) : null,
105
+ cache_read_per_merge: merged ? round4(sum((r) => r.cacheReadTokens) / merged) : null, rework_rate: rs.length ? round4(reworked / rs.length) : null };
106
+ }
107
+
108
+ /**
109
+ * #4444 (R8): a rerun lane's implement attempt against the first run on the same issue. The baseline is the earlier
110
+ * first/child slice of the rerun's own log (firstRunAttempt), else the earliest finished first/child run of the same
111
+ * repo+issue in `runs`. Ratios are rerun / first per pair (a zero baseline is skipped); null without a pair.
112
+ */
113
+ export function recoveryVsFirst(runs) {
114
+ const key = (r) => `${String(r?.repo ?? "").toLowerCase()}#${r?.issue}`;
115
+ const firsts = new Map();
116
+ for (const r of [...runs].filter((x) => x?.finished && ["first", "child"].includes(x.contextPack?.laneType) && x.implementAttempt)
117
+ .sort((a, b) => String(a.startTs ?? "").localeCompare(String(b.startTs ?? "")))) if (!firsts.has(key(r))) firsts.set(key(r), r.implementAttempt);
118
+ const turnR = [], outR = [];
119
+ for (const r of runs) {
120
+ if (!r?.finished || r.contextPack?.laneType !== "rerun" || !r.implementAttempt) continue;
121
+ const base = r.firstRunAttempt ?? firsts.get(key(r));
122
+ if (!base) continue;
123
+ if (base.turns > 0) turnR.push(r.implementAttempt.turns / base.turns);
124
+ if (base.output_tokens > 0) outR.push(r.implementAttempt.output_tokens / base.output_tokens);
125
+ }
126
+ const mean = (xs) => (xs.length ? round4(xs.reduce((a, b) => a + b, 0) / xs.length) : null);
127
+ return { pairs: Math.max(turnR.length, outR.length), mean_turn_ratio: mean(turnR), mean_output_ratio: mean(outR) };
128
+ }
129
+
94
130
  /** Markdown table for contextPackAb. */
95
131
  export function renderContextAb(ab) {
96
132
  const f = (v) => (v == null ? "n/a" : String(v));
97
- const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} |`; }));
98
- return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}`;
133
+ const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} | ${f(g.merged)} | ${f(g.turns_per_merge)} | ${f(g.cache_read_per_merge)} | ${f(g.rework_rate)} |`; }));
134
+ const rc = ab.recovery; // #4444: R8, rerun implement attempt vs the first run on the same issue
135
+ const rec = rc ? `\n\nRecovery vs first run (same issue): ${rc.pairs} pair(s), implement turns ratio ${f(rc.mean_turn_ratio)}, output tokens ratio ${f(rc.mean_output_ratio)}` : "";
136
+ return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD | Merged | Turns/merge | Cache-read/merge | Rework rate |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}${rec}`;
99
137
  }
100
138
 
101
139
  /**
@@ -145,3 +183,95 @@ export function renderTiers(rep) {
145
183
  const rows = (rep?.rows ?? []).map((g) => `| ${g.tier} | ${g.class} | ${g.n} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) | ${f(g.post_merge_rework_rate)} (${g.merges}) | ${f(g.first_pass_yield)} | ${f(g.cost_usd)} |`);
146
184
  return `| Tier | Class | n | Merge rate | Conformance rate (reviewed) | Post-merge rework (merges) | First-pass yield | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.length ? rows.join("\n") : "| none | | 0 | | | | | |"}\n\n${rep?.unrouted ?? 0} finished run(s) without a builder route (before #4394) left out.`;
147
185
  }
186
+
187
+ /**
188
+ * forge#4418: the explorer A/B (#4391) over per-slice run summaries (local-source.mjs readLocalLaneRuns), per size bucket
189
+ * (RUN_TERMINAL turns.size, set before the build) and RUN_TERMINAL `explore` mode. builder_turns / builder_cache_read:
190
+ * means over the plan + build calls. merge_rate: merged / n. conformance_rate: as builderTierReport. `paired` keeps only
191
+ * issues with a finished run in both modes. Untagged runs (before #4391) are counted, never folded into `off`.
192
+ */
193
+ export const EXPLORE_SIZES = Object.freeze(["S", "M", "L", "unsized"]);
194
+ const EXPLORE_MODES = ["on", "off"];
195
+ export function exploreAb(runs) {
196
+ const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
197
+ const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
198
+ const finished = (runs ?? []).filter((r) => r?.finished);
199
+ const tagged = finished.filter((r) => EXPLORE_MODES.includes(r.explore));
200
+ const stats = (rs) => {
201
+ const reviewed = rs.filter((r) => r.reachedReview);
202
+ const conforming = reviewed.filter((r) => !r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms"));
203
+ return { n: rs.length, builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)), builder_cache_read: mean(rs.map((r) => r.builderCacheRead ?? 0)),
204
+ merge_rate: rate(rs.filter((r) => r.reason === "merged").length, rs.length), conformance_rate: rate(conforming.length, reviewed.length), reviewed: reviewed.length };
205
+ };
206
+ const bySize = {};
207
+ for (const size of EXPLORE_SIZES) {
208
+ bySize[size] = {};
209
+ for (const mode of EXPLORE_MODES) bySize[size][mode] = stats(tagged.filter((r) => (EXPLORE_SIZES.includes(r.size) ? r.size : "unsized") === size && r.explore === mode));
210
+ }
211
+ const key = (r) => `${r.repo ?? ""}#${r.issue}`;
212
+ const both = new Set(tagged.filter((r) => r.explore === "on").map(key).filter((k) => tagged.some((r) => r.explore === "off" && key(r) === k)));
213
+ const pairedRuns = tagged.filter((r) => both.has(key(r)));
214
+ const paired = { issues: both.size };
215
+ for (const mode of EXPLORE_MODES) paired[mode] = stats(pairedRuns.filter((r) => r.explore === mode));
216
+ return { by_size: bySize, paired, untagged: finished.length - tagged.length };
217
+ }
218
+
219
+ /** Markdown tables for exploreAb. */
220
+ export function renderExploreAb(ab) {
221
+ const f = (v) => (v == null ? "n/a" : String(v));
222
+ const row = (label, mode, g) => `| ${label} | ${mode} | ${g.n} | ${f(g.builder_turns)} | ${f(g.builder_cache_read)} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) |`;
223
+ const head = "| Size | Explore | n | Builder turns | Builder cache-read | Merge rate | Conformance rate (reviewed) |\n| --- | --- | --- | --- | --- | --- | --- |";
224
+ const sizes = EXPLORE_SIZES.flatMap((s) => EXPLORE_MODES.map((m) => row(s, m, ab.by_size[s][m])));
225
+ const paired = EXPLORE_MODES.map((m) => row("paired", m, ab.paired[m]));
226
+ return `${head}\n${sizes.join("\n")}\n\nSame issues in both modes: ${ab.paired.issues}\n\n${head}\n${paired.join("\n")}\n\n${ab.untagged} finished run(s) without an explore tag (before #4391) left out.`;
227
+ }
228
+
229
+ /**
230
+ * forge#4436: the #4395 lane baseline before/after, over local run summaries (every RUN_START slice). Finished runs,
231
+ * optionally pinned to an issue set, grouped by lane profile (null → unknown). Per profile and phase: fresh-session
232
+ * first-turn baseline_tokens (sessions, median, mean). Per profile: conformance_rate (the builderTierReport rule) and
233
+ * shadow_blocks (reviewed runs with a would-block verdict). issues_in_all: issues with a finished run under every profile.
234
+ */
235
+ export const LANE_PROFILES = Object.freeze(["full", "memory-off", "lane", "mixed", "unknown"]);
236
+ export function laneProfileReport(runs, { issues = null } = {}) {
237
+ const pin = Array.isArray(issues) && issues.length ? new Set(issues) : null;
238
+ const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
239
+ const groups = new Map();
240
+ for (const r of runs ?? []) {
241
+ if (!r?.finished || (pin && !pin.has(r.issue))) continue;
242
+ const name = typeof r.laneProfile === "string" && r.laneProfile ? r.laneProfile : "unknown";
243
+ const g = groups.get(name) ?? { profile: name, n: 0, issues: new Set(), reviewed: 0, conforming: 0, shadow_blocks: 0, phases: {} };
244
+ groups.set(name, g);
245
+ g.n++;
246
+ if (r.issue != null) g.issues.add(r.issue);
247
+ if (r.reachedReview) {
248
+ g.reviewed++;
249
+ if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++;
250
+ if (typeof r.shadowWould === "string" && r.shadowWould !== "conforms") g.shadow_blocks++;
251
+ }
252
+ for (const [phase, xs] of Object.entries(r.baselines ?? {})) (g.phases[phase] ??= []).push(...xs.filter(Number.isFinite));
253
+ }
254
+ const stat = (xs) => {
255
+ if (!xs.length) return { median: null, mean: null };
256
+ const s = [...xs].sort((a, b) => a - b), m = s.length >> 1;
257
+ return { median: Math.round(s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2), mean: Math.round(s.reduce((a, b) => a + b, 0) / s.length) };
258
+ };
259
+ const rank = (p) => { const i = LANE_PROFILES.indexOf(p); return i < 0 ? LANE_PROFILES.length : i; };
260
+ const profiles = [...groups.values()].sort((a, b) => rank(a.profile) - rank(b.profile) || a.profile.localeCompare(b.profile))
261
+ .map((g) => ({ profile: g.profile, n: g.n, issues: [...g.issues].sort((a, b) => a - b), reviewed: g.reviewed, conformance_rate: rate(g.conforming, g.reviewed),
262
+ shadow_blocks: g.shadow_blocks, phases: Object.keys(g.phases).sort().map((phase) => ({ phase, sessions: g.phases[phase].length, ...stat(g.phases[phase]) })) }));
263
+ const issuesInAll = profiles.length ? profiles[0].issues.filter((i) => profiles.every((p) => p.issues.includes(i))) : [];
264
+ return { profiles, issues_in_all: issuesInAll };
265
+ }
266
+
267
+ /** Markdown tables for laneProfileReport. */
268
+ export function renderLaneProfiles(rep) {
269
+ const f = (v) => (v == null ? "n/a" : String(v));
270
+ const ps = rep?.profiles ?? [];
271
+ const base = ps.flatMap((p) => (p.phases.length ? p.phases : [{ phase: "n/a", sessions: 0, median: null, mean: null }])
272
+ .map((x) => `| ${p.profile} | ${x.phase} | ${x.sessions} | ${f(x.median)} | ${f(x.mean)} |`));
273
+ const conf = ps.map((p) => `| ${p.profile} | ${p.n} | ${f(p.conformance_rate)} (${p.reviewed}) | ${p.shadow_blocks} |`);
274
+ return `| Profile | Phase | Sessions | Median baseline | Mean baseline |\n| --- | --- | --- | --- | --- |\n${base.length ? base.join("\n") : "| none | | 0 | | |"}\n\n`
275
+ + `| Profile | Runs | Conformance rate (reviewed) | Shadow would-block |\n| --- | --- | --- | --- |\n${conf.length ? conf.join("\n") : "| none | 0 | | |"}\n\n`
276
+ + `Issues with a finished run under every profile: ${rep?.issues_in_all?.length ? rep.issues_in_all.map((i) => `#${i}`).join(", ") : "none"}`;
277
+ }
@@ -12,9 +12,9 @@ import { aggregate, compareVersions, computeAlerts, DEFAULT_ALERTS, percentile }
12
12
  import { cacheDir } from "./cache.mjs";
13
13
  import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
14
14
  import { buildReport, makeProgressPrinter, parseArgs, parseSince, readAlertConfig, runStats, sinceToTimestamp } from "./index.mjs";
15
- import { lanesOfLog, readLanes, readLocalRuns, summariseLog } from "./local-source.mjs";
15
+ import { lanesOfLog, readLanes, readLocalLaneRuns, readLocalRuns, summariseLog } from "./local-source.mjs";
16
16
  import { renderAlerts, renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
- import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers } from "./query.mjs";
17
+ import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
18
18
 
19
19
  const here = dirname(fileURLToPath(import.meta.url));
20
20
  const fx = (f) => join(here, "fixtures", f);
@@ -538,7 +538,7 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
538
538
 
539
539
  it("summariseLog carries mode, lane type, builder turns, cache tokens, cost and first-pass", () => {
540
540
  const s = summariseLog(log("on", "child"));
541
- assert.deepEqual(s.contextPack, { mode: "on", laneType: "child" });
541
+ assert.deepEqual(s.contextPack, { mode: "on", laneType: "child", ab: false });
542
542
  assert.deepEqual([s.builderTurns, s.cacheTokens, s.costUsd, s.firstPass], [10, 2130, 1.1, true]);
543
543
  assert.equal(summariseLog(log("on", "child", { builds: 4 })).firstPass, false);
544
544
  assert.equal(summariseLog(log("on", "child", { reason: "needs-human" })).firstPass, false);
@@ -553,8 +553,8 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
553
553
  const runs = [log("on", "first"), log("on", "first", { builds: 4, reviews: 2 }), log("off", "first"), log("shadow", "rerun"),
554
554
  [{ event: "RUN_START", issue: 2 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
555
555
  const ab = contextPackAb(runs);
556
- assert.deepEqual(Object.keys(ab), ["rerun", "child", "first"]);
557
- assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65 });
556
+ assert.deepEqual(Object.keys(ab), ["rerun", "child", "first", "recovery"]);
557
+ assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65, merged: 2, turns_per_merge: 15, cache_read_per_merge: 3150, rework_rate: 0 });
558
558
  assert.equal(ab.first.off.n, 1); assert.equal(ab.first.off.first_pass_yield, 1);
559
559
  assert.equal(ab.rerun.shadow.n, 1); assert.equal(ab.child.on.n, 0); assert.equal(ab.child.on.cost_usd, null);
560
560
  assert.match(renderContextAb(ab), /\| first \| on \| 2 \| 0\.5 \| 15 \| 3195 \| 1\.65 \|/);
@@ -567,6 +567,100 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
567
567
  });
568
568
  });
569
569
 
570
+ describe("explorer A/B over run logs (forge#4418)", () => {
571
+ const use = (phase, turns, cr) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: 1 } });
572
+ const slice = (issue, explore, { size = "M", turns = 5, cr = 1000, reason = "merged", triggers = ["plan", "build"], review = true } = {}) => [
573
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${explore}` },
574
+ ...triggers.map((t) => ({ ...use("implement", turns, cr), trigger: t })), ...(review ? [use("review", 2, 100)] : []),
575
+ { event: "RUN_TERMINAL", reason, code: "x", ...(explore ? { explore } : {}), ...(size ? { turns: { size, budget: 100, build: 9 } } : {}) },
576
+ ];
577
+
578
+ it("summariseLog carries the explore tag and the plan + build cache-read", () => {
579
+ const on = summariseLog(slice(1, "on"));
580
+ assert.deepEqual([on.explore, on.builderCacheRead, on.builderTurns, on.cacheTokens], ["on", 2000, 10, 2130]);
581
+ assert.equal(summariseLog(slice(1, "off")).explore, "off");
582
+ assert.equal(summariseLog(slice(1, null)).explore, null);
583
+ assert.equal(summariseLog(slice(1, "maybe")).explore, null);
584
+ });
585
+
586
+ it("readLocalLaneRuns keeps every RUN_START slice of a log", () => {
587
+ const dir = tmpDir("explore-ab-");
588
+ try {
589
+ writeFileSync(join(dir, "o__r__7.jsonl"), [...slice(7, "on"), ...slice(7, "off", { reason: "needs-human" })].map((e) => JSON.stringify(e)).join("\n"));
590
+ const runs = readLocalLaneRuns(dir);
591
+ assert.deepEqual(runs.map((r) => [r.issue, r.explore, r.reason]), [[7, "on", "merged"], [7, "off", "needs-human"]]);
592
+ assert.equal(readLocalRuns(dir).length, 1);
593
+ assert.deepEqual(readLocalLaneRuns(join(dir, "missing")), []);
594
+ } finally { rmSync(dir, { recursive: true, force: true }); }
595
+ });
596
+
597
+ it("reports per size and mode, pairs same issues and leaves untagged runs out", () => {
598
+ const runs = [slice(1, "on", { size: "L", turns: 3, cr: 500 }), slice(1, "off", { size: "L", turns: 10, cr: 4000 }),
599
+ slice(2, "on", { size: "L", turns: 4, cr: 600, triggers: ["plan", "build", "conformance"] }), slice(3, "off", { size: "S", reason: "needs-human", review: false }),
600
+ slice(4, null), slice(5, "on", { size: null })].map((l) => summariseLog(l));
601
+ const ab = exploreAb(runs);
602
+ assert.deepEqual(Object.keys(ab.by_size), ["S", "M", "L", "unsized"]);
603
+ assert.deepEqual(ab.by_size.L.on, { n: 2, builder_turns: 9, builder_cache_read: 1400, merge_rate: 1, conformance_rate: 0.5, reviewed: 2 });
604
+ assert.deepEqual(ab.by_size.L.off, { n: 1, builder_turns: 20, builder_cache_read: 8000, merge_rate: 1, conformance_rate: 1, reviewed: 1 });
605
+ assert.deepEqual([ab.by_size.S.off.merge_rate, ab.by_size.S.off.conformance_rate, ab.by_size.M.on.n, ab.by_size.M.on.builder_turns], [0, null, 0, null]);
606
+ assert.equal(ab.by_size.unsized.on.n, 1);
607
+ assert.equal(ab.untagged, 1);
608
+ assert.equal(ab.paired.issues, 1);
609
+ assert.deepEqual([ab.paired.on.n, ab.paired.on.builder_cache_read, ab.paired.off.n, ab.paired.off.builder_cache_read], [1, 1000, 1, 8000]);
610
+ const md = renderExploreAb(ab);
611
+ assert.match(md, /\| L \| on \| 2 \| 9 \| 1400 \| 1 \| 0\.5 \(2\) \|/);
612
+ assert.match(md, /\| paired \| off \| 1 \| 20 \| 8000 \| 1 \| 1 \(1\) \|/);
613
+ assert.match(md, /Same issues in both modes: 1/);
614
+ assert.match(md, /1 finished run\(s\) without an explore tag/);
615
+ });
616
+
617
+ it("--explore-ab reads run logs only (no GitHub crawl)", async () => {
618
+ assert.equal(parseArgs(["--explore-ab"]).exploreAb, true);
619
+ assert.equal(parseArgs([]).exploreAb, false);
620
+ const rep = await buildReport({ repo: "o/r", exploreAb: true }, { localRuns: [summariseLog(slice(1, "on")), { ...summariseLog(slice(2, "on")), repo: "x/y" }], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
621
+ assert.equal(rep.query.explore_ab.by_size.M.on.n, 1);
622
+ assert.equal(rep.sources.local.runs, 1);
623
+ });
624
+ });
625
+
626
+ describe("context-pack A/B per merge, rework and recovery vs first (#4444)", () => {
627
+ const use = (trigger, turns, cr, out) => ({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", trigger, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: out } });
628
+ const slice = (issue, lt, { reason = "merged", mode = "on", ab = false, calls = [["plan", 2, 100, 10], ["build", 10, 1000, 400]], extra = [] } = {}) => [
629
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${lt}`, ts: lt === "rerun" ? "2026-10-02T00:00:00Z" : "2026-10-01T00:00:00Z" },
630
+ ...calls.map((c) => use(...c)), ...extra,
631
+ { event: "RUN_TERMINAL", reason, code: "x", context_pack: mode, lane_type: lt, ...(ab ? { context_ab: true } : {}) },
632
+ ];
633
+
634
+ it("summariseLog: cache-read alone, the implement attempt without the plan call, the ab tag and the log's first-run baseline", () => {
635
+ const s = summariseLog([...slice(5, "first"), ...slice(5, "rerun", { ab: true, calls: [["recover", 3, 50, 100]] })]);
636
+ assert.equal(s.cacheReadTokens, 50);
637
+ assert.deepEqual(s.implementAttempt, { turns: 3, output_tokens: 100 });
638
+ assert.deepEqual(s.firstRunAttempt, { turns: 10, output_tokens: 400 });
639
+ assert.equal(s.contextPack.ab, true);
640
+ assert.equal(summariseLog(slice(5, "first")).firstRunAttempt, null);
641
+ assert.deepEqual(summariseLog(slice(6, "first", { calls: [] })).implementAttempt, null);
642
+ });
643
+
644
+ it("per-merge turns and cache-read, rework rate and the same-issue recovery ratio", () => {
645
+ const runs = [summariseLog(slice(1, "first")), summariseLog(slice(2, "first", { reason: "needs-human" })),
646
+ summariseLog(slice(3, "first", { extra: [{ event: "PHASE_COMMIT", phase: "remediate" }] })),
647
+ summariseLog([...slice(4, "first"), ...slice(4, "rerun", { calls: [["recover", 5, 0, 100]] })]),
648
+ summariseLog(slice(1, "rerun", { calls: [["recover", 2, 0, 200]] }))];
649
+ const ab = contextPackAb(runs);
650
+ // three runs: 12 builder turns each, 1100 cache-read each, two merged
651
+ assert.deepEqual([ab.first.on.merged, ab.first.on.turns_per_merge, ab.first.on.cache_read_per_merge, ab.first.on.rework_rate], [2, 18, 1650, 0.3333]);
652
+ assert.equal(ab.rerun.on.rework_rate, 1);
653
+ assert.equal(ab.child.on.turns_per_merge, null);
654
+ // issue 4: 5/10 turns, 100/400 output (own log); issue 1: 2/10 turns, 200/400 output (earlier first run in `runs`)
655
+ assert.deepEqual(ab.recovery, { pairs: 2, mean_turn_ratio: 0.35, mean_output_ratio: 0.375 });
656
+ const md = renderContextAb(ab);
657
+ assert.match(md, /\| Merged \| Turns\/merge \| Cache-read\/merge \| Rework rate \|/);
658
+ assert.match(md, /\| first \| on \| 3 \| .* \| 2 \| 18 \| 1650 \| 0\.3333 \|/);
659
+ assert.match(md, /Recovery vs first run \(same issue\): 2 pair\(s\), implement turns ratio 0\.35, output tokens ratio 0\.375/);
660
+ assert.deepEqual(contextPackAb([]).recovery, { pairs: 0, mean_turn_ratio: null, mean_output_ratio: null });
661
+ });
662
+ });
663
+
570
664
  describe("builder model tiers over run logs (forge#4394)", () => {
571
665
  const use = (phase, trigger, usd = 1) => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: usd, usage: { cache_read_input_tokens: 1, output_tokens: 1 } });
572
666
  const slice = (issue, { tier = "mid", cls = "small", escalated = false, reason = "merged", triggers = ["plan", "build"], review = true, shadow } = {}) => [
@@ -610,6 +704,71 @@ describe("builder model tiers over run logs (forge#4394)", () => {
610
704
  });
611
705
  });
612
706
 
707
+ describe("lane profile report (#4436)", () => {
708
+ const use = (phase, profile, baseline, trigger = "build") => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: 1, usage: { cache_read_input_tokens: 1, output_tokens: 1 },
709
+ ...(profile ? { lane_profile: profile } : {}), ...(baseline != null ? { baseline_tokens: baseline } : {}) });
710
+ const slice = (issue, profile, { impl = [40000], review = 20000, shadow, triggers } = {}) => [
711
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${profile}` },
712
+ ...impl.map((b, i) => use("implement", profile, b, triggers?.[i])), ...(review != null ? [use("review", profile, review, "review")] : []),
713
+ { event: "RUN_TERMINAL", reason: "merged", ...(shadow ? { shadow_would: shadow } : {}) },
714
+ ];
715
+
716
+ it("summariseLog keeps the slice's lane profile and per-phase baselines; resumed sessions add none", () => {
717
+ const s = summariseLog(slice(1, "lane", { impl: [30000, null] }));
718
+ assert.equal(s.laneProfile, "lane");
719
+ assert.deepEqual(s.baselines, { implement: [30000], review: [20000] });
720
+ const mixed = summariseLog([{ event: "RUN_START", issue: 2 }, use("implement", "memory-off", 1), use("review", "lane", 2), { event: "RUN_TERMINAL", reason: "merged" }]);
721
+ assert.equal(mixed.laneProfile, "mixed");
722
+ const old = summariseLog([{ event: "RUN_START", issue: 3 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }]);
723
+ assert.equal(old.laneProfile, null); assert.deepEqual(old.baselines, {});
724
+ });
725
+
726
+ it("readLocalRuns keeps one summary per log by default and every RUN_START slice with allSlices", () => {
727
+ const dir = tmpDir("lane-profile-");
728
+ try {
729
+ writeFileSync(join(dir, "7.jsonl"), [...slice(7, "memory-off", { impl: [60000] }), ...slice(7, "lane", { impl: [25000] })].map((e) => JSON.stringify(e)).join("\n"));
730
+ const last = readLocalRuns(dir);
731
+ assert.equal(last.length, 1); assert.equal(last[0].laneProfile, "lane");
732
+ const all = readLocalRuns(dir, { allSlices: true });
733
+ assert.deepEqual(all.map((r) => [r.issue, r.laneProfile, r.baselines.implement[0]]), [[7, "memory-off", 60000], [7, "lane", 25000]]);
734
+ } finally { rmSync(dir, { recursive: true, force: true }); }
735
+ });
736
+
737
+ it("compares baseline and conformance per profile on a pinned issue set", () => {
738
+ const runs = [
739
+ slice(1, "memory-off", { impl: [60000, 70000] }), slice(2, "memory-off", { impl: [50000], shadow: "conformance.scope" }),
740
+ slice(1, "lane", { impl: [20000, 30000], review: 10000 }), slice(2, "lane", { impl: [24000], review: 12000, triggers: ["conformance"] }),
741
+ slice(3, "lane", { impl: [99999] }), [{ event: "RUN_START", issue: 4 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }],
742
+ [{ event: "RUN_START", issue: 5 }, use("implement", "lane", 1)], // unfinished: left out
743
+ ].map((l) => summariseLog(l));
744
+ const all = laneProfileReport(runs);
745
+ assert.deepEqual(all.profiles.map((p) => [p.profile, p.n]), [["memory-off", 2], ["lane", 3], ["unknown", 1]]);
746
+ assert.deepEqual(all.issues_in_all, []);
747
+ const rep = laneProfileReport(runs, { issues: [1, 2] });
748
+ assert.deepEqual(rep.profiles.map((p) => p.profile), ["memory-off", "lane"]);
749
+ const [before, after] = rep.profiles;
750
+ assert.deepEqual(before.phases, [{ phase: "implement", sessions: 3, median: 60000, mean: 60000 }, { phase: "review", sessions: 2, median: 20000, mean: 20000 }]);
751
+ assert.deepEqual(after.phases[0], { phase: "implement", sessions: 3, median: 24000, mean: 24667 });
752
+ assert.deepEqual([before.reviewed, before.conformance_rate, before.shadow_blocks], [2, 0.5, 1]);
753
+ assert.deepEqual([after.reviewed, after.conformance_rate, after.shadow_blocks], [2, 0.5, 0]);
754
+ assert.deepEqual(rep.issues_in_all, [1, 2]);
755
+ const md = renderLaneProfiles(rep);
756
+ assert.match(md, /\| lane \| implement \| 3 \| 24000 \| 24667 \|/);
757
+ assert.match(md, /\| memory-off \| 2 \| 0\.5 \(2\) \| 1 \|/);
758
+ assert.match(md, /every profile: #1, #2/);
759
+ assert.match(renderLaneProfiles(laneProfileReport([])), /\| none \|/);
760
+ });
761
+
762
+ it("--lane-profile reads run logs only (no GitHub crawl); --issues takes a comma list", async () => {
763
+ assert.equal(parseArgs(["--lane-profile"]).laneProfile, true); assert.equal(parseArgs([]).laneProfile, false);
764
+ assert.deepEqual(parseArgs(["--issues", "4,12"]).issues, [4, 12]); assert.deepEqual(parseArgs(["--issues=7"]).issues, [7]);
765
+ assert.throws(() => parseArgs(["--issues", "4,x"]), /--issues must be a comma list/);
766
+ const rep = await buildReport({ repo: "o/r", laneProfile: true, issues: [1] }, { localRuns: [summariseLog(slice(1, "lane")), summariseLog(slice(2, "lane"))],
767
+ releaseTimes: [], gh: async () => { throw new Error("no network"); } });
768
+ assert.deepEqual(rep.query.lane_profile.profiles.map((p) => [p.profile, p.n]), [["lane", 1]]);
769
+ });
770
+ });
771
+
613
772
  describe("rework by trigger and first-pass rate (#4362)", () => {
614
773
  const use = (attempt, trigger, usd, phase = "implement") => ({ event: "PHASE_ATTEMPT_USAGE", phase, attempt, ...(trigger ? { trigger } : {}), cost_usd: usd, usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
615
774
  const log = (issue, attempts) => [{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}`, version: "1.30.0", ts: "2026-10-20T00:00:00Z" }, ...attempts,
@@ -783,3 +942,20 @@ describe("fail-soft alerts (#4354)", () => {
783
942
  assert.equal(computeAlerts(localLanes, { repo: "o/r" }).length, 1);
784
943
  });
785
944
  });
945
+
946
+ describe("env.tool_missing per release (forge#4414)", () => {
947
+ // The #4393 replay criterion is read as outcome-code counts per engine version; pin that the report surfaces them.
948
+ const run = (issue, version, reason, code) => ({ issue, repo: "o/r", version, reason, code, finished: true, usageEvents: 1, endTs: "2026-10-23T10:00:00Z", phases: { implement: { cr: 1, out: 1, ms: 1 } } });
949
+ const runs = [run(1, "1.30.0", "needs-human", "env.tool_missing"), run(2, "1.30.0", "merged", "completed"),
950
+ run(3, "1.31.0", "merged", "completed"), run(4, "1.31.0", "needs-human", "dependency.open")];
951
+ const codesOf = (g) => Object.fromEntries(g.flow.codes.map((c) => [c.code, c.count]));
952
+
953
+ it("counts env.tool_missing per version and drops pre-release lanes with sinceVersion", () => {
954
+ const all = aggregate({ runs }, { releaseTimes: [] });
955
+ assert.equal(codesOf(all.groups.find((g) => g.version === "1.30.0"))["env.tool_missing"], 1);
956
+ const after = aggregate({ runs }, { releaseTimes: [], sinceVersion: "1.31.0" });
957
+ assert.deepEqual(after.groups.map((g) => g.version), ["1.31.0"]);
958
+ assert.equal(codesOf(after.groups[0])["env.tool_missing"], undefined);
959
+ assert.equal(after.groups[0].flow.codesN, 2);
960
+ });
961
+ });
package/docs/CONFIG.md CHANGED
@@ -563,11 +563,11 @@ engine:
563
563
  | `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
564
564
  | `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
565
565
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
566
- | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab`. Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
566
+ | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. `ab` (#4444): the A/B experiment. Each issue gets a stable arm, `on` or `shadow`, from a hash of `repo#issue`, logged as that mode with `context_ab: true`; the default stays `shadow`. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab` (per lane type and mode: first-pass yield, turns and cache-read per merge, rework rate, and recovery lanes' implement attempt against the first run on the same issue). Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
567
567
  | `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `pitfalls`, `ledger`, `facts` (earlier lanes' `FORGE:FACTS` on the issue's files plus repo-fact file pointers). Default: all five. |
568
568
  | `context_pack_bytes` | integer | No | Hard byte cap of the pack; items that do not fit are dropped whole and counted. Default: 6144. |
569
569
  | `context_pack_days` | integer | No | Window of the merged-PR ledger, in days. Default: 30. |
570
- | `lane_instructions` | string | No | Lane profile (#4395). Core lane sessions always run with Claude Code auto-memory off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`). Set this to a repo-relative file, for example `devdocs/lane-instructions.md`, that holds only the hard rules plus pointers into docs. It is read from the lane worktree, so it is versioned with the repo. Lanes then also skip CLAUDE.md discovery (`CLAUDE_CODE_DISABLE_CLAUDE_MDS=1`), and the file text is appended to the system prompt of every fresh builder, reviewer and verifier session. An absolute path, a `..` path, or a missing, empty or over-16 KB file is a trajectory anomaly; lanes then keep CLAUDE.md. Settings and hooks always load (no `--setting-sources`). `FORGEDOCK_FULL_SESSION=1` keeps a full session. Each `phase_attempt` telemetry record carries `lane_profile` (`full`, `memory-off` or `lane`) and `baseline_tokens`, the first turn's context of a fresh session, so baseline growth is visible. |
570
+ | `lane_instructions` | string | No | Lane profile (#4395). Core lane sessions always run with Claude Code auto-memory off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`). Set this to a repo-relative file, for example `devdocs/lane-instructions.md`, that holds only the hard rules plus pointers into docs. It is read from the lane worktree, so it is versioned with the repo. Lanes then also skip CLAUDE.md discovery (`CLAUDE_CODE_DISABLE_CLAUDE_MDS=1`), and the file text is appended to the system prompt of every fresh builder, reviewer and verifier session. An absolute path, a `..` path, or a missing, empty or over-16 KB file is a trajectory anomaly; lanes then keep CLAUDE.md. Settings and hooks always load (no `--setting-sources`). `FORGEDOCK_FULL_SESSION=1` keeps a full session. Each `phase_attempt` telemetry record carries `lane_profile` (`full`, `memory-off` or `lane`) and `baseline_tokens`, the first turn's context of a fresh session, so baseline growth is visible. `forgedock stats --lane-profile [--issues <list>]` compares `baseline_tokens` per phase and the conformance rate per profile from local run logs, for the before/after check on the same issue set. |
571
571
  | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
572
572
 
573
573
  **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
@@ -613,7 +613,7 @@ lean:
613
613
  | `fix_model` | string | No | Model of fresh fix-round sessions (#4363; `engine.lean_fix_model` is also read). Default: the reviewer model (`engine.lean_reviewer_model`, else Sonnet). Fix rounds for precheck, CI, docs, structure, enumerating-test and sequence-number failures, and conformance or review FAILs whose failures all cite a `file:line`, run in a fresh session (no resume of the builder's transcript) whose prompt carries only the round's failure output, the capped `git diff <base>...HEAD`, the frozen contract and the issue title and summary (under 60K characters). A fresh session that makes no commit gets one resume of the original builder session in the same round. Other rounds (block or contract corrections, CI unavailable, base-sync conflicts, failures without a file:line) resume the builder as before; round limits (`verify.max_rounds`, the one review retry) are unchanged. The run log's `PHASE_ATTEMPT_USAGE` records `mode: "fresh"\|"resume"` on each fix-round attempt. Plan and build sessions keep the builder model. |
614
614
  | `builder_routing` | string | No | `on` (default) or `off` (#4394). After the contract is frozen, the lane is classed from its deliverable paths only: `risk` (any path with a built-in risk word such as auth, billing, payments, session, secret or migration, or matching a `review.risk_paths` glob), `docs-config-test` (every path is a test, doc or config file; lockfiles count as code), `small` (at most 2 non-test deliverables) or `large` (more, or none). `docs-config-test` and `small` build on the mid tier (`builder_mid_model`); `risk` and `large` build on the top model (`--model`, else the CLI default). The plan and contract-correction calls always run on the top model. A failed validate round on the mid tier (`precheck`, `ci_red`, `merge_ci`) escalates the lane once: that fix round and every later builder call, fresh fix sessions included, run on the top model. Escalation adds no round. Conformance or review fix rounds do not escalate and keep `fix_model`. `off` keeps every builder call on the top model, as before, and still records the class. The run log gets `BUILDER_ROUTE {mode, class, tier, model, risk}` and `BUILDER_ESCALATED {from, to, trigger}`, and `RUN_TERMINAL` gets `builder_tier`, `builder_class`, `builder_escalated` and `builder_model`. `forgedock stats --tiers` reports merge, conformance and post-merge-rework rates, first-pass yield and cost per tier (`mid`, `mid->top`, `top`) and class, from local run logs. Target-repo risk areas (for example detection code) belong in `review.risk_paths`. |
615
615
  | `builder_mid_model` | string | No | Mid-tier builder model for `builder_routing` (#4394; `engine.lean_builder_mid_model` is also read). Default `claude-sonnet-5-5`. An invalid id is ignored. |
616
- | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450: until it lands, no lane changes shape in any mode. The flat form `lean.light_mode` also works and wins. |
616
+ | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450. A light lane posts a `FORGE:LITE` receipt on the issue (outcome light or upgraded, diff stat, red/green repro, gates, true cost from the #4407 per-session deltas, eligibility reason, upgrade reasons), and the batch efficiency report ends with a `lanes:` line: the light/upgraded/full split with cost and the upgrade rate, upgraded ÷ (light + upgraded) (#4452). The flat form `lean.light_mode` also works and wins. |
617
617
  | `light.max_lines` | number | No | Upgrade trigger (#4448): a light lane whose non-test diff (added + deleted lines) exceeds this moves to the full lane. Default 40. The other triggers: more than `max_files` non-test changed files, a repro that is not red on the base, two failed test runs, a touched risk path, or a public-API-shape change (#4449). Flat `lean.light_max_lines` wins. |
618
618
  | `light.max_files` | number | No | Max predicted non-test files for eligibility and max changed non-test files before an upgrade (#4448). Default 2. Flat `lean.light_max_files` wins. |
619
619
  | `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
package/docs/STATS.md CHANGED
@@ -78,6 +78,19 @@ Limits: logs written before #4362 carry no trigger, so implement attempts beyond
78
78
  `unknown`. Logs written before #4407 sum running session totals. Lanes run outside a batch are in no window. `gh` (for
79
79
  merged issues only) is the one network call; when it fails, quality and acceptance print `n/a` with the reason.
80
80
 
81
+ ## Explorer A/B (`--explore-ab`, forge#4418)
82
+
83
+ Compares lanes run with forge.yaml `lean.explore: on` against `off` (#4391), from the local run logs only (no GitHub
84
+ crawl). Every RUN_START slice of each log counts, so re-running an issue with the other mode adds a second lane.
85
+
86
+ - Rows per size bucket (`S`, `M`, `L` from the lane's turn record, else `unsized`) and mode: n, mean builder (plan +
87
+ build) turns, mean builder `cache_read_input_tokens`, merge rate (merged / finished) and conformance rate (lanes that
88
+ reached review with no conformance/review fix round and no shadow would-block, over reviewed; same as `--tiers`).
89
+ - A paired table restricted to issues with a finished lane in both modes.
90
+ - Finished lanes without an `explore` tag (before #4391) are counted and left out.
91
+
92
+ `--json` prints the same data as `query.explore_ab`.
93
+
81
94
  ## Sections (per version and repo)
82
95
 
83
96
  - **Cost**: cache-read tokens per issue (mean, median), per phase (mean over all runs, so phases add up to the per-issue
@@ -0,0 +1,55 @@
1
+ {
2
+ "version": "lightlane-v1",
3
+ "description": "Three-way light-lane benchmark (#4453, parent #4442): three more-itertools bugs reported after the model cutoff, graded by the upstream fix tests. Instructions: docs/eval/lightlane-bench.md.",
4
+ "upstream": "more-itertools/more-itertools",
5
+ "license": "MIT",
6
+ "clone_url": "https://github.com/more-itertools/more-itertools.git",
7
+ "base": "670b715b307adfe5d2fca08a87ca562cb8bfea2c",
8
+ "suite_command": "python3 -m unittest discover -q -s tests -t .",
9
+ "control_prompt": "Fix this issue in the repository in the current directory. Write a failing regression test first, then the fix, then run the targeted tests. Do not use web tools.\n\nIssue: {title}\n\n{body}",
10
+ "issues": [
11
+ {
12
+ "id": "lightlane-1293",
13
+ "upstream_issue": 1293,
14
+ "fix_pr": 1294,
15
+ "fix_sha": "1dd480a08bc2a4c399c84bf21cb1558f8b5fc838",
16
+ "title": "Repeated zero-cache seekable.peek() retains nested chain iterators",
17
+ "body": "On current master (`b5e3886`), the fix for #1268 preserves values but each `seekable(..., maxlen=0).peek()` prepends a new `chain` around the previous source. Repeated peeking without advancing retains an iterator chain proportional to the number of calls, despite having no history cache.\n\n```python\nimport tracemalloc\nfrom more_itertools import seekable\n\ns = seekable(range(3), maxlen=0)\ntracemalloc.start()\ns.peek()\nbaseline = tracemalloc.get_traced_memory()[0]\nfor _ in range(10000):\n assert s.peek() == 0\nprint(tracemalloc.get_traced_memory()[0] - baseline)\nprint(list(s.elements()), list(s))\n```\n\nOn CPython 3.12.13/macOS this retains 2,427,672 additional bytes; output remains `[] [0, 1, 2]`. A single lookahead slot should be sufficient regardless of the number of repeated `peek()`/`bool()` calls. I am checking a fix that reuses the existing `peekable` helper only in the zero-history case, with unchanged bounded/unbounded history behavior.\n\nInvestigation performed with AI assistance.\n",
18
+ "oracle": {
19
+ "files": [
20
+ "tests/test_more.py"
21
+ ],
22
+ "command": "python3 -m unittest -q tests.test_more.SeekableTest.test_repeated_zero_cache_peek_has_bounded_memory",
23
+ "note": "670b715 is the merge parent of the fix, but the fix branch also carries the #1268/#1269 seekable maxlen=0 value fix; on the base the oracle errors with StopIteration, so an arm passes only by bounding memory and keeping the peeked values."
24
+ }
25
+ },
26
+ {
27
+ "id": "lightlane-1304",
28
+ "upstream_issue": 1304,
29
+ "fix_pr": 1305,
30
+ "fix_sha": "1ea82a711c69f590054987b5cb194157f8ce8ac4",
31
+ "title": "ichunked(..., 0) raises islice's ValueError on a nonempty iterable",
32
+ "body": "`ichunked` is documented as being like `chunked`, but with lazy chunks. `chunked` treats `n=0` as \"yield nothing\". `ichunked` only does that when the source is already empty; with any remaining items it calls `islice(iterator, n - 1)` and blows up.\n\nI hit this switching a streaming pipeline from `chunked` to `ichunked` so batches would not be materialized as lists. Chunk size comes from the same config in both paths; `0` means \"this stage is disabled, emit nothing.\"\n\n```python\nfrom more_itertools import chunked, ichunked\n\nrows = [\"a\", \"b\", \"c\"] # e.g. leftover rows from a DB cursor\nchunk_size = 0 # config: skip this sink\n\nprint(list(chunked(rows, chunk_size)))\n# []\n\nprint([list(c) for c in ichunked(rows, chunk_size)])\n# ValueError: Stop argument for islice() must be None or an integer: 0 <= x <= sys.maxsize.\n```\n\n`IchunkedTests.test_zero` only uses `iterable = []`, which never enters the `for first in iterator` loop, so the `islice(..., n - 1)` path is untested.\n\nNegative `n` has the same leak: `chunked` raises `ValueError: n must be at least 0`, `ichunked` raises islice's \"Indices for islice() must be None or an integer...\".\n\nHappy to send a PR.",
33
+ "oracle": {
34
+ "files": [
35
+ "tests/test_more.py"
36
+ ],
37
+ "command": "python3 -m unittest -q tests.test_more.IchunkedTests.test_zero_nonempty tests.test_more.IchunkedTests.test_negative"
38
+ }
39
+ },
40
+ {
41
+ "id": "lightlane-1308",
42
+ "upstream_issue": 1308,
43
+ "fix_pr": 1309,
44
+ "fix_sha": "67fcf640477c78605580f5ee703a54c73c1e7a8a",
45
+ "title": "bucket ignores a supplied validator when the callable is falsey",
46
+ "body": "`bucket` replaces a supplied validator with its default when the callable has a false truth value. An empty callable set is one example:\n\n```python\nfrom more_itertools import bucket\n\nclass AllowedKeys(set):\n def __call__(self, key):\n return key in self\n\nallowed = AllowedKeys()\nsource = iter([1, 2, 3])\nbuckets = bucket(source, key=lambda x: x, validator=allowed)\nprint(list(buckets[1])) # [1]; expected []\n```\n\nThe validator rejects every key, so selecting a bucket should return an empty iterator without consuming `source`. Instead, `validator or (lambda x: True)` discards it. Membership checks and iteration over bucket keys also accept keys the validator rejects. On an infinite input, a lookup for an absent, rejected key can keep scanning rather than return immediately.\n\nI reproduced this on current main (11.1.0). Changing the default selection to `(lambda x: True) if validator is None else validator` preserves the supplied callable. My regression checks rejected selection, membership, source consumption, and key iteration; the full unittest suite passes with that change.\n\nI can send the small fix and regression test if this behavior is unintended.\n",
47
+ "oracle": {
48
+ "files": [
49
+ "tests/test_more.py"
50
+ ],
51
+ "command": "python3 -m unittest -q tests.test_more.BucketTests.test_falsey_validator_is_honoured"
52
+ }
53
+ }
54
+ ]
55
+ }
@@ -0,0 +1,91 @@
1
+ # Light-lane three-way benchmark (#4453)
2
+
3
+ This benchmark compares the light lane (#4450) with the full lean lane and with a plain single-session `claude -p` control. All three arms run on the same issue set, and each is graded by the **upstream oracle tests**: the test files of the real upstream fix, which fail on the base and pass on the fix. It reproduces the #4442 benchmark of 2026-10-22 and checks its acceptance targets.
4
+
5
+ - Harness: `scripts/bench-lightlane.mjs`
6
+ - Corpus: `docs/eval/corpus/lightlane-v1.json`. This is more-itertools @ `670b715` with upstream #1293, #1304 and #1308. Their fixes are PRs #1294, #1305 and #1309, and they were reported after the model's training cutoff.
7
+
8
+ ## Prerequisites
9
+
10
+ - `python3`, `git`, `gh` (authenticated, with access to the sandbox repos), `claude` and `forgedock` on PATH. Use the ForgeDock release under test.
11
+ - Two **private sandbox repos**, one for the light arm and one for the full arm. Each needs the upstream history up to the base, so the base SHA exists in it:
12
+ ```sh
13
+ git clone https://github.com/more-itertools/more-itertools.git mi && cd mi
14
+ git push git@github.com:<you>/mi-light.git 670b715b307adfe5d2fca08a87ca562cb8bfea2c:refs/heads/main
15
+ git push git@github.com:<you>/mi-full.git 670b715b307adfe5d2fca08a87ca562cb8bfea2c:refs/heads/main
16
+ ```
17
+ If a sandbox is a squashed snapshot instead, set `arms.<arm>.base` in the run config to its base commit.
18
+ - A `forge.yaml` in each sandbox. The light arm sets `lean:` → `light:` → `mode: on`, and the full arm sets `mode: off`. Commit it on `main`; the harness diffs from the base, so this commit doesn't count as an arm change.
19
+ - The corpus issues filed in each sandbox, with the title and body taken verbatim from the corpus. Labels follow the #4442 setup: `bug`.
20
+ ```sh
21
+ jq -c '.issues[]' docs/eval/corpus/lightlane-v1.json | while read -r i; do
22
+ gh issue create -R <you>/mi-light --title "$(jq -r .title <<<"$i")" --body "$(jq -r .body <<<"$i")" --label bug
23
+ done
24
+ ```
25
+
26
+ ## Run config (`runcfg.json`, not committed)
27
+
28
+ ```json
29
+ {
30
+ "arms": {
31
+ "light": { "repo": "<you>/mi-light", "issues": { "lightlane-1293": 1, "lightlane-1304": 2, "lightlane-1308": 3 } },
32
+ "full": { "repo": "<you>/mi-full", "issues": { "lightlane-1293": 1, "lightlane-1304": 2, "lightlane-1308": 3 } },
33
+ "control": { "model": "claude-opus-5-5" }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Optional per arm:
39
+ - `command`: argv prefix. The default is `["forgedock","run-issue"]`, or `["claude"]` for the control.
40
+ - `args`: extra arguments, for example `["--model","claude-opus-5-5"]` for a lane, or the permission flags your `claude` needs for the control.
41
+ - `clone_url` and `base` for the lane arms.
42
+
43
+ Use the same model in every arm, and no web tools.
44
+
45
+ ## Steps
46
+
47
+ 1. **Calibrate** the oracles. Every issue must report `red_on_base` and `green_on_fix`, otherwise the command exits 2:
48
+ ```sh
49
+ node scripts/bench-lightlane.mjs calibrate --corpus docs/eval/corpus/lightlane-v1.json
50
+ ```
51
+ 2. **Run** the arms. This takes about 10 minutes per arm per issue, runs sequentially, and appends one JSON row per arm and issue:
52
+ ```sh
53
+ node scripts/bench-lightlane.mjs run --corpus docs/eval/corpus/lightlane-v1.json --config runcfg.json --out rows.jsonl
54
+ ```
55
+ Use `--arms light,control` or `--issues lightlane-1304` to rerun part of the set. Use `--keep` to keep the scratch clones.
56
+ 3. **Aggregate** the rows. This prints the metrics and targets, and exits 2 when a target fails:
57
+ ```sh
58
+ node scripts/bench-lightlane.mjs aggregate rows.jsonl
59
+ ```
60
+ 4. Post the aggregate JSON and the target table, plus `rows.jsonl`, on the measurement tracking issue. Note the ForgeDock version, the model and the date.
61
+
62
+ ## What each metric means
63
+
64
+ | Field | Meaning |
65
+ |---|---|
66
+ | `cost_usd` | **True** cost. For the lanes, it is the run result's `cost_usd`: the sum of the #4407 per-session deltas, not the naive sum of running totals. For the control, it is `total_cost_usd`. |
67
+ | `wall_ms` | Lane wall time inside the engine (`wall_ms`, #4451). For the control, it is `duration_ms`. `harness_wall_ms` adds CLI start-up. |
68
+ | `model_ms` | Summed session time (`model_ms`, #4451). For the control, it is `duration_ms`, since the control is one session. |
69
+ | `oracle_pass` | The oracle command passes after the fix's test files are overlaid on the arm's head (`git checkout <fix_sha> -- tests/test_more.py`). |
70
+ | `suite_pass` | The repo's own suite (`suite_command`) passes on the arm's head, before the overlay. |
71
+ | `claimed_fix` | The arm reported success and its diff is non-empty. For the lanes, success is a `merged` or `ci-pending` terminal reason; for the control, it is exit 0 with `is_error: false`. |
72
+ | false fix | `claimed_fix` and not `oracle_pass`. |
73
+ | `api_shape` | `bin/lean/apishape.mjs` from the base to the arm's head (signature, sync↔async, generator↔function, removed or renamed public symbol). |
74
+ | undetected API shape | A light-arm row that finished light (`light: true`, no upgrade) although its diff changed the public API shape. |
75
+
76
+ ## Targets (#4442)
77
+
78
+ | Target | Check |
79
+ |---|---|
80
+ | Light true cost ≤ 1.2× control | The cost sum of eligible light rows, divided by the control's cost on the same issues. |
81
+ | Light wall ≤ 1.3× model time | The `wall_ms` sum of eligible light rows, divided by their `model_ms` sum. |
82
+ | Light oracle pass rate ≥ control | Over all light rows. |
83
+ | Light false-fix rate ≤ control | Over all light rows. |
84
+ | 0 undetected public-API-shape changes | Over all light rows. |
85
+
86
+ "Eligible" means the row's `light_eligibility` (the run result's light-lane verdict) is `eligible`. A target is `null` when an arm it needs has no rows.
87
+
88
+ ## Corpus notes
89
+
90
+ - Oracle commands are scoped to the test ids each fix PR added or changed. Class-wide commands would include tests that fail on the base for reasons unrelated to the issue.
91
+ - `lightlane-1293`: `670b715` is the merge parent of the fix, but the fix branch also carries the #1268/#1269 seekable `maxlen=0` value fix. On the base the oracle errors with `StopIteration`, so an arm passes only if it bounds memory and also keeps the peeked values.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"