forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,148 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Node twin of scripts/lib/telemetry.sh: same record shape, same upsert into the trusted
5
+ * `<!-- FORGE:TELEMETRY v1 -->` comment, same rollover and local buffer. emit() NEVER throws and never
6
+ * rejects its caller; FORGE_TELEMETRY=off disables it. Records hold ids, counts and codes only.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { randomBytes } from "node:crypto";
10
+ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = join(here, "..", "..");
17
+ const RESERVED = new Set(["v", "ts", "forge_version", "repo", "issue", "pr", "run", "phase", "event", "eid"]);
18
+ const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
19
+ const REVIEW_SIDE = new Set(["review_panel", "finding_disposition", "ci_gate"]);
20
+
21
+ const clean = (v) => String(v).replace(/[\n\r\t]/g, " ").replace(/[^A-Za-z0-9 _.:/#@+=,-]/g, "_").slice(0, 200);
22
+ const num = (s) => (/^[0-9]+$/.test(s ?? "") ? Number(s) : null);
23
+ const str = (s) => (s ? String(s) : null);
24
+
25
+ function forgeVersion() {
26
+ try { return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version || null; } catch { return null; }
27
+ }
28
+
29
+ /** Same JSON line the shell emitter writes (key order included). `ctx` overrides the FORGE_* env. */
30
+ export function buildRecord(event, payload = {}, ctx = {}, now = new Date()) {
31
+ const env = process.env;
32
+ const c = {
33
+ repo: ctx.repo ?? env.FORGE_REPO, issue: ctx.issue ?? env.FORGE_ISSUE, pr: ctx.pr ?? env.FORGE_PR,
34
+ run: ctx.run ?? env.FORGE_RUN, phase: ctx.phase ?? env.FORGE_PHASE,
35
+ };
36
+ const extra = {};
37
+ for (const [k, raw] of Object.entries(payload)) {
38
+ if (!/^[A-Za-z0-9_]+$/.test(k) || RESERVED.has(k)) continue;
39
+ const v = clean(raw);
40
+ extra[k] = /^-?[0-9]+$/.test(v) ? Number(v) : v === "true" ? true : v === "false" ? false : v;
41
+ }
42
+ return {
43
+ v: 1, ts: now.toISOString().replace(/\.\d+Z$/, "Z"), forge_version: forgeVersion(), repo: str(c.repo),
44
+ issue: num(String(c.issue ?? "")), pr: num(String(c.pr ?? "")), run: str(c.run), phase: str(c.phase), event,
45
+ eid: `${Math.floor(now.getTime() / 1000)}-${process.pid}-${randomBytes(4).toString("hex")}`, ...extra,
46
+ };
47
+ }
48
+
49
+ function target(event, rec) {
50
+ const order = REVIEW_SIDE.has(event) ? [rec.pr, rec.issue] : [rec.issue, rec.pr];
51
+ const n = order.find((x) => x != null);
52
+ return n != null && rec.repo && rec.repo.includes("/") ? n : null;
53
+ }
54
+
55
+ function gh(args) {
56
+ const bin = process.env.FORGE_TELEMETRY_GH || "gh";
57
+ const r = spawnSync(bin, args, { encoding: "utf8", timeout: 30000, maxBuffer: 64 * 1024 * 1024 });
58
+ if (r.error || r.status !== 0) throw new Error("gh failed");
59
+ return r.stdout;
60
+ }
61
+
62
+ function trusted(comment) {
63
+ const tc = join(ROOT, "scripts", "trusted-comments.sh");
64
+ const r = spawnSync("bash", [tc, "count", MARKER_RE.source], { input: JSON.stringify([comment]), encoding: "utf8" });
65
+ return r.status === 0 && r.stdout.trim() === "1";
66
+ }
67
+
68
+ function upsert(repo, n, lines) {
69
+ const max = Number(process.env.FORGE_TELEMETRY_MAX_CHARS) || 60000;
70
+ const raw = gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`]).trim();
71
+ const pages = raw ? JSON.parse(`[${raw.replace(/\]\s*\[/g, "],[")}]`) : [[]];
72
+ let best = null;
73
+ const parts = [];
74
+ for (const c of pages.flat()) {
75
+ const m = MARKER_RE.exec(c.body || "");
76
+ if (!m || !trusted(c)) continue;
77
+ const part = m[2] ? Number(m[2]) : 1;
78
+ parts.push({ body: c.body });
79
+ if (!best || part >= best.part) best = { id: c.id, part, body: c.body };
80
+ }
81
+ const all = parts.map((p) => p.body).join("\n");
82
+ const fresh = lines.filter((l) => !all.includes(l));
83
+ if (fresh.length === 0) return;
84
+ if (best) {
85
+ const cut = best.body.lastIndexOf("\n```");
86
+ const out = `${cut >= 0 ? best.body.slice(0, cut) : best.body}\n${fresh.join("\n")}\n\`\`\``;
87
+ if (out.length <= max) { gh(["api", "-X", "PATCH", `repos/${repo}/issues/comments/${best.id}`, "-f", `body=${out}`]); return; }
88
+ }
89
+ const hdr = best ? `<!-- FORGE:TELEMETRY v1 part=${best.part + 1} -->` : "<!-- FORGE:TELEMETRY v1 -->";
90
+ gh(["api", "-X", "POST", `repos/${repo}/issues/${n}/comments`, "-f", `body=${hdr}\n\`\`\`jsonl\n${fresh.join("\n")}\n\`\`\``]);
91
+ }
92
+
93
+ /** Emit one event. Returns the record when written or buffered, null when disabled/dropped. Never throws. */
94
+ export function emit(event, payload = {}, ctx = {}) {
95
+ try {
96
+ if ((process.env.FORGE_TELEMETRY || "on") === "off") return null;
97
+ if (!/^[A-Za-z0-9_.-]+$/.test(event || "")) return null;
98
+ const rec = buildRecord(event, payload, ctx);
99
+ const n = target(event, rec);
100
+ if (n == null) return null;
101
+ const dir = process.env.FORGE_TELEMETRY_BUFFER_DIR || join(homedir(), ".forge", "telemetry-buffer");
102
+ const buf = join(dir, `${rec.repo.replace("/", "_")}__${n}.jsonl`);
103
+ const line = JSON.stringify(rec);
104
+ // Per-target lock (mkdir is atomic): serializes emitters on this host so the unversioned GitHub read-modify-write
105
+ // cannot lose a record or double-POST a part. Bounded wait (50 x 0.1s); a lock over 5 min old is stale and is
106
+ // reclaimed by an atomic rename (one winner); the holder writes an owner token and releases only its own lock.
107
+ // A non-writable buffer dir skips locking; on timeout the record is buffered.
108
+ let canLock = true;
109
+ try { mkdirSync(dir, { recursive: true }); accessSync(dir, constants.W_OK); } catch { canLock = false; }
110
+ const lock = join(dir, `.lock-${rec.repo.replace("/", "_")}__${n}`);
111
+ const token = `${process.pid}.${randomBytes(4).toString("hex")}`;
112
+ let got = !canLock;
113
+ for (let i = 0; i < 50 && !got; i++) {
114
+ try { mkdirSync(lock); writeFileSync(join(lock, "owner"), token); got = true; } catch {
115
+ let stale = false;
116
+ try { stale = Date.now() - statSync(lock).mtimeMs > 300000; } catch { /* raced */ }
117
+ if (stale) { try { renameSync(lock, `${lock}.stale.${token}`); rmSync(`${lock}.stale.${token}`, { recursive: true, force: true }); } catch { /* lost the race */ } }
118
+ else spawnSync("sleep", ["0.1"]);
119
+ }
120
+ }
121
+ if (!got) { appendFileSync(buf, `${line}\n`); return rec; }
122
+ const unlock = () => { if (!canLock) return; try { if (readFileSync(join(lock, "owner"), "utf8") === token) rmSync(lock, { recursive: true, force: true }); } catch { /* already gone */ } };
123
+ // Claim the buffer by renaming it: records appended meanwhile land in a fresh file and are never deleted. Holding
124
+ // the lock, any other "<buf>.flush.*" is an orphan of a killed emitter: fold it in.
125
+ const claimed = `${buf}.flush.${token}`;
126
+ let pending = [line];
127
+ try {
128
+ if (existsSync(buf)) renameSync(buf, claimed);
129
+ for (const f of readdirSync(dir)) {
130
+ const full = join(dir, f);
131
+ if (f.startsWith(`${basename(buf)}.flush.`) && full !== claimed) { appendFileSync(claimed, readFileSync(full, "utf8")); rmSync(full, { force: true }); }
132
+ }
133
+ if (existsSync(claimed)) pending = [...readFileSync(claimed, "utf8").split("\n").filter(Boolean), line];
134
+ } catch { /* nothing claimed */ }
135
+ try {
136
+ upsert(rec.repo, n, pending);
137
+ rmSync(claimed, { force: true });
138
+ } catch {
139
+ try {
140
+ if (existsSync(claimed)) { appendFileSync(buf, readFileSync(claimed, "utf8")); rmSync(claimed, { force: true }); }
141
+ appendFileSync(buf, `${line}\n`);
142
+ } catch { /* lost record costs completeness only */ }
143
+ } finally { unlock(); }
144
+ return rec;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
@@ -0,0 +1,120 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it, beforeEach, afterEach } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { buildRecord, emit } from "./telemetry.mjs";
10
+
11
+ let td; let saved;
12
+ const KEYS = ["FORGE_TELEMETRY", "FORGE_TELEMETRY_GH", "FORGE_TELEMETRY_BUFFER_DIR", "FORGE_TELEMETRY_MAX_CHARS", "FORGE_REPO", "FORGE_ISSUE", "FORGE_PR", "FORGE_RUN", "FORGE_PHASE", "TD"];
13
+
14
+ // Stub gh: lists $TD/comments.json, records the last PATCH/POST body in $TD/patched|posted, logs calls.
15
+ function stubGh(fail = false) {
16
+ const p = join(td, "gh");
17
+ writeFileSync(p, `#!/usr/bin/env bash
18
+ echo "$*" >> "$TD/calls"
19
+ ${fail ? "exit 1" : ""}
20
+ case "$*" in
21
+ "api --paginate "*) if [ -f "$TD/comments.json" ]; then cat "$TD/comments.json"; else echo '[]'; fi ;;
22
+ "api -X PATCH"*) b="\${@: -1}"; printf '%s' "\${b#body=}" > "$TD/patched" ;;
23
+ "api -X POST"*) b="\${@: -1}"; printf '%s' "\${b#body=}" > "$TD/posted" ;;
24
+ esac
25
+ `);
26
+ chmodSync(p, 0o755);
27
+ process.env.FORGE_TELEMETRY_GH = p;
28
+ }
29
+ const setComments = (bodies) => writeFileSync(join(td, "comments.json"),
30
+ JSON.stringify(bodies.map((b, i) => ({ id: 100 + i, body: b, user: { type: "Bot" }, author_association: "NONE" }))));
31
+
32
+ beforeEach(() => {
33
+ saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
34
+ td = mkdtempSync(join(tmpdir(), "tel-"));
35
+ Object.assign(process.env, { TD: td, FORGE_TELEMETRY_BUFFER_DIR: join(td, "buf"), FORGE_REPO: "o/r", FORGE_ISSUE: "5" });
36
+ for (const k of ["FORGE_TELEMETRY", "FORGE_PR", "FORGE_RUN", "FORGE_PHASE", "FORGE_TELEMETRY_MAX_CHARS"]) delete process.env[k];
37
+ });
38
+ afterEach(() => {
39
+ for (const k of KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
40
+ rmSync(td, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("telemetry emitter (node twin)", () => {
44
+ it("builds the shared record shape with null context and typed payload", () => {
45
+ const r = buildRecord("diff_size", { lines: "10", excluded: 0, threshold: 0, result: "OK", bad: "a`b\nc" });
46
+ assert.deepEqual(Object.keys(r).slice(0, 9), ["v", "ts", "forge_version", "repo", "issue", "pr", "run", "phase", "event"]);
47
+ assert.equal(r.v, 1); assert.equal(r.issue, 5); assert.equal(r.pr, null); assert.equal(r.run, null);
48
+ assert.equal(r.lines, 10); assert.equal(r.result, "OK"); assert.equal(r.bad, "a_b c");
49
+ assert.match(r.forge_version, /^\d+\.\d+\.\d+/);
50
+ });
51
+
52
+ it("drops reserved and malformed keys", () => {
53
+ const r = buildRecord("x", { repo: "evil", "a b": 1, ok: 1 });
54
+ assert.equal(r.repo, "o/r"); assert.equal(r.ok, 1); assert.equal("a b" in r, false);
55
+ });
56
+
57
+ it("creates then upserts one trusted comment", () => {
58
+ stubGh();
59
+ emit("ci_gate", { result: "PASS", rc: 0, checks: 3 });
60
+ const posted = readFileSync(join(td, "posted"), "utf8");
61
+ assert.match(posted, /^<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n\{.*"event":"ci_gate".*\}\n```$/);
62
+ setComments([posted]);
63
+ emit("liveness", { decision: "DEFER", why: "lease" });
64
+ const patched = readFileSync(join(td, "patched"), "utf8");
65
+ assert.equal(patched.split("\n").filter((l) => l.startsWith("{")).length, 2);
66
+ assert.deepEqual(existsSync(join(td, "buf")) ? readdirSync(join(td, "buf")) : [], []);
67
+ });
68
+
69
+ it("ignores an untrusted telemetry comment", () => {
70
+ stubGh();
71
+ writeFileSync(join(td, "comments.json"), JSON.stringify([{ id: 1, body: "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n```", user: { type: "User" }, author_association: "NONE" }]));
72
+ emit("ci_gate", { result: "PASS" });
73
+ assert.ok(existsSync(join(td, "posted")));
74
+ assert.equal(existsSync(join(td, "patched")), false);
75
+ });
76
+
77
+ it("rolls over to part=N near the size limit", () => {
78
+ stubGh();
79
+ process.env.FORGE_TELEMETRY_MAX_CHARS = "300";
80
+ setComments(["<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n" + '{"a":"' + "x".repeat(250) + '"}\n```']);
81
+ emit("ci_gate", { result: "PASS" });
82
+ assert.match(readFileSync(join(td, "posted"), "utf8"), /^<!-- FORGE:TELEMETRY v1 part=2 -->/);
83
+ });
84
+
85
+ it("buffers on a failed write and flushes on the next emit", () => {
86
+ stubGh(true);
87
+ assert.doesNotThrow(() => emit("diff_size", { lines: 1 }));
88
+ const files = readdirSync(join(td, "buf"));
89
+ assert.deepEqual(files, ["o_r__5.jsonl"]);
90
+ stubGh(false);
91
+ emit("diff_size", { lines: 2 });
92
+ const posted = readFileSync(join(td, "posted"), "utf8");
93
+ assert.equal(posted.split("\n").filter((l) => l.startsWith("{")).length, 2);
94
+ assert.equal(existsSync(join(td, "buf", "o_r__5.jsonl")), false);
95
+ });
96
+
97
+ it("never throws with an unwritable buffer dir and no gh", () => {
98
+ process.env.FORGE_TELEMETRY_GH = "/nonexistent/gh";
99
+ writeFileSync(join(td, "afile"), "x");
100
+ process.env.FORGE_TELEMETRY_BUFFER_DIR = join(td, "afile", "buf"); // parent is a file: mkdir fails
101
+ assert.doesNotThrow(() => emit("diff_size", { lines: 1 }));
102
+ });
103
+
104
+ it("honours FORGE_TELEMETRY=off and drops records with no target", () => {
105
+ stubGh();
106
+ process.env.FORGE_TELEMETRY = "off";
107
+ assert.equal(emit("ci_gate", { result: "PASS" }), null);
108
+ assert.equal(existsSync(join(td, "calls")), false);
109
+ delete process.env.FORGE_TELEMETRY; delete process.env.FORGE_ISSUE;
110
+ assert.equal(emit("ci_gate", { result: "PASS" }), null);
111
+ });
112
+
113
+ it("shell and node emitters produce the same keys", () => {
114
+ process.env.FORGE_PR = "9"; process.env.FORGE_RUN = "r1"; process.env.FORGE_PHASE = "build";
115
+ const sh = spawnSync("bash", ["-c", `. "${join(import.meta.dirname, "..", "..", "scripts", "lib", "telemetry.sh")}"; _forge_tel_record diff_size lines=10 result=OK`], { encoding: "utf8", env: process.env });
116
+ const a = JSON.parse(sh.stdout); const b = buildRecord("diff_size", { lines: 10, result: "OK" });
117
+ assert.deepEqual(Object.keys(a), Object.keys(b));
118
+ for (const k of Object.keys(a)) if (k !== "ts" && k !== "eid") assert.deepEqual(a[k], b[k], k);
119
+ });
120
+ });
@@ -13,6 +13,7 @@ import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs"
13
13
  import { makeProjector } from "./engine/projector.mjs";
14
14
  import { readLog, deriveState } from "./engine/runlog.mjs";
15
15
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
16
+ import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
16
17
 
17
18
  /**
18
19
  * Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
@@ -77,8 +78,16 @@ export function runDir() { return join(homedir(), ".forge", "runs"); }
77
78
  export function aggregateUsage(events) {
78
79
  let found = false;
79
80
  const total = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
81
+ // forge#3696: PHASE_COMMIT.usage is cumulative over all attempts, so summing it with
82
+ // PHASE_FAILED would double count. For a phase that has PHASE_ATTEMPT_USAGE events,
83
+ // sum ONLY those; otherwise (old logs) fall back to the legacy COMMIT + FAILED sum.
84
+ const attemptPhases = new Set(events.filter((e) => e.event === "PHASE_ATTEMPT_USAGE").map((e) => e.phase));
80
85
  for (const e of events) {
81
- if (e.event !== "PHASE_COMMIT" && e.event !== "PHASE_FAILED") continue;
86
+ if (e.event === "PHASE_ATTEMPT_USAGE") {
87
+ // counted as-is (usage null => nothing to add)
88
+ } else if (e.event === "PHASE_COMMIT" || e.event === "PHASE_FAILED") {
89
+ if (attemptPhases.has(e.phase)) continue;
90
+ } else continue;
82
91
  if (!e.usage || typeof e.usage !== "object") continue;
83
92
  found = true;
84
93
  total.input_tokens += e.usage.input_tokens ?? 0;
@@ -393,6 +402,8 @@ export async function runFromCli(argv, deps = {}) {
393
402
  ...(repo ? { repo } : {}),
394
403
  runner: (await import("./runner.mjs")).runCommand, now: () => Date.now(),
395
404
  onProgress,
405
+ // forge#3835: phase_attempt / run_terminal records reach GitHub so `forgedock stats` works from any machine.
406
+ emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo }),
396
407
  // Only forwarded when explicitly provided — omitting them preserves
397
408
  // runIssue's/runner.mjs's existing defaults (forge#2028).
398
409
  ...(backend ? { backend } : {}),