forgedock 1.3.0 → 1.5.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 (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
@@ -0,0 +1,149 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { classifyInputPattern, shouldReResolve, foldNewMatches } from "./resolve.mjs";
4
+
5
+ describe("classifyInputPattern — literal sets", () => {
6
+ it("classifies bare issue numbers as literal", () => {
7
+ assert.deepEqual(classifyInputPattern("1 2 3"), {
8
+ kind: "literal",
9
+ pattern: "literal-numbers",
10
+ args: ["1", "2", "3"],
11
+ });
12
+ });
13
+
14
+ it("classifies #-prefixed issue numbers as literal", () => {
15
+ const r = classifyInputPattern("#1 #2 #3");
16
+ assert.equal(r.kind, "literal");
17
+ assert.equal(r.pattern, "literal-numbers");
18
+ });
19
+
20
+ it("classifies repo-prefixed literal numbers as literal", () => {
21
+ const r = classifyInputPattern("#123 mcp:5 n8n:12");
22
+ assert.equal(r.kind, "literal");
23
+ assert.equal(r.pattern, "literal-numbers");
24
+ });
25
+
26
+ it("classifies a single bare number as literal", () => {
27
+ const r = classifyInputPattern("42");
28
+ assert.equal(r.kind, "literal");
29
+ });
30
+ });
31
+
32
+ describe("classifyInputPattern — query patterns", () => {
33
+ it("classifies milestone <slug> as a query", () => {
34
+ const r = classifyInputPattern("milestone modular-pipeline-architecture");
35
+ assert.equal(r.kind, "query");
36
+ assert.equal(r.pattern, "milestone");
37
+ assert.deepEqual(r.args, ["modular-pipeline-architecture"]);
38
+ });
39
+
40
+ it("classifies next <N> as a query", () => {
41
+ const r = classifyInputPattern("next 5");
42
+ assert.equal(r.kind, "query");
43
+ assert.equal(r.pattern, "next-n");
44
+ });
45
+
46
+ it("classifies next <N> all-repos as a query", () => {
47
+ const r = classifyInputPattern("next 5 all-repos");
48
+ assert.equal(r.kind, "query");
49
+ assert.equal(r.pattern, "next-n-all-repos");
50
+ });
51
+
52
+ it("classifies fast-lane / fast as a query", () => {
53
+ assert.equal(classifyInputPattern("fast-lane").pattern, "fast-lane");
54
+ assert.equal(classifyInputPattern("fast").pattern, "fast-lane");
55
+ assert.equal(classifyInputPattern("fast-lane").kind, "query");
56
+ });
57
+
58
+ it("classifies priority:P0 / priority:P1 as a query (both label schemas, forge#2232)", () => {
59
+ assert.equal(classifyInputPattern("priority:P0").pattern, "priority");
60
+ assert.equal(classifyInputPattern("priority:P1").kind, "query");
61
+ assert.equal(classifyInputPattern("mcp:priority:P2").pattern, "priority");
62
+ });
63
+
64
+ it("classifies repo-scoped queries as a query", () => {
65
+ assert.equal(classifyInputPattern("mcp:fast").pattern, "repo-scoped");
66
+ assert.equal(classifyInputPattern("n8n:next 3").pattern, "repo-scoped");
67
+ });
68
+
69
+ it("classifies cascade/review-findings/findings as a query", () => {
70
+ assert.equal(classifyInputPattern("cascade").pattern, "cascade");
71
+ assert.equal(classifyInputPattern("review-findings").pattern, "cascade");
72
+ assert.equal(classifyInputPattern("findings --include-deferred").pattern, "cascade");
73
+ });
74
+
75
+ it("classifies a bare slug as a query", () => {
76
+ const r = classifyInputPattern("some-milestone-slug");
77
+ assert.equal(r.kind, "query");
78
+ assert.equal(r.pattern, "bare-slug");
79
+ });
80
+
81
+ it("classifies empty/unrecognized input as an unknown query, never literal", () => {
82
+ assert.equal(classifyInputPattern("").kind, "query");
83
+ assert.equal(classifyInputPattern("").pattern, "unknown");
84
+ });
85
+ });
86
+
87
+ describe("shouldReResolve", () => {
88
+ it("never re-resolves a literal set, regardless of config", () => {
89
+ const classified = { kind: "literal", pattern: "literal-numbers", args: ["1"] };
90
+ const r = shouldReResolve(classified, { enabled: true, maxRounds: 100 }, 0);
91
+ assert.equal(r.reResolve, false);
92
+ assert.match(r.reason, /never re-resolve/);
93
+ });
94
+
95
+ it("re-resolves a query pattern by default (no config)", () => {
96
+ const classified = { kind: "query", pattern: "priority", args: [] };
97
+ const r = shouldReResolve(classified);
98
+ assert.equal(r.reResolve, true);
99
+ });
100
+
101
+ it("respects an explicit off switch (boolean false)", () => {
102
+ const classified = { kind: "query", pattern: "fast-lane", args: [] };
103
+ const r = shouldReResolve(classified, { enabled: false });
104
+ assert.equal(r.reResolve, false);
105
+ assert.match(r.reason, /off/);
106
+ });
107
+
108
+ it("respects an explicit off switch (string 'off', case-insensitive)", () => {
109
+ const classified = { kind: "query", pattern: "fast-lane", args: [] };
110
+ assert.equal(shouldReResolve(classified, { enabled: "off" }).reResolve, false);
111
+ assert.equal(shouldReResolve(classified, { enabled: "OFF" }).reResolve, false);
112
+ });
113
+
114
+ it("bounds termination via maxRounds", () => {
115
+ const classified = { kind: "query", pattern: "milestone", args: [] };
116
+ assert.equal(shouldReResolve(classified, { maxRounds: 3 }, 2).reResolve, true);
117
+ const atLimit = shouldReResolve(classified, { maxRounds: 3 }, 3);
118
+ assert.equal(atLimit.reResolve, false);
119
+ assert.match(atLimit.reason, /max_rounds/);
120
+ });
121
+ });
122
+
123
+ describe("foldNewMatches", () => {
124
+ it("returns issues not already in the processed registry", () => {
125
+ const r = foldNewMatches([10, 11, 12], [10, 11]);
126
+ assert.deepEqual(r.newMatches, [12]);
127
+ assert.deepEqual(r.alreadyProcessed, [10, 11]);
128
+ });
129
+
130
+ it("is idempotent — re-running with an updated registry returns nothing new", () => {
131
+ const first = foldNewMatches([10, 11, 12], [10, 11]);
132
+ assert.deepEqual(first.newMatches, [12]);
133
+ const registryAfterFold = new Set([10, 11, ...first.newMatches]);
134
+ const second = foldNewMatches([10, 11, 12], registryAfterFold);
135
+ assert.deepEqual(second.newMatches, []);
136
+ assert.deepEqual(second.alreadyProcessed, [10, 11, 12]);
137
+ });
138
+
139
+ it("de-dupes duplicate numbers within the same re-resolution result", () => {
140
+ const r = foldNewMatches([5, 5, 6], []);
141
+ assert.deepEqual(r.newMatches, [5, 6]);
142
+ });
143
+
144
+ it("handles an empty re-resolution result", () => {
145
+ const r = foldNewMatches([], [1, 2, 3]);
146
+ assert.deepEqual(r.newMatches, []);
147
+ assert.deepEqual(r.alreadyProcessed, []);
148
+ });
149
+ });
@@ -57,7 +57,7 @@ export function deriveState(events) {
57
57
  /** @type {import("./phases.mjs").RunState} */
58
58
  const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
59
59
  phase: null, branch: null, pr: null, terminal: false,
60
- terminalReason: null, lease: null };
60
+ terminalReason: null, lease: null, lastRateLimit: null };
61
61
  for (const e of events) {
62
62
  switch (e.event) {
63
63
  case "RUN_START":
@@ -72,6 +72,16 @@ export function deriveState(events) {
72
72
  case "RUN_TERMINAL":
73
73
  s.terminal = true; s.terminalReason = e.reason ?? "done"; s.v = e.seq;
74
74
  break;
75
+ // forge#2524: a session-limit pause is purely informational — it does
76
+ // NOT touch committed/terminal/terminalReason. The phase that hit the
77
+ // pause is, by construction, not yet committed (the engine is about to
78
+ // retry it from scratch once the wait elapses), so a resumed/hydrated
79
+ // run correctly re-enters pickPhase at the same phase with no special
80
+ // casing needed here beyond recording the diagnostic below.
81
+ case "PHASE_RATE_LIMITED":
82
+ s.lastRateLimit = { phase: e.phase, resetAt: e.resetAt ?? null, waitMs: e.waitMs ?? null };
83
+ s.v = e.seq;
84
+ break;
75
85
  // PHASE_START / PHASE_FAILED do not change committed state
76
86
  }
77
87
  }
@@ -2,6 +2,11 @@
2
2
  * Codec for the single machine-readable FORGE:STATE HTML-comment block on an
3
3
  * issue body. Carries the COMPACT INDEX only (no rich per-phase outputs).
4
4
  */
5
+ import {
6
+ escapeHtmlCommentDelimiters,
7
+ unescapeHtmlCommentDelimiters,
8
+ } from "../../packages/protocol/src/html-comment-escape.js";
9
+
5
10
  const OPEN = "<!-- FORGE:STATE";
6
11
  const CLOSE = "-->";
7
12
  const BLOCK_RE = /<!-- FORGE:STATE\s*([\s\S]*?)-->/;
@@ -12,25 +17,18 @@ export function serializeState(index) {
12
17
  // open or close an HTML comment in GitHub's renderer:
13
18
  // - "<!--" the comment opener — an unescaped opener would start a new
14
19
  // (nested-looking) comment, visually swallowing subsequent content
15
- // (mirrors packages/protocol/src/emit.js's sanitizeFieldValue, forge#1638/#2119)
16
20
  // - "-->" and "--!>" the two comment-close forms browsers/HTML parsers
17
21
  // honour. JSON.stringify does not escape any of these, so a payload
18
22
  // value containing them could otherwise open/close the
19
23
  // <!-- FORGE:STATE ... --> block early (CodeQL js/bad-tag-filter).
20
24
  //
21
- // The "&" escape below MUST run first, before the delimiter escapes. Without
22
- // it, a payload value that already contains literal entity-like text (e.g.
23
- // "<!--&gt;" or "&lt;!--") collides with the encoded form of an unrelated
24
- // real delimiter — the encoder is not injective and the collision is
25
- // unrecoverable on decode (forge#2137). Escaping "&" first guarantees every
26
- // "&lt;!--"/"--&gt;"/"--!&gt;" sequence present in the output was produced
27
- // by *this* encode pass, never by pre-existing entity text (which is now
28
- // doubly-escaped, e.g. "&" -> "&amp;" -> untouched by the delimiter pass
29
- // that only matches literal "<!--"/"--"+">" character sequences).
30
- const payload = JSON.stringify(index)
31
- .replace(/&/g, "&amp;")
32
- .replace(/<!--/g, "&lt;!--")
33
- .replace(/--(!?)>/g, "--$1&gt;");
25
+ // Delegated to the shared escapeHtmlCommentDelimiters() helper (forge#2225),
26
+ // which is also used by packages/protocol/src/emit.js's sanitizeFieldValue —
27
+ // this scheme previously had two hand-duplicated copies kept in sync only by
28
+ // cross-referencing code comments, and had already drifted once (forge#1638's
29
+ // fix landed here only after forge#2119). A single shared implementation
30
+ // removes that drift vector entirely.
31
+ const payload = escapeHtmlCommentDelimiters(JSON.stringify(index));
34
32
  return `${OPEN}\n${payload}\n${CLOSE}`;
35
33
  }
36
34
 
@@ -38,21 +36,15 @@ export function serializeState(index) {
38
36
  export function parseState(issueBody) {
39
37
  const m = BLOCK_RE.exec(issueBody || "");
40
38
  if (!m) return null;
41
- // Inverse of serializeState: restore "--&gt;", "--!&gt;", "&lt;!--", then
42
- // "&amp;" — in that order. Unescape the closer forms first (most-specific
43
- // "--!&gt;" before "--&gt;") so a dangling "!" is never left behind, then
44
- // restore the opener — same ordering rationale as
45
- // packages/protocol/src/parse.js's unescapeFieldValue. The "&amp;" -> "&"
46
- // unescape MUST run LAST (mirroring the encode side running it FIRST) so it
47
- // never touches the "&" that is part of a just-restored "<!--"/"-->"/"--!>"
48
- // sequence (forge#2137).
39
+ // Inverse of serializeState — delegated to the shared
40
+ // unescapeHtmlCommentDelimiters() helper (forge#2225), same ordering
41
+ // rationale as packages/protocol/src/parse.js's unescapeFieldValue: closer
42
+ // forms restored first (most-specific "--!&gt;" before "--&gt;"), opener
43
+ // restored next, and "&amp;" -> "&" unescaped LAST so it never touches the
44
+ // "&" that is part of a just-restored "<!--"/"-->"/"--!>" sequence
45
+ // (forge#2137).
49
46
  try {
50
- return JSON.parse(
51
- m[1].trim()
52
- .replace(/--(!?)&gt;/g, "--$1>")
53
- .replace(/&lt;!--/g, "<!--")
54
- .replace(/&amp;/g, "&"),
55
- );
47
+ return JSON.parse(unescapeHtmlCommentDelimiters(m[1].trim()));
56
48
  } catch { return null; }
57
49
  }
58
50
 
@@ -55,15 +55,60 @@ export function makeIo() {
55
55
 
56
56
  export function runDir() { return join(homedir(), ".forge", "runs"); }
57
57
 
58
+ /**
59
+ * Sums the four token-usage fields across every `PHASE_COMMIT`/`PHASE_FAILED`
60
+ * run-log event that carries a non-null `usage` object. Mirrors the writer
61
+ * shape and `?? 0` accumulation convention used by `bin/engine.mjs:411,500`
62
+ * (`usage: outcome.usage ?? null`) and `bin/runner.mjs:1699-1723` (the
63
+ * canonical per-run accumulator) — same field names, same null-safe adds.
64
+ *
65
+ * Only `PHASE_COMMIT`/`PHASE_FAILED` events carry `usage`; other event types
66
+ * are skipped without inspecting their shape.
67
+ *
68
+ * @param {Array<object>} events - run-log events (from readLog())
69
+ * @returns {{input_tokens: number, output_tokens: number, cache_creation_input_tokens: number, cache_read_input_tokens: number} | null}
70
+ * `null` when no event carried usage data (e.g. an all-CLI-backend run) —
71
+ * callers MUST treat `null` as "omit the line", never substitute zeros
72
+ * (a `0 tokens` line would misleadingly imply usage was measured and was
73
+ * zero, rather than "not measured at all").
74
+ */
75
+ export function aggregateUsage(events) {
76
+ let found = false;
77
+ const total = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
78
+ for (const e of events) {
79
+ if (e.event !== "PHASE_COMMIT" && e.event !== "PHASE_FAILED") continue;
80
+ if (!e.usage || typeof e.usage !== "object") continue;
81
+ found = true;
82
+ total.input_tokens += e.usage.input_tokens ?? 0;
83
+ total.output_tokens += e.usage.output_tokens ?? 0;
84
+ total.cache_creation_input_tokens += e.usage.cache_creation_input_tokens ?? 0;
85
+ total.cache_read_input_tokens += e.usage.cache_read_input_tokens ?? 0;
86
+ }
87
+ return found ? total : null;
88
+ }
89
+
90
+ /**
91
+ * Renders a single usage-summary line from an `aggregateUsage()` result, or
92
+ * `""` when `usage` is `null` — callers must skip printing entirely in that
93
+ * case rather than print a placeholder/zero line.
94
+ * @param {{input_tokens:number, output_tokens:number, cache_creation_input_tokens:number, cache_read_input_tokens:number} | null} usage
95
+ * @returns {string}
96
+ */
97
+ export function formatUsageLine(usage) {
98
+ if (!usage) return "";
99
+ return ` usage: ${usage.input_tokens} in / ${usage.output_tokens} out (${usage.cache_read_input_tokens} cache-read / ${usage.cache_creation_input_tokens} cache-write)`;
100
+ }
101
+
58
102
  /**
59
103
  * Renders the diagnostic block printed below the bare terminal line whenever
60
104
  * a run does not terminate `merged` (forge#2175). Reconstructs the failing
61
105
  * phase, attempt count, and `PHASE_FAILED.reason` from the durable run-log
62
106
  * (the same data `bin/engine.mjs`'s `runPhaseWithRetry()` already appends via
63
- * `appendEvent()`), plus the final committed/branch/pr state and the run-log
64
- * path — closing the gap where an operator previously had to manually open
65
- * `~/.forge/runs/{issue}.jsonl` and read engine source to interpret a bare
66
- * `issue #N -> needs-human` line.
107
+ * `appendEvent()`), plus the final committed/branch/pr state, the aggregate
108
+ * per-run token usage (forge#2399 — omitted when no event carries usage
109
+ * data), and the run-log path — closing the gap where an operator previously
110
+ * had to manually open `~/.forge/runs/{issue}.jsonl` and read engine source
111
+ * to interpret a bare `issue #N -> needs-human` line.
67
112
  *
68
113
  * Best-effort: if the run-log is empty/unreadable (e.g. a `deferred` early
69
114
  * return before any event was appended), only the run-log path line is
@@ -95,13 +140,15 @@ export function formatTerminalDiagnostics(dir, issue) {
95
140
  const state = deriveState(events);
96
141
  const lastFailure = [...events].reverse().find((e) => e.event === "PHASE_FAILED");
97
142
  if (lastFailure) {
98
- lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${DEFAULT_MAX_ATTEMPTS} attempts)`);
143
+ lines.push(` phase: ${lastFailure.phase} (failed ${lastFailure.attempt}/${lastFailure.maxAttempts ?? DEFAULT_MAX_ATTEMPTS} attempts)`);
99
144
  lines.push(` reason: ${lastFailure.reason}`);
100
145
  }
101
146
  const committed = state.committed.length ? state.committed.join(",") : "";
102
147
  lines.push(` state: committed=[${committed}] branch=${state.branch ?? "null"} pr=${state.pr ?? "null"}`);
103
148
  }
104
149
  lines.push(` run-log: ${runLogPath}`);
150
+ const usageLine = formatUsageLine(aggregateUsage(events));
151
+ if (usageLine) lines.push(usageLine);
105
152
  return lines.join("\n");
106
153
  }
107
154
 
@@ -304,8 +351,30 @@ export async function runFromCli(argv, deps = {}) {
304
351
  const agentId = `cli_${process.pid}`;
305
352
  // Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
306
353
  const dir = deps.dir ?? runDir();
354
+ // forge#2240: print the run-log path at the very start of the run, not only
355
+ // in the non-merged completion diagnostics (formatTerminalDiagnostics prints
356
+ // it too, but only post-completion and only for non-"merged" outcomes). A
357
+ // caller tailing this process's stdout should be able to find the run-log
358
+ // to inspect immediately, without waiting for the run to finish.
359
+ console.log(`run-log: ${join(dir, `${issue}.jsonl`)}`);
360
+ // forge#2240: phase-boundary progress lines — the only stdout emitted
361
+ // during the run itself. `runIssue()`'s `onProgress` callback defaults to a
362
+ // no-op, so this is purely additive; engine.mjs never calls console.log
363
+ // directly (keeps it injectable/testable — see its onProgress param doc).
364
+ const onProgress = (e) => {
365
+ if (e.event === "phase_enter") console.log(`→ phase ${e.phase} started`);
366
+ else if (e.event === "phase_exit" && e.status === "committed") console.log(`✓ phase ${e.phase} committed`);
367
+ else if (e.event === "phase_exit" && e.status === "blocked") console.log(`✗ phase ${e.phase} blocked: ${e.detail ?? "no detail"}`);
368
+ // forge#2524: session-limit pause — the engine is waiting in-process
369
+ // until the reported reset time before retrying this same phase, rather
370
+ // than terminating. Distinct glyph from the exit/blocked lines above so
371
+ // an operator tailing this stdout can tell "still alive, just waiting"
372
+ // from "phase actually failed".
373
+ else if (e.event === "phase_paused") console.log(`⏸ phase ${e.phase} paused: ${e.detail ?? "session limit"}`);
374
+ };
307
375
  const res = await runIssueFn({ issue, dir, agentId, lane, io,
308
376
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
377
+ onProgress,
309
378
  // Only forwarded when explicitly provided — omitting them preserves
310
379
  // runIssue's/runner.mjs's existing defaults (forge#2028).
311
380
  ...(backend ? { backend } : {}),
@@ -319,6 +388,18 @@ export async function runFromCli(argv, deps = {}) {
319
388
  // to avoid regressing/cluttering the existing happy-path output.
320
389
  if (res.terminalReason !== "merged") {
321
390
  console.log(formatTerminalDiagnostics(dir, issue));
391
+ } else {
392
+ // forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
393
+ // block) intentionally stays gated to non-merged outcomes above, but the
394
+ // aggregate token-usage total should print for "total tokens per run" to
395
+ // hold on the common success case too — surface just the usage line here
396
+ // rather than the full diagnostic block.
397
+ try {
398
+ const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
399
+ if (usageLine) console.log(usageLine);
400
+ } catch {
401
+ // Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
402
+ }
322
403
  }
323
404
  return res;
324
405
  }