forgedock 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,233 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { TRIGGERS } from "../lean/fixround.mjs";
10
+ import {
11
+ TRIGGER_CAUSE, RETRY_CAUSES, WEEK_MS, codeCause, issueSlices, sliceRetries, issueQuality, acceptanceOf, efficiencyMetrics,
12
+ batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows,
13
+ } from "./efficiency.mjs";
14
+ import { parseArgs, runStats } from "./index.mjs";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const FX = join(here, "fixtures", "efficiency-20261022");
18
+ const AL = "RapierCraftStudios/AlterLab";
19
+ const B1 = "b20261022-181111-1103229", B2 = "b20261022-183421-1103229";
20
+ const NOW = Date.parse("2026-10-23T00:00:00Z");
21
+ const DAY = 86400000;
22
+
23
+ const start = (ts, repo = "o/r") => ({ event: "RUN_START", ts, repo });
24
+ const att = (phase, o = {}) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns: 10, cost_usd: 1, usage: { cache_read_input_tokens: 1000 }, model: "m1", ...o });
25
+ const term = (reason, code, o = {}) => ({ event: "RUN_TERMINAL", reason, code, ...o });
26
+ const probe = (result, at, trusted = true) => ({ body: `<!-- FORGE:PROBE -->\n## Acceptance probe\n\`{"fix":"abc","result":"${result}"}\``, created_at: new Date(at).toISOString(),
27
+ author_association: trusted ? "OWNER" : "NONE", user: { type: trusted ? "Bot" : "User", login: "x" } });
28
+
29
+ describe("#4402 retry causes", () => {
30
+ it("classifies every builder trigger (property: TRIGGERS ⊆ TRIGGER_CAUSE)", () => {
31
+ for (const t of TRIGGERS) assert.ok(Object.hasOwn(TRIGGER_CAUSE, t), `trigger ${t} has no cause`);
32
+ for (const c of Object.values(TRIGGER_CAUSE)) assert.ok(c === null || RETRY_CAUSES.includes(c));
33
+ assert.equal(TRIGGER_CAUSE.post_merge, "code"); assert.equal(TRIGGER_CAUSE.duplicate, "code"); // #4406: builder rework on the merged tree
34
+ });
35
+ it("maps lane-rerun codes by family", () => {
36
+ assert.equal(codeCause("env.tool_missing"), "env");
37
+ assert.equal(codeCause("followup.unavailable"), "engine");
38
+ assert.equal(codeCause("engine.error"), "engine");
39
+ assert.equal(codeCause("conformance.unimplemented"), "code");
40
+ assert.equal(codeCause("review.failed"), "review");
41
+ assert.equal(codeCause("weird"), "unknown");
42
+ assert.equal(codeCause(null), "unknown");
43
+ });
44
+ it("counts triggered attempts by cause and legacy attempts beyond plan + build as unknown", () => {
45
+ const s = issueSlices([start("t"), att("implement", { trigger: "plan" }), att("implement", { trigger: "build" }), att("implement", { trigger: "ci_red" }),
46
+ att("implement", { trigger: "context_restart" }), att("implement", { trigger: "ci_unavailable" }), att("review", { trigger: "review" }), att("implement", { trigger: "review" })])[0];
47
+ assert.deepEqual(sliceRetries(s), { env: 1, engine: 1, code: 1, review: 1, unknown: 0 });
48
+ const legacy = issueSlices([start("t"), att("implement"), att("implement"), att("implement"), att("implement"), att("review")])[0];
49
+ assert.deepEqual(sliceRetries(legacy), { env: 0, engine: 0, code: 0, review: 0, unknown: 2 });
50
+ });
51
+ });
52
+
53
+ describe("#4402 issue quality and acceptance", () => {
54
+ it("tags root cause + file:line + fix design", () => {
55
+ assert.equal(issueQuality("## Root Cause\nx at `bin/a.mjs:12`\n## Fix design\ny").tag, "full");
56
+ assert.equal(issueQuality("**Root cause**: z\n### Proposal\nfoo").tag, "partial");
57
+ assert.equal(issueQuality("just words, see a.js:3").tag, "partial");
58
+ assert.equal(issueQuality("plain text").tag, "none");
59
+ assert.equal(issueQuality("<!-- ## Root Cause a.js:1 ## Fix -->\nnothing").tag, "none");
60
+ assert.equal(issueQuality("<!-- ## Root Cause a.js:1\n## Fix design\nunclosed").tag, "none");
61
+ assert.equal(issueQuality(null).tag, "none");
62
+ });
63
+ it("is pending under 7 days, then pass only when every record within 7 days passes", () => {
64
+ const t0 = NOW - WEEK_MS;
65
+ assert.equal(acceptanceOf([], NOW), "unprobed");
66
+ assert.equal(acceptanceOf([probe("pass", t0 + 1)], NOW), "pending");
67
+ assert.equal(acceptanceOf([probe("pass", t0)], NOW), "pass", "exactly 7 days settles");
68
+ assert.equal(acceptanceOf([probe("pass", t0), probe("fail", t0 + WEEK_MS)], NOW + WEEK_MS), "fail");
69
+ assert.equal(acceptanceOf([probe("pass", t0), probe("fail", t0 + WEEK_MS + 1)], NOW + WEEK_MS), "pass", "a fail after the window does not count");
70
+ assert.equal(acceptanceOf([probe("fail", t0, false)], NOW), "unprobed", "untrusted records are ignored");
71
+ });
72
+ });
73
+
74
+ describe("#4402 efficiencyMetrics", () => {
75
+ const logs = new Map([
76
+ [1, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "plan" }), att("implement", { trigger: "build" }), term("needs-human", "env.tool_missing"),
77
+ start("2026-10-02T00:00:00Z"), att("implement", { trigger: "build", model: "m2", turns: 30, usage: { cache_read_input_tokens: 9000 } }), att("review", { cost_usd: 0.5 }),
78
+ term("merged", "completed", { turns: { size: "S" } }), start("2026-10-09T00:00:00Z"), att("implement", { cost_usd: 99 })]],
79
+ [2, [start("2026-10-02T00:00:00Z", "x/y"), att("implement", { cost_usd: 50 }), term("merged", "completed"),
80
+ start("2026-10-03T00:00:00Z"), att("implement", { trigger: "build" }), term("merged", "completed")]],
81
+ ]);
82
+ const entries = [
83
+ { row: { issue: 1, outcome: "merged" }, settleAt: "2026-10-05T00:00:00Z" },
84
+ { row: { issue: 2, outcome: "merged" }, settleAt: null },
85
+ { row: { issue: 3, outcome: "needs-human", code: "builder.blocked", detail: "Builder is blocked (promotion): on staging", cost_usd: 0.3 } },
86
+ { row: { issue: 4, outcome: "needs-human", code: "builder.blocked", detail: "Builder is blocked (credentials): x", cost_usd: 0.3 } },
87
+ { row: { issue: 5, outcome: "held", prefilter: { parked: true, kind: "awaiting-promotion" }, cost_usd: 0 } },
88
+ ];
89
+ const issues = new Map([[1, { body: "## Root cause\na.js:1\n## Fix", labels: [], comments: [probe("pass", NOW - 8 * DAY)] }],
90
+ [2, { body: "x", labels: ["size:L"], comments: [] }]]);
91
+ const m = efficiencyMetrics({ entries, logs, issues, repo: "o/r", now: NOW });
92
+
93
+ it("sums the cost to merge over earlier slices, bounded by the settle time and the repo", () => {
94
+ assert.equal(m.merged, 2);
95
+ assert.deepEqual(m.per_issue.map((p) => [p.issue, p.slices, p.cost_usd, p.implement_turns, p.size]), [[1, 2, 3.5, 50, "S"], [2, 1, 1, 10, "L"]]);
96
+ assert.equal(m.usd_per_merge, 2.25);
97
+ assert.equal(m.turns_per_merge, 30);
98
+ assert.equal(m.context_per_turn, 200);
99
+ assert.deepEqual(Object.keys(m.by_size), ["S", "L"]);
100
+ assert.deepEqual(m.by_model.m2, { merges: 1, attempts: 1, turns: 30, cost_usd: 1, usd_per_merge: 1, turns_per_merge: 30, context_per_turn: 300 });
101
+ });
102
+ it("counts retries by cause including lane reruns", () => {
103
+ assert.deepEqual(m.retries, { total: 1, per_merge: 0.5, by_cause: { env: 1, engine: 0, code: 0, review: 0, unknown: 0 }, lane_reruns: 1 });
104
+ });
105
+ it("lists no-action lanes: parked and promotion-blocked only", () => {
106
+ assert.equal(m.no_action.lanes, 2);
107
+ assert.deepEqual(Object.keys(m.no_action.by_kind).sort(), ["awaiting-promotion", "blocked-promotion"]);
108
+ assert.equal(m.no_action.cost_usd, 0.3);
109
+ });
110
+ it("compares quality tags and reports the acceptance rate", () => {
111
+ assert.equal(m.quality.full.n, 1);
112
+ assert.equal(m.quality.none.n, 1);
113
+ assert.equal(m.quality.partial.n, 0);
114
+ assert.deepEqual(m.acceptance, { pass: 1, fail: 0, pending: 0, unprobed: 1, probed: 1, pass_rate: 1 });
115
+ });
116
+ it("renders n/a, never NaN, for an empty cohort", () => {
117
+ const lines = formatEfficiencyLines(efficiencyMetrics({}));
118
+ assert.equal(lines.length, 8);
119
+ assert.ok(!lines.some((l) => /NaN|undefined/.test(l)), lines.join("\n"));
120
+ assert.match(lines[0], /^efficiency: merged 0, n\/a\/merge/);
121
+ assert.equal(lines[6], "acceptance (7d after promotion): n/a");
122
+ assert.equal(lines[7], "tools: calls/turn p50 n/a, re-reads (>=3x same path)/merge n/a");
123
+ });
124
+ });
125
+
126
+ describe("#4410 tool calls per turn and re-reads", () => {
127
+ const tools = (calls, turns, rereads) => ({ tools: { calls, turns, per_turn: calls / turns, rereads, top: [] } });
128
+ const logs = new Map([
129
+ [1, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "plan", ...tools(4, 4, 0) }), att("implement", { trigger: "build", ...tools(8, 4, 2) }),
130
+ att("review", tools(50, 1, 9)), term("merged", "completed")]],
131
+ [2, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(30, 10, 1) }), term("merged", "completed")]],
132
+ [3, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(5, 5, 0) }), term("merged", "completed")]],
133
+ [4, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build" }), term("merged", "completed")]],
134
+ ]);
135
+ const entries = [1, 2, 3, 4].map((issue) => ({ row: { issue, outcome: "merged" }, settleAt: null }));
136
+ const m = efficiencyMetrics({ entries, logs, repo: "o/r", now: NOW });
137
+
138
+ it("p50 over lanes with tool data of implement calls / implement tool turns; re-reads per such lane", () => {
139
+ // lane 1: 12/8 = 1.5 (review excluded), lane 2: 3, lane 3: 1; lane 4 has no tool data
140
+ assert.equal(m.tool_calls_per_turn_p50, 1.5);
141
+ assert.equal(m.rereads_per_merge, 1);
142
+ const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
143
+ assert.equal(p[1].implement_tool_calls_per_turn, 1.5); assert.equal(p[1].implement_rereads, 2);
144
+ assert.equal("implement_tool_calls_per_turn" in p[4], false);
145
+ assert.equal(formatEfficiencyLines(m).at(-1), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
146
+ });
147
+ it("null without tool data (older logs)", () => {
148
+ const e = efficiencyMetrics({ entries: [entries[3]], logs, repo: "o/r", now: NOW });
149
+ assert.equal(e.tool_calls_per_turn_p50, null); assert.equal(e.rereads_per_merge, null);
150
+ });
151
+ });
152
+
153
+ describe("#4402 reproduces the 2026-10-22 AlterLab batches (#4389-#4394)", () => {
154
+ const window = async (gh = null) => (await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, gh, now: NOW, windows: [7 * DAY] }))["7d"];
155
+ it("implement $85.98 against review $3.13 over the 10 merged lanes, per-issue turns, cache-read and cost", async () => {
156
+ const m = await window();
157
+ assert.equal(m.batches, 2);
158
+ assert.equal(m.merged, 10);
159
+ assert.equal(m.implement_usd.toFixed(2), "85.98");
160
+ assert.equal(m.review_usd.toFixed(2), "3.13");
161
+ assert.equal(Math.round((m.implement_usd / (m.implement_usd + m.review_usd)) * 100), 96);
162
+ assert.equal(m.implement_turns, 802);
163
+ assert.equal(m.implement_cache_read, 92215514);
164
+ assert.equal(m.context_per_turn, 114982);
165
+ assert.equal(m.turns_per_merge, 80.2);
166
+ const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
167
+ const mm = (x) => `${(x / 1e6).toFixed(1)}M`;
168
+ assert.deepEqual([p[36629].implement_turns, mm(p[36629].implement_cache_read), p[36629].implement_usd.toFixed(2)], [124, "22.6M", "19.80"]);
169
+ assert.deepEqual([p[36143].implement_turns, mm(p[36143].implement_cache_read), p[36143].implement_usd.toFixed(2), p[36143].slices], [165, "14.2M", "15.03", 2]);
170
+ assert.equal(p[36143].retries.code, 1);
171
+ assert.deepEqual([p[36627].implement_turns, mm(p[36627].implement_cache_read), p[36627].implement_usd.toFixed(2)], [102, "15.8M", "9.20"]);
172
+ assert.deepEqual([p[36620].implement_turns, mm(p[36620].implement_cache_read)], [91, "12.2M"]);
173
+ assert.deepEqual([p[36612].implement_turns, p[36612].implement_usd.toFixed(2)], [19, "1.23"]);
174
+ assert.deepEqual([p[36619].slices, p[36619].retries.env, p[36619].retries.engine], [4, 1, 2]);
175
+ });
176
+ it("batch 183421: 32 promotion-blocked no-action lanes for $10.13 and the #4389 outcome table", async () => {
177
+ const b = await batchEfficiency({ batchDir: join(FX, "batches", B2), runsDir: join(FX, "runs"), now: NOW });
178
+ assert.equal(b.batch, B2);
179
+ assert.deepEqual([b.no_action.lanes, b.no_action.cost_usd.toFixed(2), Object.keys(b.no_action.by_kind)], [32, "10.13", ["blocked-promotion"]]);
180
+ const { readFileSync } = await import("node:fs");
181
+ const rows = JSON.parse(readFileSync(join(FX, "batches", B2, "report.json"), "utf8")).rows;
182
+ const by = (o) => { const xs = rows.filter((r) => r.outcome === o); return [xs.length, xs.reduce((a, r) => a + r.cost_usd, 0).toFixed(2)]; };
183
+ assert.deepEqual([by("merged"), by("dependency-blocked"), by("invalid")], [[7, "52.97"], [3, "1.27"], [2, "0.82"]]);
184
+ const b1 = await batchEfficiency({ batchDir: join(FX, "batches", B1), runsDir: join(FX, "runs"), now: NOW });
185
+ assert.equal(b1.merged + b.merged, 10);
186
+ });
187
+ it("a gh failure leaves quality and acceptance n/a with a note, never throws", async () => {
188
+ const m = await window(async () => { throw new Error("HTTP 502"); });
189
+ assert.equal(m.quality, null);
190
+ assert.equal(m.acceptance, null);
191
+ assert.match(formatEfficiencyLines(m).join("\n"), /issue quality: n\/a \(gh failed \(HTTP 502\)\)/);
192
+ });
193
+ it("windows exclude batches outside the window and other repos", async () => {
194
+ const out = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, now: NOW + 30 * DAY, windows: [7 * DAY, 60 * DAY] });
195
+ assert.deepEqual([out["7d"].batches, out["7d"].merged, out["60d"].batches, out["60d"].merged], [0, 0, 2, 10]);
196
+ const other = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: "o/r", now: NOW, windows: [7 * DAY] });
197
+ assert.equal(other["7d"].batches, 0);
198
+ });
199
+ it("an issue in two batches counts once, with its latest row", async () => {
200
+ const root = tmpDir("eff-root-");
201
+ for (const [id, created, outcome] of [["b1", "2026-10-20T00:00:00Z", "deferred"], ["b2", "2026-10-21T00:00:00Z", "merged"]]) {
202
+ mkdirSync(join(root, id), { recursive: true });
203
+ writeFileSync(join(root, id, "plan.json"), JSON.stringify({ v: 1, id, repo: "o/r", created }));
204
+ writeFileSync(join(root, id, "report.json"), JSON.stringify({ rows: [{ issue: 7, outcome, cost_usd: 1 }] }));
205
+ }
206
+ const m = (await windowEfficiency({ batchRoot: root, runsDir: tmpDir("eff-runs-"), repo: "o/r", now: NOW, windows: [7 * DAY] }))["7d"];
207
+ assert.deepEqual([m.batches, m.merged], [2, 1]);
208
+ });
209
+ });
210
+
211
+ describe("#4402 forgedock stats --efficiency", () => {
212
+ it("parses the flags and rejects --batch/--window without --efficiency", () => {
213
+ const o = parseArgs(["--efficiency", "--batch", B2, "--window", "7d,30d"]);
214
+ assert.deepEqual([o.efficiency, o.batch, o.windows], [true, B2, [7 * DAY, 30 * DAY]]);
215
+ assert.throws(() => parseArgs(["--batch", B2]), /need --efficiency/);
216
+ assert.throws(() => parseArgs(["--efficiency", "--batch", "../x"]), /invalid --batch/);
217
+ assert.throws(() => parseWindows("7w"), /--window/);
218
+ });
219
+ it("prints the batch and each window", async () => {
220
+ const out = [], w = process.stdout.write;
221
+ process.stdout.write = (t) => { out.push(String(t)); return true; };
222
+ let code;
223
+ try {
224
+ code = await runStats(["--efficiency", "--batch", B2, "--window", "7d", "--repo", AL],
225
+ { batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), now: () => NOW, gh: async () => { throw new Error("offline"); } });
226
+ } finally { process.stdout.write = w; }
227
+ assert.equal(code, 0);
228
+ const text = out.join("");
229
+ assert.match(text, new RegExp(`^efficiency \\(batch ${B2}\\): merged 7,`, "m"));
230
+ assert.match(text, /^efficiency \(7d, 2 batches\): merged 10, \$8\.91\/merge \(implement \$85\.98, review \$3\.13\), turns\/merge 80\.2, context\/turn 114982$/m);
231
+ assert.match(text, /^no action: 32 lanes, \$10\.13 \(blocked-promotion 32\)$/m);
232
+ });
233
+ });
@@ -77,7 +77,7 @@ export function runsFromRecords(records) {
77
77
  const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
78
78
  const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
79
79
  finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
80
- phases: {}, _first: null };
80
+ phases: {}, builderAttempts: [], _first: null };
81
81
  byRun.set(key, run);
82
82
  if (!run._first || r.ts < run._first) run._first = r.ts;
83
83
  if (r.event === "run_terminal") {
@@ -92,8 +92,10 @@ export function runsFromRecords(records) {
92
92
  p.out += num(r.output_tokens) ?? 0;
93
93
  p.attempts++;
94
94
  if (num(r.duration_ms) != null) p.ms = (p.ms ?? 0) + num(r.duration_ms);
95
- if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
95
+ const usd = num(r.cost_usd_delta) ?? num(r.cost_usd); // #4407: cost_usd is a running total for a resumed session
96
+ if (usd != null) p.usd = (p.usd ?? 0) + usd;
96
97
  if (id === "remediate") run.remediated = true;
98
+ if (id === "implement") run.builderAttempts.push({ attempt: num(r.attempt), trigger: r.trigger ?? null, restart_of: r.restart_of ?? null, cost_usd: usd, _ts: r.ts ?? "" }); // #4362
97
99
  }
98
100
  const latest = new Map();
99
101
  for (const run of byRun.values()) {
@@ -102,7 +104,10 @@ export function runsFromRecords(records) {
102
104
  const a = run._first ?? "", b = cur?._first ?? "";
103
105
  if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
104
106
  }
105
- return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
107
+ // #4362: attempt order (a timeout restart reuses its number: then record time); stable for equal keys
108
+ const order = (a, b) => (a.attempt ?? 0) - (b.attempt ?? 0) || (a._ts < b._ts ? -1 : a._ts > b._ts ? 1 : 0);
109
+ return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first,
110
+ builderAttempts: [...run.builderAttempts].sort(order).map(({ _ts, ...a }) => a) }));
106
111
  }
107
112
 
108
113
  /** Gate/review records (everything that is not a run record) become aggregate events. */
@@ -9,16 +9,20 @@ import { execFileSync } from "node:child_process";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
13
- import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
14
- import { readLocalRuns, runsDir } from "./local-source.mjs";
12
+ import { aggregate, computeAlerts, DEFAULT_ALERTS, DEFAULT_MIN_N } from "./aggregate.mjs";
13
+ import { defaultGh, fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
14
+ import { readLanes, readLocalRuns, runsDir } from "./local-source.mjs";
15
+ import { parseForgeYaml } from "../forge-utils.mjs";
15
16
  import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
16
- import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
17
+ import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers } from "./query.mjs";
18
+ import { batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows } from "./efficiency.mjs";
19
+ import { batchRoot } from "../lean/batch.mjs";
17
20
 
18
21
  const here = dirname(fileURLToPath(import.meta.url));
19
22
 
20
23
  export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
21
24
  [--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
25
+ forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--repo <owner/repo>] [--json]
22
26
 
23
27
  Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
24
28
  delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
@@ -36,6 +40,10 @@ delta against the previous version and a low-n flag for samples under 10. Missin
36
40
  --release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
37
41
  (default: git tags v* of the ForgeDock checkout)
38
42
 
43
+ ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
44
+ (DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
45
+ thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
46
+
39
47
  Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry, no comment bodies read):
40
48
  --edge FROM:TO merged PRs that added the dependency or import edge FROM -> TO (e.g. @s/web:@s/db)
41
49
  --milestone M conformance verdict mix and per-day structure series for milestone M
@@ -43,10 +51,20 @@ Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry,
43
51
  Context-pack A/B (forge#4275; local run logs only, no GitHub crawl):
44
52
  --context-ab first-pass yield, builder turns, cache tokens and cost per lane type (rerun, child,
45
53
  first) and context_pack mode (on, off, shadow); --json for JSON
54
+
55
+ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues' bodies, labels and probes):
56
+ --efficiency $/merge, turns/merge, context/turn per size and model, retries/merge by cause, no-action
57
+ lanes, issue quality vs cost and the acceptance pass rate 7 days after promotion
58
+ --batch <id> also report one batch (~/.forge/batches/<id>, FORGEDOCK_BATCH_DIR)
59
+ --window <list> trailing windows over the repo's batches (default 7d,30d)
60
+
61
+ Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
62
+ --tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
63
+ (mid, mid->top escalated, top) and routing class; --json for JSON
46
64
  `;
47
65
 
48
66
  export function parseArgs(argv) {
49
- const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
67
+ const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, tiers: false, efficiency: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
50
68
  const val = (i, name) => {
51
69
  const v = argv[i + 1];
52
70
  if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
@@ -63,6 +81,10 @@ export function parseArgs(argv) {
63
81
  else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
64
82
  else if (name === "--milestone") o.milestone = take();
65
83
  else if (name === "--context-ab") o.contextAb = true;
84
+ else if (name === "--tiers") o.tiers = true;
85
+ else if (name === "--efficiency") o.efficiency = true;
86
+ else if (name === "--batch") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error(`invalid --batch id: ${v}`); o.batch = v; }
87
+ else if (name === "--window") o.windows = parseWindows(take());
66
88
  else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
67
89
  else if (name === "--json") o.json = true;
68
90
  else if (name === "--refresh") o.refresh = true;
@@ -72,6 +94,7 @@ export function parseArgs(argv) {
72
94
  else if (name === "--help" || name === "-h") o.help = true;
73
95
  else throw new Error(`unknown argument: ${a}`);
74
96
  }
97
+ if ((o.batch || o.windows) && !o.efficiency) throw new Error("--batch and --window need --efficiency");
75
98
  return o;
76
99
  }
77
100
 
@@ -153,6 +176,25 @@ export function resolveRepo(explicit, cwd = process.cwd()) {
153
176
  return null;
154
177
  }
155
178
 
179
+ const ALERT_KEYS = { window: "window", min_lanes: "minLanes", deps_failed_pct: "depsFailedPct", deferred_to_ci_pct: "deferredPct",
180
+ toolchain_warning_pct: "toolchainWarningPct", outcome_share_factor: "outcomeShareFactor" };
181
+
182
+ /**
183
+ * forge.yaml `stats.alerts.*` (forge#4354). The one-level parser flattens a nested `alerts:` block under `stats:` to
184
+ * `stats.<key>`; the flat `stats.alerts_<key>` form also works and wins. Invalid or unreadable values keep DEFAULT_ALERTS.
185
+ */
186
+ export function readAlertConfig(cwd = process.cwd()) {
187
+ const out = { ...DEFAULT_ALERTS };
188
+ try {
189
+ const s = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.stats ?? {};
190
+ for (const [key, name] of Object.entries(ALERT_KEYS)) {
191
+ const raw = String(s[`alerts_${key}`] ?? s[key] ?? "").trim(), n = Number(raw);
192
+ if (raw !== "" && Number.isFinite(n) && n > 0) out[name] = n;
193
+ }
194
+ } catch { /* no forge.yaml: defaults */ }
195
+ return out;
196
+ }
197
+
156
198
  /** Build the report object. deps: { gh, env, localRuns, now, onProgress, warn, sleep } for tests. Never touches the network when `gh` is stubbed. */
157
199
  export async function buildReport(opts, deps = {}) {
158
200
  const env = deps.env ?? process.env;
@@ -161,10 +203,21 @@ export async function buildReport(opts, deps = {}) {
161
203
  if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
162
204
  else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
163
205
  else releaseTimes = gitTagTimes();
206
+ if (opts.efficiency) { // forge#4402: local batch dirs + run logs; gh only for merged issues
207
+ const root = deps.batchRoot ?? batchRoot(env), rd = deps.runsDir ?? runsDir(env), now = deps.now?.() ?? Date.now(), gh = deps.gh ?? defaultGh;
208
+ const efficiency = {};
209
+ if (opts.batch) efficiency.batch = await batchEfficiency({ batchDir: join(root, opts.batch), runsDir: rd, repo, gh, now });
210
+ efficiency.windows = await windowEfficiency({ batchRoot: root, runsDir: rd, repo, gh, now, windows: opts.windows ?? parseWindows("7d,30d") });
211
+ return { repo, query: { efficiency } };
212
+ }
164
213
  if (opts.contextAb) { // forge#4275: run logs only
165
214
  const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
166
215
  return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
167
216
  }
217
+ if (opts.tiers) { // forge#4394: run logs only
218
+ const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
219
+ return { repo, sources: { local: { runs: runs.length } }, query: { tiers: builderTierReport(runs) } };
220
+ }
168
221
  const bound = sinceToTimestamp(opts.since, releaseTimes);
169
222
  if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
170
223
  const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
@@ -182,7 +235,9 @@ export async function buildReport(opts, deps = {}) {
182
235
  }
183
236
  const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
184
237
  { minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
185
- return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report };
238
+ // forge#4354: fail-soft alerts from the local run logs only (no gh call)
239
+ const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
240
+ return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
186
241
  }
187
242
 
188
243
  export async function runStats(argv, deps = {}) {
@@ -199,6 +254,14 @@ export async function runStats(argv, deps = {}) {
199
254
  }
200
255
  try {
201
256
  const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
257
+ if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
258
+ if (report.query?.efficiency && !opts.json) {
259
+ const e = report.query.efficiency;
260
+ const lines = [...(e.batch ? formatEfficiencyLines(e.batch, `batch ${e.batch.batch ?? opts.batch}`) : []),
261
+ ...Object.entries(e.windows).flatMap(([w, m]) => formatEfficiencyLines(m, `${w}, ${m.batches} batch${m.batches === 1 ? "" : "es"}`))];
262
+ process.stdout.write(`${lines.join("\n")}\n`);
263
+ return 0;
264
+ }
202
265
  if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
203
266
  process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
204
267
  return 0;
@@ -21,6 +21,8 @@ function parseLog(text) {
21
21
  }
22
22
 
23
23
  const n = (x) => (Number.isFinite(x) ? x : 0);
24
+ /** #4407: an attempt's own cost; `cost_usd` is a running total for a resumed session, so the delta wins when present. */
25
+ const costOf = (e) => (Number.isFinite(e.cost_usd_delta) ? e.cost_usd_delta : e.cost_usd);
24
26
 
25
27
  /** Summarise one log: the last RUN_START..end slice. Returns null when there is no RUN_START. */
26
28
  export function summariseLog(events, fallbackIssue = null) {
@@ -47,7 +49,7 @@ export function summariseLog(events, fallbackIssue = null) {
47
49
  p.out += n(e.usage.output_tokens);
48
50
  p.attempts++;
49
51
  if (attempt && Number.isFinite(e.duration_ms)) p.ms = (p.ms ?? 0) + e.duration_ms;
50
- if (attempt && Number.isFinite(e.cost_usd)) p.usd = (p.usd ?? 0) + e.cost_usd;
52
+ if (attempt && Number.isFinite(costOf(e))) p.usd = (p.usd ?? 0) + costOf(e);
51
53
  }
52
54
  const finished = t >= 0 && (r < 0 || t > r);
53
55
  // forge#4275: the context-pack A/B inputs. Builder calls log as phase "implement" (the plan call is the first).
@@ -68,14 +70,55 @@ export function summariseLog(events, fallbackIssue = null) {
68
70
  startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
69
71
  phases,
70
72
  contextPack,
73
+ // #4362: builder attempts in log order with their trigger (null before the field existed)
74
+ builderAttempts: builderCalls.map((e) => ({ attempt: e.attempt ?? null, trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, cost_usd: Number.isFinite(costOf(e)) ? costOf(e) : null })),
75
+ // #4375: pitfalls in the pack matching the issue's files (RUN_TERMINAL, else the last CONTEXT_PACK); null before the field
76
+ pitfallsMatched: Number.isInteger(term?.pitfalls_matched) ? term.pitfalls_matched
77
+ : Array.isArray(cp?.pitfalls) ? cp.pitfalls.filter((p) => p?.match === "files").length : null,
71
78
  builderTurns: builderCalls.reduce((x, e) => x + n(e.turns), 0),
79
+ size: term?.turns?.size ?? null, // #4409: S|M|L from the lane's turn record, buckets the admission estimate history
72
80
  cacheTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens) + n(e.usage?.cache_creation_input_tokens), 0),
73
- costUsd: usage.reduce((x, e) => x + n(e.cost_usd), 0),
81
+ costUsd: usage.reduce((x, e) => x + n(costOf(e)), 0),
82
+ // #4394: the builder tier (RUN_TERMINAL builder_*, else the BUILDER_ROUTE / BUILDER_ESCALATED events); null before routing
83
+ builderRoute: routeOf(term, ev),
84
+ conformanceRound: builderCalls.some((e) => e.trigger === "conformance" || e.trigger === "review"),
85
+ shadowWould: typeof term?.shadow_would === "string" ? term.shadow_would : null,
86
+ reachedReview: reviewCalls > 0 || typeof term?.shadow_would === "string",
87
+ // #4394: routes of earlier runs in this log that ended merged: a later run of the same issue is post-merge rework
88
+ reworkedMerges: earlierMergedRoutes(events.slice(0, s)),
74
89
  // first pass: merged off one plan + one implement call, at most one review, no CI fix round
75
90
  firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
76
91
  };
77
92
  }
78
93
 
94
+ /** #4394: {tier, class, escalated, model} of a lane, or null when it computed no builder route. */
95
+ function routeOf(term, ev) {
96
+ if (typeof term?.builder_tier === "string") {
97
+ return { tier: term.builder_tier, class: term.builder_class ?? null, escalated: term.builder_escalated === true, model: term.builder_model ?? null };
98
+ }
99
+ const r = [...ev].reverse().find((e) => e.event === "BUILDER_ROUTE");
100
+ if (!r) return null;
101
+ return { tier: r.tier ?? null, class: r.class ?? null, escalated: ev.some((e) => e.event === "BUILDER_ESCALATED"), model: r.model ?? null };
102
+ }
103
+
104
+ /** #4394: the routes ({tier, class, escalated, model}) of the RUN_START slices in `events` that ended merged with a route. */
105
+ function earlierMergedRoutes(events) {
106
+ const out = [];
107
+ let slice = null;
108
+ const close = () => {
109
+ if (!slice) return;
110
+ const term = [...slice].reverse().find((e) => e.event === "RUN_TERMINAL");
111
+ const rt = term?.reason === "merged" ? routeOf(term, slice) : null;
112
+ if (rt) out.push(rt);
113
+ };
114
+ for (const e of events) {
115
+ if (e.event === "RUN_START") { close(); slice = []; }
116
+ if (slice) slice.push(e);
117
+ }
118
+ close();
119
+ return out;
120
+ }
121
+
79
122
  /** All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). */
80
123
  export function readLocalRuns(dir = runsDir()) {
81
124
  if (!existsSync(dir)) return [];
@@ -83,10 +126,50 @@ export function readLocalRuns(dir = runsDir()) {
83
126
  for (const f of readdirSync(dir).sort()) {
84
127
  if (!f.endsWith(".jsonl")) continue;
85
128
  try {
86
- const m = /^(\d+)\.jsonl$/.exec(f);
129
+ const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
87
130
  const sum = summariseLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null);
88
131
  if (sum) runs.push({ ...sum, mtimeTs: new Date(statSync(join(dir, f)).mtimeMs).toISOString() });
89
132
  } catch { /* unreadable log */ }
90
133
  }
91
134
  return runs;
92
135
  }
136
+
137
+ /**
138
+ * forge#4354: one lane per RUN_START..next-RUN_START slice (every slice, not only the last), with the fail-soft events the
139
+ * alert pass rates: DEPS_PREPARED rows, TOOLCHAIN_WARNING texts, deferred-to-ci precheck commands and the terminal code.
140
+ */
141
+ export function lanesOfLog(events, fallbackIssue = null, fallbackTs = null) {
142
+ const lanes = [];
143
+ let cur = null;
144
+ for (const e of events) {
145
+ if (e.event === "RUN_START") {
146
+ cur = { repo: e.repo ?? null, issue: e.issue ?? fallbackIssue, startTs: e.ts ?? fallbackTs, code: null, reason: null,
147
+ deps: [], toolchainWarnings: [], deferred: [], sparseFallback: null };
148
+ lanes.push(cur);
149
+ continue;
150
+ }
151
+ if (!cur) continue;
152
+ if (e.event === "RUN_TERMINAL") { cur.code = e.code ?? null; cur.reason = e.reason ?? null; }
153
+ else if (e.event === "DEPS_PREPARED") cur.deps.push({ eco: e.eco ?? null, status: e.status ?? null, reason: e.reason ?? null });
154
+ else if (e.event === "TOOLCHAIN_WARNING" && e.warning) cur.toolchainWarnings.push(String(e.warning));
155
+ else if (e.event === "PRECHECK_COMMANDS" && Array.isArray(e.results)) {
156
+ for (const r of e.results) if (r?.status === "deferred-to-ci") cur.deferred.push(String(r.command ?? ""));
157
+ } else if (e.event === "WORKTREE_SPARSE_FALLBACK") cur.sparseFallback = e.reason ?? "";
158
+ }
159
+ return lanes;
160
+ }
161
+
162
+ /** All lanes in `dir` (a missing dir or an unreadable log yields nothing, never an error). Local files only, no network. */
163
+ export function readLanes(dir = runsDir()) {
164
+ if (!existsSync(dir)) return [];
165
+ const lanes = [];
166
+ for (const f of readdirSync(dir).sort()) {
167
+ if (!f.endsWith(".jsonl")) continue;
168
+ try {
169
+ const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
170
+ const mtime = new Date(statSync(join(dir, f)).mtimeMs).toISOString();
171
+ lanes.push(...lanesOfLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null, mtime));
172
+ } catch { /* unreadable log */ }
173
+ }
174
+ return lanes;
175
+ }
@@ -97,3 +97,51 @@ export function renderContextAb(ab) {
97
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
98
  return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}`;
99
99
  }
100
+
101
+ /**
102
+ * forge#4394: builder model tiers over local run summaries (local-source.mjs summariseLog): tier mid, mid->top
103
+ * (escalated) or top, each with its routing class. merge_rate: merged / finished. conformance_rate: of the lanes that
104
+ * reached review, those with no conformance/review fix round and no shadow would-block. post_merge_rework_rate: merges
105
+ * reworked later (a later run of the same issue, or a remediate phase) / merges, where a log's earlier merged runs count
106
+ * as merges too. Runs without a route (older logs, refactor:split) are counted under `unrouted` only.
107
+ */
108
+ export const BUILDER_TIERS = Object.freeze(["mid", "mid->top", "top"]);
109
+ export function builderTierReport(runs) {
110
+ const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
111
+ const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
112
+ const tierOf = (rt) => (rt?.tier === "mid" ? (rt.escalated ? "mid->top" : "mid") : rt?.tier === "top" ? "top" : null);
113
+ const groups = new Map();
114
+ const group = (tier, cls) => {
115
+ const k = `${tier}\u0000${cls}`;
116
+ if (!groups.has(k)) groups.set(k, { tier, class: cls, n: 0, merged: 0, reviewed: 0, conforming: 0, merges: 0, reworked: 0, firstPass: 0, cost: [] });
117
+ return groups.get(k);
118
+ };
119
+ let unrouted = 0;
120
+ for (const r of runs ?? []) {
121
+ for (const m of Array.isArray(r?.reworkedMerges) ? r.reworkedMerges : []) {
122
+ const t = tierOf(m);
123
+ if (t) { const g = group(t, m.class ?? "unknown"); g.merges++; g.reworked++; }
124
+ }
125
+ if (!r?.finished) continue;
126
+ const t = tierOf(r.builderRoute);
127
+ if (!t) { unrouted++; continue; }
128
+ const g = group(t, r.builderRoute.class ?? "unknown");
129
+ g.n++;
130
+ g.cost.push(Number.isFinite(r.costUsd) ? r.costUsd : 0);
131
+ if (r.firstPass) g.firstPass++;
132
+ if (r.reason === "merged") { g.merged++; g.merges++; if (r.remediated) g.reworked++; }
133
+ if (r.reachedReview) { g.reviewed++; if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++; }
134
+ }
135
+ const rows = [...groups.values()]
136
+ .sort((a, b) => BUILDER_TIERS.indexOf(a.tier) - BUILDER_TIERS.indexOf(b.tier) || a.class.localeCompare(b.class))
137
+ .map((g) => ({ tier: g.tier, class: g.class, n: g.n, merge_rate: rate(g.merged, g.n), conformance_rate: rate(g.conforming, g.reviewed), reviewed: g.reviewed,
138
+ post_merge_rework_rate: rate(g.reworked, g.merges), merges: g.merges, first_pass_yield: rate(g.firstPass, g.n), cost_usd: mean(g.cost) }));
139
+ return { rows, unrouted };
140
+ }
141
+
142
+ /** Markdown table for builderTierReport. */
143
+ export function renderTiers(rep) {
144
+ const f = (v) => (v == null ? "n/a" : String(v));
145
+ 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
+ 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
+ }