forgedock 1.3.0 → 1.4.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.
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +80 -5
- package/bin/engine.mjs +390 -18
- package/bin/forgedock.mjs +336 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +169 -13
- package/bin/journey.mjs +8 -2
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +321 -10
- package/bin/watch-utils.mjs +255 -0
- package/commands/cleanup.md +7 -3
- package/commands/orchestrate/config.md +41 -1
- package/commands/orchestrate/phase-1-resolve.md +234 -8
- package/commands/orchestrate/phase-3-dependency.md +27 -18
- package/commands/orchestrate/phase-4-execution.md +221 -22
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr.md +23 -7
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +2 -0
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +96 -0
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/forge-run.sh +35 -4
|
@@ -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
|
+
});
|
package/bin/engine/state.mjs
CHANGED
|
@@ -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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
// doubly-escaped, e.g. "&" -> "&" -> untouched by the delimiter pass
|
|
29
|
-
// that only matches literal "<!--"/"--"+">" character sequences).
|
|
30
|
-
const payload = JSON.stringify(index)
|
|
31
|
-
.replace(/&/g, "&")
|
|
32
|
-
.replace(/<!--/g, "<!--")
|
|
33
|
-
.replace(/--(!?)>/g, "--$1>");
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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 "--!>" before "-->"), opener
|
|
43
|
+
// restored next, and "&" -> "&" 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(/--(!?)>/g, "--$1>")
|
|
53
|
-
.replace(/<!--/g, "<!--")
|
|
54
|
-
.replace(/&/g, "&"),
|
|
55
|
-
);
|
|
47
|
+
return JSON.parse(unescapeHtmlCommentDelimiters(m[1].trim()));
|
|
56
48
|
} catch { return null; }
|
|
57
49
|
}
|
|
58
50
|
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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,24 @@ 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
|
+
};
|
|
307
369
|
const res = await runIssueFn({ issue, dir, agentId, lane, io,
|
|
308
370
|
runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
|
|
371
|
+
onProgress,
|
|
309
372
|
// Only forwarded when explicitly provided — omitting them preserves
|
|
310
373
|
// runIssue's/runner.mjs's existing defaults (forge#2028).
|
|
311
374
|
...(backend ? { backend } : {}),
|
|
@@ -319,6 +382,18 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
319
382
|
// to avoid regressing/cluttering the existing happy-path output.
|
|
320
383
|
if (res.terminalReason !== "merged") {
|
|
321
384
|
console.log(formatTerminalDiagnostics(dir, issue));
|
|
385
|
+
} else {
|
|
386
|
+
// forge#2399: formatTerminalDiagnostics() (and its phase/reason/state
|
|
387
|
+
// block) intentionally stays gated to non-merged outcomes above, but the
|
|
388
|
+
// aggregate token-usage total should print for "total tokens per run" to
|
|
389
|
+
// hold on the common success case too — surface just the usage line here
|
|
390
|
+
// rather than the full diagnostic block.
|
|
391
|
+
try {
|
|
392
|
+
const usageLine = formatUsageLine(aggregateUsage(readLog(dir, issue)));
|
|
393
|
+
if (usageLine) console.log(usageLine);
|
|
394
|
+
} catch {
|
|
395
|
+
// Corrupt/unreadable log — degrade silently, same as formatTerminalDiagnostics.
|
|
396
|
+
}
|
|
322
397
|
}
|
|
323
398
|
return res;
|
|
324
399
|
}
|