forgedock 1.30.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.
Files changed (162) 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 +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
package/bin/runner.mjs CHANGED
@@ -1983,17 +1983,23 @@ export function runCliBackend({
1983
1983
  }
1984
1984
  }
1985
1985
 
1986
- /** Descendant processes of `pid` (pid + command line), best effort. Used to record what was alive when a session was killed. */
1987
- export function listDescendants(pid, { psFn = null } = {}) {
1986
+ /**
1987
+ * Descendant processes of `pid` (pid + command line), best effort. Used to record what was alive when a session was killed;
1988
+ * `maxCmd` caps each command line (the #4405 busy check needs it whole: a long script path would cut off the tool name).
1989
+ */
1990
+ export function listDescendants(pid, { psFn = null, maxCmd = 200 } = {}) {
1988
1991
  try {
1989
1992
  const out = psFn ? psFn() : execSync("ps -eo pid=,ppid=,args=", { encoding: "utf-8", timeout: 3000, maxBuffer: 8 * 1024 * 1024 });
1990
- const rows = String(out).split("\n").map((l) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l)).filter(Boolean).map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3].slice(0, 200) }));
1993
+ const rows = String(out).split("\n").map((l) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(l)).filter(Boolean).map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3].slice(0, maxCmd) }));
1991
1994
  const kids = []; const seen = new Set([Number(pid)]);
1992
1995
  for (let grew = true; grew;) { grew = false; for (const r of rows) if (!seen.has(r.pid) && seen.has(r.ppid)) { seen.add(r.pid); kids.push({ pid: r.pid, cmd: r.cmd }); grew = true; } }
1993
1996
  return kids.slice(0, 30);
1994
1997
  } catch { return []; }
1995
1998
  }
1996
1999
 
2000
+ /** #4410: cap of the tool uses one stream session records. */
2001
+ const TOOL_USES_MAX = 5000;
2002
+
1997
2003
  /**
1998
2004
  * Async twin of runCliBackend (core work-on live comments): the CLI runs under child_process.spawn so the caller's event
1999
2005
  * loop keeps turning while the session runs. It returns the SAME envelope (and throws the same errors) because the
@@ -2005,9 +2011,19 @@ export function listDescendants(pid, { psFn = null } = {}) {
2005
2011
  * stream-json so progress is observable (the final `result` line is the envelope); `wrap(bin,args)` rewrites the spawn
2006
2012
  * (cgroup caps); `control` receives {pause, resume} (SIGSTOP/SIGCONT of the child's whole process group) and paused time
2007
2013
  * does not count as idle. `onSpawn(child)` receives the ChildProcess.
2014
+ * #4390: `contextMax` (stream only; 0 = off) kills the session once one assistant turn's context (input + cache read +
2015
+ * cache creation) reaches it, throwing CLI_CONTEXT with the stream-aggregated usage (null when none was seen) and turns.
2016
+ * #4392: `turnMax` (stream only; 0 = off) kills the session once it has produced that many assistant turns (deduped by
2017
+ * message id), throwing CLI_TURNS with `turns`, `turnMax`, `lastTool` and the stream-aggregated usage.
2018
+ * #4405: `busy(children)` (info or null) is asked once idle (at most every min(5 s, idleMs)); while it answers busy (a live
2019
+ * heavy step, test or lint child) the kill waits until idleMs + `busyMs` of silence, `onIdleExtend(info)` is told once per
2020
+ * idle stretch, and a later kill carries `extended: true` and `heavyQueue` (info.heavyQueue).
2021
+ * #4395: a stream result carries `baselineTokens`, the first assistant turn's context; absent when no message was seen.
2022
+ * #4410: a stream result (and a CLI_TURNS / CLI_CONTEXT error) carries `toolUses`, [{msg, name, path, cmd}] per tool_use
2023
+ * (deduped by tool_use id, capped at TOOL_USES_MAX), for bin/lean/toolstats.mjs; absent on the non-stream path.
2008
2024
  */
2009
2025
  export async function runCliBackendAsync(opts) {
2010
- const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, ...rest } = opts;
2026
+ const { spawnFn: _ignored, onSpawn = null, idleMs = 0, maxMs = 0, stream = false, wrap = null, control = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null, ...rest } = opts;
2011
2027
  let captured = null;
2012
2028
  try {
2013
2029
  runCliBackend({ ...rest, spawnFn: (bin, args, o) => {
@@ -2032,14 +2048,16 @@ export async function runCliBackendAsync(opts) {
2032
2048
  }
2033
2049
  if (typeof wrap === "function") ({ bin, args } = wrap(bin, args));
2034
2050
  const ceiling = maxMs > 0 ? maxMs : o.timeout;
2035
- let collected, idleInfo = null;
2051
+ let collected, idleInfo = null, ctxInfo = null, turnInfo = null;
2052
+ const msgUsage = new Map(); // #4390: assistant message id -> its usage (a message spans several stream lines)
2053
+ const toolUses = new Map(); // #4410: tool_use id -> {msg, name, path, cmd}
2036
2054
  try {
2037
2055
  collected = await new Promise((resolveP) => {
2038
2056
  let stdout = "", stderr = "", settled = false, timedOut = false, paused = false;
2039
2057
  let lineBuf = "", resultLine = null, lastTool = null;
2040
2058
  const max = o.maxBuffer ?? DEFAULT_SPAWN_MAX_BUFFER_BYTES;
2041
2059
  const startedAt = Date.now();
2042
- let lastActivity = startedAt, pausedAt = 0, pausedTotal = 0, timer = null;
2060
+ let lastActivity = startedAt, pausedAt = 0, pausedTotal = 0, timer = null, busyAsk = null, extendedSince = null, extendInfo = null;
2043
2061
  const finish = (r) => { if (settled) return; settled = true; clearInterval(timer); resolveP(r); };
2044
2062
  let child;
2045
2063
  const input = typeof o.input === "string" ? o.input : null; // forge#4129: large prompt over stdin
@@ -2058,10 +2076,29 @@ export async function runCliBackendAsync(opts) {
2058
2076
  const tick = Math.max(20, Math.min(1000, (idleMs || 4000) / 4));
2059
2077
  timer = setInterval(() => {
2060
2078
  const now = Date.now();
2061
- if (!paused && idleMs > 0 && now - lastActivity >= idleMs && !idleInfo) {
2062
- idleInfo = { lastTool, children: listDescendants(child.pid), idleMs, pid: child.pid };
2063
- kill();
2064
- } else if (!timedOut && !idleInfo && now - startedAt - pausedTotal - (paused ? now - pausedAt : 0) >= ceiling) { timedOut = true; kill(); }
2079
+ if (!paused && idleMs > 0 && now - lastActivity >= idleMs && !idleInfo && !ctxInfo && !turnInfo) {
2080
+ let info = null, settle = false;
2081
+ if (typeof busy === "function" && busyMs > 0 && now - lastActivity < idleMs + busyMs) {
2082
+ const prev = busyAsk && busyAsk.since === lastActivity ? busyAsk : null;
2083
+ if (prev && now - prev.at < Math.min(5000, idleMs)) info = prev.info;
2084
+ else {
2085
+ try { info = busy(listDescendants(child.pid, { maxCmd: 8192 })) || null; } catch { info = null; }
2086
+ busyAsk = { at: now, since: lastActivity, info };
2087
+ // The synchronous `ps` blocked the loop: output the child wrote meanwhile is read before a first "not busy" kills.
2088
+ settle = !info && !(prev && prev.info === null);
2089
+ }
2090
+ }
2091
+ if (settle) { /* re-decided next tick */ } else if (info) {
2092
+ extendInfo = info;
2093
+ if (extendedSince !== lastActivity) { extendedSince = lastActivity; try { onIdleExtend?.(info); } catch { /* observer only */ } }
2094
+ } else {
2095
+ const extended = extendedSince === lastActivity;
2096
+ idleInfo = { lastTool, children: listDescendants(child.pid), idleMs, pid: child.pid, extended, heavyQueue: extended ? extendInfo?.heavyQueue ?? null : null };
2097
+ kill();
2098
+ }
2099
+ }
2100
+ // #4405: a separate check, so the absolute ceiling still applies while the idle kill is extended (busy) or settling.
2101
+ if (!timedOut && !idleInfo && !ctxInfo && !turnInfo && now - startedAt - pausedTotal - (paused ? now - pausedAt : 0) >= ceiling) { timedOut = true; kill(); }
2065
2102
  }, tick);
2066
2103
  timer.unref?.();
2067
2104
  const noteLine = (line) => {
@@ -2069,7 +2106,20 @@ export async function runCliBackendAsync(opts) {
2069
2106
  let j; try { j = JSON.parse(line); } catch { return; }
2070
2107
  if (j?.type === "result") resultLine = line;
2071
2108
  else if (j?.type === "assistant" && Array.isArray(j.message?.content)) {
2072
- for (const c of j.message.content) if (c?.type === "tool_use") lastTool = { name: c.name, input: JSON.stringify(c.input ?? {}).slice(0, 200), at: Date.now() };
2109
+ for (const c of j.message.content) {
2110
+ if (c?.type !== "tool_use") continue;
2111
+ lastTool = { name: c.name, input: JSON.stringify(c.input ?? {}).slice(0, 200), at: Date.now() };
2112
+ const key = c.id ?? `${j.message.id}#${toolUses.size}`, inp = c.input ?? {};
2113
+ if (!toolUses.has(key) && toolUses.size < TOOL_USES_MAX) toolUses.set(key, { msg: j.message.id ?? null, name: c.name ?? null,
2114
+ path: typeof (inp.file_path ?? inp.path) === "string" ? (inp.file_path ?? inp.path) : null, cmd: typeof inp.command === "string" ? inp.command.slice(0, 1000) : null });
2115
+ }
2116
+ const u = j.message.usage;
2117
+ if (u && typeof u === "object") {
2118
+ msgUsage.set(j.message.id ?? `#${msgUsage.size}`, u);
2119
+ const ctx = toFiniteUsageNumber(u.input_tokens) + toFiniteUsageNumber(u.cache_read_input_tokens) + toFiniteUsageNumber(u.cache_creation_input_tokens);
2120
+ if (contextMax > 0 && ctx >= contextMax && !ctxInfo && !turnInfo && !idleInfo && !timedOut) { ctxInfo = { context: ctx, lastTool }; kill(); }
2121
+ else if (turnMax > 0 && msgUsage.size >= turnMax && !ctxInfo && !turnInfo && !idleInfo && !timedOut) { turnInfo = { lastTool }; kill(); }
2122
+ }
2073
2123
  }
2074
2124
  };
2075
2125
  child.stdout.on("data", (d) => {
@@ -2090,13 +2140,32 @@ export async function runCliBackendAsync(opts) {
2090
2140
  });
2091
2141
  });
2092
2142
  } finally { if (sysDir) rmSync(sysDir, { recursive: true, force: true }); }
2143
+ const sum = (k) => [...msgUsage.values()].reduce((a, u) => a + toFiniteUsageNumber(u[k]), 0);
2144
+ const streamUsage = () => (msgUsage.size ? { input_tokens: sum("input_tokens"), output_tokens: sum("output_tokens"), cache_creation_input_tokens: sum("cache_creation_input_tokens"), cache_read_input_tokens: sum("cache_read_input_tokens") } : null);
2145
+ if (turnInfo) {
2146
+ const err = new Error(`claude CLI session reached its turn budget (${turnMax} turns) and was stopped`);
2147
+ err.code = "CLI_TURNS"; err.turns = msgUsage.size; err.turnMax = turnMax; err.lastTool = turnInfo.lastTool; err.usage = streamUsage(); err.toolUses = [...toolUses.values()];
2148
+ throw err;
2149
+ }
2150
+ if (ctxInfo) {
2151
+ const err = new Error(`claude CLI session reached ${ctxInfo.context} context tokens (ceiling ${contextMax}) after ${msgUsage.size} turn(s) and was stopped`);
2152
+ err.code = "CLI_CONTEXT"; err.context = ctxInfo.context; err.contextMax = contextMax; err.turns = msgUsage.size; err.lastTool = ctxInfo.lastTool;
2153
+ err.usage = streamUsage(); err.toolUses = [...toolUses.values()];
2154
+ throw err;
2155
+ }
2093
2156
  if (idleInfo) {
2094
2157
  const mins = Math.round((idleInfo.idleMs / 60000) * 10) / 10;
2095
- const err = new Error(`claude CLI had no stream activity for ${mins} min and was killed (idle). Last tool: ${idleInfo.lastTool ? `${idleInfo.lastTool.name} ${idleInfo.lastTool.input}` : "none"}. Live children: ${idleInfo.children.map((c) => `${c.pid}:${c.cmd}`).join("; ") || "none"}`);
2158
+ const err = new Error(`claude CLI had no stream activity for ${mins} min and was killed (idle${idleInfo.extended ? ", after the busy extension" : ""}). Last tool: ${idleInfo.lastTool ? `${idleInfo.lastTool.name} ${idleInfo.lastTool.input}` : "none"}. Live children: ${idleInfo.children.map((c) => `${c.pid}:${c.cmd}`).join("; ") || "none"}`);
2096
2159
  err.code = "CLI_IDLE"; err.lastTool = idleInfo.lastTool; err.children = idleInfo.children; err.idleMs = idleInfo.idleMs;
2160
+ err.extended = idleInfo.extended; err.heavyQueue = idleInfo.heavyQueue;
2097
2161
  throw err;
2098
2162
  }
2099
- return runCliBackend({ ...rest, spawnFn: () => collected });
2163
+ const r = runCliBackend({ ...rest, spawnFn: () => collected });
2164
+ // #4395: the session baseline is the first assistant turn's context (system prompt, tools, instructions, first prompt).
2165
+ const first = msgUsage.values().next().value;
2166
+ if (stream && toolUses.size && r && typeof r === "object") r.toolUses = [...toolUses.values()];
2167
+ if (first && r && typeof r === "object") r.baselineTokens = toFiniteUsageNumber(first.input_tokens) + toFiniteUsageNumber(first.cache_read_input_tokens) + toFiniteUsageNumber(first.cache_creation_input_tokens);
2168
+ return r;
2100
2169
  }
2101
2170
 
2102
2171
  // ---------------------------------------------------------------------------
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * Input record model (every source maps onto it; a missing field is null and degrades to "n/a"):
7
7
  * run { source, repo, version, issue, run, finished, hydrated, usageEvents, reason, code, remediated,
8
- * startTs, endTs, wallMs, phases: { <id>: { cr, out, usd, ms, attempts } } }
8
+ * startTs, endTs, wallMs, phases: { <id>: { cr, out, usd, ms, attempts } },
9
+ * builderAttempts: [{ attempt, trigger, cost_usd }] (#4362; trigger null before the field existed),
10
+ * pitfallsMatched (#4375: pitfalls in the context pack matching the issue's files; null when unknown, local logs only) }
9
11
  * event { repo, version, issue, pr, ts, event, ...payload } (FORGE:TELEMETRY gate/review events)
10
12
  * issue { repo, number, isPr, createdAt, closedAt, labels: [name], authorType }
11
13
  *
@@ -14,6 +16,8 @@
14
16
  * per-phase mean divides by n (a run that skipped a phase counts 0).
15
17
  */
16
18
 
19
+ import { REWORK_TRIGGERS, firstPassOf } from "../lean/fixround.mjs";
20
+
17
21
  export const DEFAULT_MIN_N = 10;
18
22
  export const UNKNOWN = "unknown";
19
23
 
@@ -113,6 +117,35 @@ function flowSection(runs) {
113
117
  };
114
118
  }
115
119
 
120
+ /**
121
+ * #4362: builder attempts by trigger (all, and rework only) and the first-pass rate. An attempt without a trigger
122
+ * (written before the field) counts as "unknown" rework, never dropped; a run without one is unknown for first pass.
123
+ */
124
+ function reworkSection(runs) {
125
+ const attempts = {}, byTrigger = {};
126
+ let n = 0, usd = 0, firstPass = 0, measured = 0, unknown = 0;
127
+ const split = { with: { firstPass: 0, n: 0 }, without: { firstPass: 0, n: 0 } }; // #4375: by matching pitfalls in the pack
128
+ for (const r of runs) {
129
+ const list = Array.isArray(r.builderAttempts) ? r.builderAttempts : [];
130
+ for (const a of list) {
131
+ const t = a?.trigger || UNKNOWN, c = isNum(a?.cost_usd) ? a.cost_usd : 0;
132
+ const all = (attempts[t] ??= { n: 0, usd: 0 });
133
+ all.n++; all.usd += c;
134
+ if (t !== UNKNOWN && !REWORK_TRIGGERS.includes(t)) continue;
135
+ const d = (byTrigger[t] ??= { n: 0, usd: 0 });
136
+ d.n++; d.usd += c; n++; usd += c;
137
+ }
138
+ const fp = firstPassOf(list);
139
+ // unknown: untagged attempts or no attempt list (an older cache); a run that never reached the build is not a lane here
140
+ if (fp == null) { if (!Array.isArray(r.builderAttempts) || list.some((a) => !a?.trigger)) unknown++; continue; }
141
+ measured++;
142
+ if (fp) firstPass++;
143
+ if (Number.isInteger(r.pitfallsMatched)) { const s = split[r.pitfallsMatched > 0 ? "with" : "without"]; s.n++; if (fp) s.firstPass++; }
144
+ }
145
+ const firstPassByPitfalls = Object.fromEntries(Object.entries(split).map(([k, v]) => [k, { rate: ratio(v.firstPass, v.n), ...v }]));
146
+ return { rework: { n, usd, byTrigger, attempts }, firstPass: { rate: ratio(firstPass, measured), firstPass, n: measured, unknown }, firstPassByPitfalls };
147
+ }
148
+
116
149
  function funnelSection(events, issues) {
117
150
  const findings = events.filter((e) => e.event === "finding_disposition");
118
151
  const reviews = new Set([...events.filter((e) => e.event === "review_panel"), ...findings]
@@ -217,7 +250,8 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
217
250
  let groups = [...keys.values()].map(({ version, repo }) => {
218
251
  const sel = (list) => list.filter((x) => x.version === version && x.repo === repo);
219
252
  const g = { version, repo, cost: costSection(sel(counted)), time: timeSection(sel(counted)), flow: flowSection(sel(counted)),
220
- funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null };
253
+ funnel: funnelSection(sel(evs), sel(iss)), gates: gatesSection(sel(evs)), issueFlow: null,
254
+ ...reworkSection(sel(counted)) };
221
255
  g.issueFlow = issueFlowSection(sel(iss), g.funnel.introduced);
222
256
  g.n = g.flow.n;
223
257
  g.lowN = g.n < minN;
@@ -233,3 +267,54 @@ export function aggregate({ runs = [], events = [], issues = [] } = {}, opts = {
233
267
  if (opts.sinceVersion) groups = groups.filter((g) => g.version !== UNKNOWN && compareVersions(g.version, opts.sinceVersion) >= 0);
234
268
  return { groups, excluded, minN };
235
269
  }
270
+
271
+ /** forge#4354: fail-soft alert thresholds (forge.yaml `stats.alerts.*`). Percentages are 0..100. */
272
+ export const DEFAULT_ALERTS = { window: 50, minLanes: 10, depsFailedPct: 20, deferredPct: 30, toolchainWarningPct: 50, outcomeShareFactor: 2 };
273
+
274
+ /**
275
+ * Rates of fail-soft events over the last `window` lanes of `repo` (lanes from local-source `readLanes`), ordered by start
276
+ * time. Nothing below `minLanes`. Rules: DEPS_PREPARED failed per ecosystem, deferred-to-ci, each TOOLCHAIN_WARNING text,
277
+ * and outcome codes (except `completed`) whose share doubled against the previous window. Pure: no I/O, no clock.
278
+ */
279
+ export function computeAlerts(lanes = [], opts = {}) {
280
+ const c = { ...DEFAULT_ALERTS };
281
+ for (const k of Object.keys(DEFAULT_ALERTS)) if (isNum(opts[k]) && opts[k] > 0) c[k] = opts[k];
282
+ const all = (lanes ?? []).filter((l) => l && (opts.repo == null || l.repo === opts.repo))
283
+ .map((l, i) => ({ l, i, t: toMs(l.startTs) ?? -Infinity }))
284
+ .sort((a, b) => a.t - b.t || a.i - b.i).map((x) => x.l);
285
+ const win = all.slice(-c.window);
286
+ if (win.length < c.minLanes) return [];
287
+ const prev = all.slice(Math.max(0, all.length - 2 * c.window), all.length - win.length);
288
+ const out = [];
289
+ const add = (event, count, sample, threshold, example, extra = {}) => out.push({ event, rate: count / sample, count, sample, threshold, example, ...extra });
290
+ const ecos = [...new Set(win.flatMap((l) => l.deps.map((d) => d.eco ?? "unknown")))].sort();
291
+ for (const eco of ecos) {
292
+ const used = win.filter((l) => l.deps.some((d) => (d.eco ?? "unknown") === eco));
293
+ const failed = used.filter((l) => l.deps.some((d) => (d.eco ?? "unknown") === eco && d.status === "failed"));
294
+ if (failed.length / used.length > c.depsFailedPct / 100) {
295
+ const ex = failed.flatMap((l) => l.deps).find((d) => (d.eco ?? "unknown") === eco && d.status === "failed");
296
+ add(`DEPS_PREPARED failed (${eco})`, failed.length, used.length, c.depsFailedPct / 100, ex?.reason ?? null);
297
+ }
298
+ }
299
+ const deferred = win.filter((l) => l.deferred.length);
300
+ if (deferred.length / win.length > c.deferredPct / 100) add("deferred-to-ci", deferred.length, win.length, c.deferredPct / 100, deferred[deferred.length - 1].deferred[0]);
301
+ const warnings = [...new Set(win.flatMap((l) => l.toolchainWarnings))].sort();
302
+ for (const w of warnings) {
303
+ const hit = win.filter((l) => l.toolchainWarnings.includes(w)).length;
304
+ if (hit / win.length > c.toolchainWarningPct / 100) add("TOOLCHAIN_WARNING", hit, win.length, c.toolchainWarningPct / 100, w);
305
+ }
306
+ const coded = (list) => list.filter((l) => l.code != null);
307
+ const cw = coded(win), cp = coded(prev);
308
+ if (cw.length && prev.length >= c.minLanes && cp.length) {
309
+ const share = (list, code) => list.filter((l) => l.code === code).length / list.length;
310
+ for (const code of [...new Set(cw.map((l) => l.code))].filter((x) => x !== "completed").sort()) {
311
+ const hits = cw.filter((l) => l.code === code);
312
+ const now = share(cw, code), before = share(cp, code);
313
+ if (hits.length >= 2 && now >= c.outcomeShareFactor * before) {
314
+ const ex = hits[hits.length - 1];
315
+ add(`outcome ${code}`, hits.length, cw.length, c.outcomeShareFactor, `#${ex.issue} ${ex.reason ?? code}`, { prevRate: before });
316
+ }
317
+ }
318
+ }
319
+ return out;
320
+ }
@@ -0,0 +1,335 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * forge#4402: batch efficiency and accuracy metrics, per batch and over trailing windows. No model, no clock (`now` is
5
+ * passed in), no network except the injected `gh` for merged issues' bodies, labels and FORGE:PROBE comments.
6
+ *
7
+ * Sources: the batch dirs (`~/.forge/batches/<id>/`: plan.json, report.json rows, log.jsonl settle times) and the run
8
+ * logs (`~/.forge/runs/<issue>.jsonl`). Batch membership exists only locally, and GitHub `phase_attempt` records keep one
9
+ * run per issue, so lane reruns would be lost there.
10
+ *
11
+ * Cohort: a batch's rows. A merged issue's metrics cover every RUN_START..RUN_TERMINAL slice of its run log that started
12
+ * at or before its settle time (the latest log.jsonl row for it), including earlier batches and ad-hoc runs: the cost to
13
+ * merge. Attempt cost is `cost_usd_delta`, else `cost_usd` (#4407; logs before the delta overstate resumed sessions).
14
+ */
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { citesFileLine } from "../lean/fixround.mjs";
18
+ import { laneSize, SIZES } from "../lean/budget.mjs";
19
+ import { probeResultOf } from "../lean/probe.mjs";
20
+ import { listBatches } from "../lean/batch.mjs";
21
+ import { isTrustedComment } from "../engine/phases.mjs";
22
+ import { noComments } from "../lean/render.mjs";
23
+ import { readLog, runLogPath } from "../engine/runlog.mjs";
24
+
25
+ export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
26
+ export const RETRY_CAUSES = Object.freeze(["env", "engine", "code", "review", "unknown"]);
27
+
28
+ /** Builder (implement) attempt trigger -> retry cause; null = not a retry. Every fixround.mjs TRIGGERS entry is listed. */
29
+ export const TRIGGER_CAUSE = Object.freeze({
30
+ plan: null, build: null, verifier: null,
31
+ ci_unavailable: "env",
32
+ timeout_restart: "engine", context_restart: "engine", budget_wrapup: "engine", recovery: "engine",
33
+ precheck: "code", structure: "code", docs: "code", enumerating: "code", sequence: "code", post_merge: "code", duplicate: "code", ci_red: "code", conformance: "code",
34
+ merge_ci: "code", block_correction: "code", contract_correction: "code",
35
+ review: "review",
36
+ });
37
+
38
+ const CODE_CAUSE = [[/^env\./, "env"], [/^(engine|run|followup|base|dependency)\./, "engine"],
39
+ [/^(ci|conformance|precheck|budget|builder|contract)\./, "code"], [/^review\./, "review"]];
40
+ /** A lane rerun's cause from the RUN_TERMINAL code of the slice it replaced. */
41
+ export function codeCause(code) {
42
+ const c = String(code ?? "");
43
+ for (const [re, cause] of CODE_CAUSE) if (re.test(c)) return cause;
44
+ return "unknown";
45
+ }
46
+
47
+ const num = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
48
+ const costOf = (e) => num(e.cost_usd_delta) ?? num(e.cost_usd);
49
+ const sum = (xs) => xs.reduce((a, b) => a + (num(b) ?? 0), 0);
50
+ const r = (x, p = 1e4) => (num(x) == null ? null : Math.round(x * p) / p);
51
+ const per = (a, b) => (b > 0 ? a / b : null);
52
+
53
+ function parseLog(text) {
54
+ const out = [];
55
+ for (const line of String(text ?? "").split("\n")) {
56
+ if (!line.trim()) continue;
57
+ try { const e = JSON.parse(line); if (e && typeof e === "object") out.push(e); } catch { /* torn line */ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ export const LANE_SHAPES = Object.freeze(["light", "upgraded", "full"]);
63
+
64
+ /** Every RUN_START..RUN_TERMINAL slice of one run log (all of them, not only the last). `shape` (#4452): light (LIGHT_DONE), upgraded (LIGHT_UPGRADE) or full. */
65
+ export function issueSlices(events) {
66
+ const out = [];
67
+ let cur = null;
68
+ for (const e of Array.isArray(events) ? events : []) {
69
+ if (!e || typeof e !== "object") continue;
70
+ if (e.event === "RUN_START") { cur = { startTs: e.ts ?? null, repo: e.repo ?? null, terminal: null, shape: "full", attempts: [] }; out.push(cur); continue; }
71
+ if (!cur) continue;
72
+ if (e.event === "LIGHT_DONE") cur.shape = "light";
73
+ else if (e.event === "LIGHT_UPGRADE") cur.shape = "upgraded";
74
+ if (e.event === "PHASE_ATTEMPT_USAGE") {
75
+ cur.attempts.push({ phase: e.phase ?? "other", trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, model: e.model ?? null,
76
+ turns: num(e.turns), cacheRead: num(e.usage?.cache_read_input_tokens), cost: costOf(e),
77
+ ...(e.tools && typeof e.tools === "object" ? { calls: num(e.tools.calls), toolTurns: num(e.tools.turns), rereads: num(e.tools.rereads) } : {}) }); // #4410
78
+ } else if (e.event === "RUN_TERMINAL") {
79
+ cur.terminal = { reason: e.reason ?? null, code: e.code ?? null, ts: e.ts ?? null, size: SIZES.includes(e.turns?.size) ? e.turns.size : null };
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Retries of one slice's builder attempts by cause. Legacy (no trigger anywhere): implement attempts beyond plan + build are unknown. */
86
+ export function sliceRetries(slice) {
87
+ const out = Object.fromEntries(RETRY_CAUSES.map((c) => [c, 0]));
88
+ const impl = (slice?.attempts ?? []).filter((a) => a.phase === "implement");
89
+ if (impl.length && impl.every((a) => !a.trigger)) { out.unknown += Math.max(0, impl.length - 2); return out; }
90
+ for (const a of impl) {
91
+ if (!a.trigger) { out.unknown++; continue; }
92
+ const c = Object.hasOwn(TRIGGER_CAUSE, a.trigger) ? TRIGGER_CAUSE[a.trigger] : "unknown";
93
+ if (c) out[c]++;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ const HEAD_RE = /^[ \t]{0,3}(?:#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$|\*\*([^*\n]+)\*\*)/gm;
99
+ /** Issue-quality tag from the body: root cause, file:line and fix design present (full), some (partial) or none. */
100
+ export function issueQuality(body) {
101
+ const t = noComments(body);
102
+ const heads = [...t.matchAll(HEAD_RE)].map((m) => m[1] ?? m[2] ?? "");
103
+ const rootCause = heads.some((h) => /root cause/i.test(h));
104
+ const fileLine = citesFileLine([t]);
105
+ const fixDesign = heads.some((h) => /fix|design|approach|proposal/i.test(h));
106
+ const k = [rootCause, fileLine, fixDesign].filter(Boolean).length;
107
+ return { rootCause, fileLine, fixDesign, tag: k === 3 ? "full" : k ? "partial" : "none" };
108
+ }
109
+
110
+ /** pass | fail | pending | unprobed: trusted FORGE:PROBE records within 7 days of the first one (promotion, #4399). */
111
+ export function acceptanceOf(comments, now) {
112
+ const recs = (Array.isArray(comments) ? comments : []).filter((c) => isTrustedComment(c))
113
+ .map((c) => ({ t: Date.parse(c.created_at ?? ""), res: probeResultOf(c.body) })).filter((x) => x.res && Number.isFinite(x.t)).sort((a, b) => a.t - b.t);
114
+ if (!recs.length) return "unprobed";
115
+ const t0 = recs[0].t;
116
+ if (now - t0 < WEEK_MS) return "pending";
117
+ return recs.filter((x) => x.t <= t0 + WEEK_MS).every((x) => x.res.result === "pass") ? "pass" : "fail";
118
+ }
119
+
120
+ /** Issue body, labels and comments over the injected gh (the calls of bin/lean/probe.mjs readIssue). */
121
+ export async function fetchIssue(gh, repo, n) {
122
+ const iss = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
123
+ const raw = String(await gh(["api", `repos/${repo}/issues/${n}/comments`, "--paginate", "--jq", ".[]"]));
124
+ const comments = raw.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
125
+ return { body: iss?.body ?? "", labels: (Array.isArray(iss?.labels) ? iss.labels : []).map((l) => l?.name ?? l), comments };
126
+ }
127
+
128
+ /** #4410: a lane's implement tool calls per turn and re-reads, over the attempts that carry tool data; null when none. */
129
+ function laneTools(impl) {
130
+ const xs = impl.filter((a) => a.calls != null && a.toolTurns > 0);
131
+ if (!xs.length) return null;
132
+ return { per_turn: r(sum(xs.map((a) => a.calls)) / sum(xs.map((a) => a.toolTurns)), 100), rereads: sum(xs.map((a) => a.rereads ?? 0)) };
133
+ }
134
+ const median = (xs) => {
135
+ if (!xs.length) return null;
136
+ const s = [...xs].sort((a, b) => a - b), h = Math.floor(s.length / 2);
137
+ return s.length % 2 ? s[h] : r((s[h - 1] + s[h]) / 2, 100);
138
+ };
139
+
140
+ const group = (xs) => {
141
+ const turns = sum(xs.map((x) => x.implTurns)), cache = sum(xs.map((x) => x.implCache));
142
+ return { n: xs.length, cost_usd: r(sum(xs.map((x) => x.cost)), 1e6), usd_per_merge: r(per(sum(xs.map((x) => x.cost)), xs.length)),
143
+ turns_per_merge: r(per(turns, xs.length), 10), context_per_turn: r(per(cache, turns), 1) };
144
+ };
145
+
146
+ /**
147
+ * The metrics of one cohort. `entries`: [{row, settleAt}] (a batch row and its settle time, ISO or null = unbounded);
148
+ * `logs`: issue -> run-log events; `issues`: issue -> {body, labels, comments} or null when gh was not available.
149
+ */
150
+ export function efficiencyMetrics({ entries = [], logs = new Map(), issues = null, repo = null, now = 0, notes = [] } = {}) {
151
+ const merged = [];
152
+ const inScope = (row, settleAt) => issueSlices(logs.get(row.issue) ?? []).filter((s) => (!repo || !s.repo || s.repo === repo) && (!settleAt || !s.startTs || s.startTs <= settleAt));
153
+ // #4452: light/full split and upgrade rate over every lane of every row (all outcomes); cost is the #4407 delta sum
154
+ const shapes = Object.fromEntries(LANE_SHAPES.map((k) => [k, { n: 0, cost_usd: 0 }]));
155
+ for (const { row, settleAt } of entries) {
156
+ if (!Number.isSafeInteger(row?.issue)) continue;
157
+ for (const s of inScope(row, settleAt)) { const g = shapes[s.shape] ?? shapes.full; g.n++; g.cost_usd += sum(s.attempts.map((a) => a.cost)); }
158
+ }
159
+ for (const g of Object.values(shapes)) g.cost_usd = r(g.cost_usd, 1e6);
160
+ const lanes = { ...shapes, upgrade_rate: r(per(shapes.upgraded.n, shapes.light.n + shapes.upgraded.n)) };
161
+ for (const { row, settleAt } of entries) {
162
+ if (row?.outcome !== "merged") continue;
163
+ const slices = inScope(row, settleAt);
164
+ const atts = slices.flatMap((s) => s.attempts), impl = atts.filter((a) => a.phase === "implement");
165
+ const retries = Object.fromEntries(RETRY_CAUSES.map((c) => [c, 0]));
166
+ for (const s of slices) for (const [c, k] of Object.entries(sliceRetries(s))) retries[c] += k;
167
+ for (const s of slices.slice(0, -1)) retries[codeCause(s.terminal?.code)]++; // lane reruns
168
+ const info = issues?.get(row.issue) ?? null;
169
+ const term = [...slices].reverse().find((s) => s.terminal?.reason === "merged")?.terminal;
170
+ merged.push({ issue: row.issue, slices: slices.length, cost: sum(atts.map((a) => a.cost)), implCost: sum(impl.map((a) => a.cost)),
171
+ reviewCost: sum(atts.filter((a) => a.phase === "review").map((a) => a.cost)), implTurns: sum(impl.map((a) => a.turns)), implCache: sum(impl.map((a) => a.cacheRead)),
172
+ size: term?.size ?? laneSize({ labels: info?.labels ?? [] }) ?? "unknown", impl, retries, info, tools: laneTools(impl), shape: slices.at(-1)?.shape ?? null });
173
+ }
174
+ const all = group(merged);
175
+ const bySize = {};
176
+ for (const s of [...SIZES, "unknown"]) { const xs = merged.filter((m) => m.size === s); if (xs.length) bySize[s] = group(xs); }
177
+ const byModel = {};
178
+ for (const m of merged) {
179
+ for (const a of m.impl) {
180
+ const k = a.model ?? "unknown", g = (byModel[k] ??= { merges: new Set(), attempts: 0, turns: 0, cache: 0, cost: 0 });
181
+ g.merges.add(m.issue); g.attempts++; g.turns += a.turns ?? 0; g.cache += a.cacheRead ?? 0; g.cost += a.cost ?? 0;
182
+ }
183
+ }
184
+ const retries = Object.fromEntries(RETRY_CAUSES.map((c) => [c, sum(merged.map((m) => m.retries[c]))]));
185
+ const withTools = merged.filter((m) => m.tools);
186
+ const total = sum(Object.values(retries));
187
+ // no possible action: parked post-deploy follow-ups and promotion-blocked lanes (#4389), over all rows
188
+ const noAction = {};
189
+ for (const { row } of entries) {
190
+ const kind = row?.prefilter?.parked ? String(row.prefilter.kind ?? "parked")
191
+ : row?.code === "builder.blocked" && /^Builder is blocked \(promotion\)/.test(String(row.detail ?? "")) ? "blocked-promotion" : null;
192
+ if (!kind) continue;
193
+ const g = (noAction[kind] ??= { n: 0, cost_usd: 0, issues: [] });
194
+ g.n++; g.cost_usd += num(row.cost_usd) ?? 0; g.issues.push(row.issue);
195
+ }
196
+ let quality = null, acceptance = null;
197
+ if (issues) {
198
+ quality = {};
199
+ for (const t of ["full", "partial", "none"]) {
200
+ const xs = merged.filter((m) => m.info && issueQuality(m.info.body).tag === t);
201
+ quality[t] = group(xs);
202
+ }
203
+ acceptance = { pass: 0, fail: 0, pending: 0, unprobed: 0 };
204
+ for (const m of merged) if (m.info) acceptance[acceptanceOf(m.info.comments, now)]++;
205
+ acceptance.probed = acceptance.pass + acceptance.fail + acceptance.pending;
206
+ acceptance.pass_rate = r(per(acceptance.pass, acceptance.pass + acceptance.fail));
207
+ }
208
+ return {
209
+ merged: merged.length, cost_usd: all.cost_usd, implement_usd: r(sum(merged.map((m) => m.implCost)), 1e6), review_usd: r(sum(merged.map((m) => m.reviewCost)), 1e6),
210
+ usd_per_merge: all.usd_per_merge, turns_per_merge: all.turns_per_merge, context_per_turn: all.context_per_turn,
211
+ implement_turns: sum(merged.map((m) => m.implTurns)), implement_cache_read: sum(merged.map((m) => m.implCache)),
212
+ by_size: bySize,
213
+ by_model: Object.fromEntries(Object.entries(byModel).map(([k, g]) => [k, { merges: g.merges.size, attempts: g.attempts, turns: g.turns, cost_usd: r(g.cost, 1e6),
214
+ usd_per_merge: r(per(g.cost, g.merges.size)), turns_per_merge: r(per(g.turns, g.merges.size), 10), context_per_turn: r(per(g.cache, g.turns), 1) }])),
215
+ retries: { total, per_merge: r(per(total, merged.length), 100), by_cause: retries, lane_reruns: sum(merged.map((m) => Math.max(0, m.slices - 1))) },
216
+ no_action: { lanes: sum(Object.values(noAction).map((g) => g.n)), cost_usd: r(sum(Object.values(noAction).map((g) => g.cost_usd)), 1e6),
217
+ by_kind: Object.fromEntries(Object.entries(noAction).map(([k, g]) => [k, { ...g, cost_usd: r(g.cost_usd, 1e6) }])) },
218
+ quality, acceptance, lanes,
219
+ tool_calls_per_turn_p50: median(withTools.map((m) => m.tools.per_turn)), rereads_per_merge: withTools.length ? r(per(sum(withTools.map((m) => m.tools.rereads)), withTools.length), 100) : null,
220
+ per_issue: merged.map((m) => ({ issue: m.issue, slices: m.slices, cost_usd: r(m.cost, 1e6), implement_usd: r(m.implCost, 1e6), implement_turns: m.implTurns,
221
+ implement_cache_read: m.implCache, size: m.size, retries: m.retries, ...(m.shape ? { shape: m.shape } : {}), ...(m.info ? { quality: issueQuality(m.info.body).tag } : {}),
222
+ ...(m.tools ? { implement_tool_calls_per_turn: m.tools.per_turn, implement_rereads: m.tools.rereads } : {}) })),
223
+ notes,
224
+ };
225
+ }
226
+
227
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
228
+ /** #4432: the repo's run log (legacy `<n>.jsonl` only through readLog's fallback); null repo = the legacy file. */
229
+ const readEvents = (runsDir, n, repo = null) => {
230
+ try { return readLog(runsDir, n, { repo }); } catch { /* a corrupt line: parse the file leniently */ }
231
+ try { return parseLog(readFileSync(runLogPath(runsDir, n, repo), "utf8")); } catch { return []; }
232
+ };
233
+ const readLogRows = (dir) => { try { return parseLog(readFileSync(join(dir, "log.jsonl"), "utf8")); } catch { return []; } };
234
+
235
+ /** report.json rows, else state.json results (object or entry list). */
236
+ function batchRows(dir) {
237
+ const rep = readJson(join(dir, "report.json"));
238
+ if (Array.isArray(rep?.rows)) return rep.rows;
239
+ const res = readJson(join(dir, "state.json"))?.results;
240
+ const vals = Array.isArray(res) ? res.map((x) => (Array.isArray(x) ? x[1] : x)) : res && typeof res === "object" ? Object.values(res) : [];
241
+ return vals.filter((x) => x && Number.isSafeInteger(x.issue));
242
+ }
243
+
244
+ /** Latest log.jsonl `at` per issue. */
245
+ function settleTimes(logRows) {
246
+ const at = new Map();
247
+ for (const l of logRows) if (Number.isSafeInteger(l?.issue) && typeof l.at === "string" && l.at > (at.get(l.issue) ?? "")) at.set(l.issue, l.at);
248
+ return at;
249
+ }
250
+
251
+ async function computeScope({ entries, runsDir, repo, gh, now }) {
252
+ const logs = new Map(entries.map(({ row }) => [row.issue, readEvents(runsDir, row.issue, repo)]));
253
+ const notes = [];
254
+ let issues = null;
255
+ const mergedIssues = [...new Set(entries.filter(({ row }) => row.outcome === "merged").map(({ row }) => row.issue))];
256
+ if (!gh || !repo) notes.push("issue quality and acceptance: no gh/repo");
257
+ else {
258
+ issues = new Map();
259
+ for (const n of mergedIssues) {
260
+ try { issues.set(n, await fetchIssue(gh, repo, n)); } catch (e) {
261
+ issues = null; notes.push(`issue quality and acceptance: gh failed (${String(e?.message ?? e).split("\n")[0].slice(0, 160)})`); break;
262
+ }
263
+ }
264
+ }
265
+ return efficiencyMetrics({ entries, logs, issues, repo, now, notes });
266
+ }
267
+
268
+ /** One batch. `rows`/`logRows` override the files (the batch report passes its in-memory rows). */
269
+ export async function batchEfficiency({ batchDir, runsDir, repo = null, gh = null, now = Date.now(), rows = null, logRows = null }) {
270
+ const plan = readJson(join(batchDir, "plan.json")) ?? {};
271
+ const rs = Array.isArray(rows) ? rows : batchRows(batchDir);
272
+ const at = settleTimes(Array.isArray(logRows) ? logRows : readLogRows(batchDir));
273
+ const entries = rs.filter((x) => Number.isSafeInteger(x?.issue)).map((row) => ({ row, settleAt: at.get(row.issue) ?? null }));
274
+ return { batch: plan.id ?? null, ...(await computeScope({ entries, runsDir, repo: repo ?? plan.repo ?? null, gh, now })) };
275
+ }
276
+
277
+ /** Trailing windows: the union of the repo's batches created in each window; an issue in several batches counts once (latest row). */
278
+ export async function windowEfficiency({ batchRoot, runsDir, repo = null, gh = null, now = Date.now(), windows = [WEEK_MS] }) {
279
+ const batches = (existsSync(batchRoot) ? listBatches(batchRoot) : []).map((id) => {
280
+ const dir = join(batchRoot, id), plan = readJson(join(dir, "plan.json")) ?? {};
281
+ return { dir, plan, created: Date.parse(plan.created ?? "") };
282
+ }).filter((b) => Number.isFinite(b.created) && (!repo || b.plan.repo === repo)).sort((a, b) => a.created - b.created);
283
+ const out = {};
284
+ for (const w of windows) {
285
+ const latest = new Map();
286
+ for (const b of batches) {
287
+ if (b.created > now || now - b.created > w) continue;
288
+ const at = settleTimes(readLogRows(b.dir));
289
+ for (const row of batchRows(b.dir)) if (Number.isSafeInteger(row?.issue)) latest.set(row.issue, { row, settleAt: at.get(row.issue) ?? null });
290
+ }
291
+ out[windowLabel(w)] = { batches: batches.filter((b) => b.created <= now && now - b.created <= w).length,
292
+ ...(await computeScope({ entries: [...latest.values()], runsDir, repo, gh, now })) };
293
+ }
294
+ return out;
295
+ }
296
+
297
+ export const windowLabel = (ms) => `${Math.round(ms / 86400000)}d`;
298
+ /** "7d,30d" -> [ms]; throws on anything else. */
299
+ export function parseWindows(s) {
300
+ const out = String(s ?? "").split(",").map((x) => x.trim()).filter(Boolean).map((x) => {
301
+ const m = /^(\d+)d$/.exec(x);
302
+ if (!m || Number(m[1]) < 1) throw new Error(`--window must be a comma list of Nd (e.g. 7d,30d): ${s}`);
303
+ return Number(m[1]) * 86400000;
304
+ });
305
+ if (!out.length) throw new Error("--window needs at least one Nd");
306
+ return out;
307
+ }
308
+
309
+ const f = (x, d = 2) => (num(x) == null ? "n/a" : x.toFixed(d));
310
+ const usd = (x) => (num(x) == null ? "n/a" : `$${x.toFixed(2)}`);
311
+ const int = (x) => (num(x) == null ? "n/a" : String(Math.round(x)));
312
+ const g1 = (g) => `n=${g.n} ${usd(g.usd_per_merge)}/merge turns/merge ${f(g.turns_per_merge, 1)} context/turn ${int(g.context_per_turn)}`;
313
+
314
+ /** The report lines of one scope (the batch report prints them unlabelled). */
315
+ export function formatEfficiencyLines(m, label = "") {
316
+ const lines = [`efficiency${label ? ` (${label})` : ""}: merged ${m.merged}, ${usd(m.usd_per_merge)}/merge (implement ${usd(m.implement_usd)}, review ${usd(m.review_usd)}), turns/merge ${f(m.turns_per_merge, 1)}, context/turn ${int(m.context_per_turn)}`];
317
+ const sizes = Object.entries(m.by_size ?? {});
318
+ lines.push(`efficiency by size: ${sizes.length ? sizes.map(([s, g]) => `${s} ${g1(g)}`).join("; ") : "n/a"}`);
319
+ const models = Object.entries(m.by_model ?? {});
320
+ lines.push(`efficiency by model: ${models.length ? models.map(([k, g]) => `${k} n=${g.merges} ${usd(g.usd_per_merge)}/merge turns/merge ${f(g.turns_per_merge, 1)} context/turn ${int(g.context_per_turn)}`).join("; ") : "n/a"}`);
321
+ const rc = m.retries?.by_cause ?? {};
322
+ lines.push(`retries/merge ${f(m.retries?.per_merge)} (${RETRY_CAUSES.map((c) => `${c} ${rc[c] ?? 0}`).join(", ")}; lane reruns ${m.retries?.lane_reruns ?? 0})`);
323
+ const kinds = Object.entries(m.no_action?.by_kind ?? {});
324
+ lines.push(`no action: ${m.no_action?.lanes ?? 0} lanes, ${usd(m.no_action?.cost_usd ?? 0)}${kinds.length ? ` (${kinds.map(([k, g]) => `${k} ${g.n}`).join(", ")})` : ""}`);
325
+ const note = (m.notes ?? []).find((x) => /^issue quality/.test(x));
326
+ lines.push(m.quality ? `issue quality: ${["full", "partial", "none"].map((t) => `${t} ${g1(m.quality[t])}`).join("; ")}` : `issue quality: n/a${note ? ` (${note.replace(/^issue quality and acceptance: /, "")})` : ""}`);
327
+ const a = m.acceptance;
328
+ lines.push(a ? `acceptance (7d after promotion): pass ${a.pass}/${a.pass + a.fail}${a.pass_rate == null ? "" : ` (${Math.round(a.pass_rate * 100)}%)`}, pending ${a.pending}, unprobed ${a.unprobed}`
329
+ : "acceptance (7d after promotion): n/a");
330
+ lines.push(`tools: calls/turn p50 ${f(m.tool_calls_per_turn_p50)}, re-reads (>=3x same path)/merge ${f(m.rereads_per_merge)}`); // #4410
331
+ const ln = m.lanes; // #4452
332
+ lines.push(ln ? `lanes: ${LANE_SHAPES.map((k) => `${k} ${ln[k]?.n ?? 0} (${usd(ln[k]?.cost_usd ?? 0)})`).join(", ")}; upgrade rate ${ln.upgrade_rate == null ? "n/a"
333
+ : `${Math.round(ln.upgrade_rate * 100)}% (${ln.upgraded?.n ?? 0}/${(ln.light?.n ?? 0) + (ln.upgraded?.n ?? 0)})`}` : "lanes: n/a");
334
+ return lines;
335
+ }