forgedock 1.16.2 → 1.18.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/engine/outcome-codes.mjs +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Primary source for `forgedock stats` (forge#3835): the target repo's issues and PRs on GitHub.
|
|
5
|
+
* Reads labels and ONLY trusted `<!-- FORGE:TELEMETRY v1 -->` comments (same predicate as
|
|
6
|
+
* scripts/trusted-comments.sh: an author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS, a Bot, or a login in
|
|
7
|
+
* FORGE_TRAIL_TRUSTED_LOGINS). Every `gh` call is argv-based (no shell). The `gh` runner is injectable for tests.
|
|
8
|
+
* Fetching is incremental: only items updated after the cached watermark (minus a small overlap window) are re-read.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { emptyCache, loadCache, saveCache } from "./cache.mjs";
|
|
12
|
+
|
|
13
|
+
export const MARKER_RE = /^<!-- FORGE:TELEMETRY v1( part=(\d+))? -->/;
|
|
14
|
+
const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
15
|
+
export const isValidRepo = (r) => typeof r === "string" && OWNER_REPO_RE.test(r) && !r.split("/").some((p) => p === "." || p === "..");
|
|
16
|
+
|
|
17
|
+
export function defaultGh(args) {
|
|
18
|
+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `gh api --paginate` prints one JSON array per page; join them into one flat array. */
|
|
22
|
+
export function parsePages(raw) {
|
|
23
|
+
const t = (raw ?? "").trim();
|
|
24
|
+
if (!t) return [];
|
|
25
|
+
return JSON.parse(`[${t.replace(/\]\s*\[/g, "],[")}]`).flat();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTrusted(comment, env = process.env) {
|
|
29
|
+
const assoc = (env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR").split(",").map((s) => s.trim()).filter(Boolean);
|
|
30
|
+
const logins = (env.FORGE_TRAIL_TRUSTED_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
return assoc.includes(comment?.author_association ?? "") || (comment?.user?.type ?? "") === "Bot" || logins.includes(comment?.user?.login ?? "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Trusted FORGE:TELEMETRY comments -> record objects. Parts are all read (order is irrelevant), the fenced `jsonl`
|
|
36
|
+
* block is parsed line by line (a torn or invalid line is skipped), and records are deduped on `eid` (never on the
|
|
37
|
+
* line text: identical same-second events carry different ids).
|
|
38
|
+
*/
|
|
39
|
+
export function parseTelemetryComments(comments, env = process.env) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const c of comments) {
|
|
43
|
+
if (!MARKER_RE.test(c?.body ?? "") || !isTrusted(c, env)) continue;
|
|
44
|
+
const block = /```jsonl\n([\s\S]*?)(?:\n```|$)/.exec(c.body);
|
|
45
|
+
if (!block) continue;
|
|
46
|
+
for (const line of block[1].split("\n")) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
let rec;
|
|
49
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
50
|
+
if (!rec || typeof rec !== "object" || typeof rec.event !== "string" || typeof rec.eid !== "string" || seen.has(rec.eid)) continue;
|
|
51
|
+
seen.add(rec.eid);
|
|
52
|
+
out.push(rec);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const num = (x) => { const v = Number(x); return x != null && x !== "" && Number.isFinite(v) ? v : null; };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turn `phase_attempt` / `run_terminal` records into run summaries, finished only when a `run_terminal` exists.
|
|
62
|
+
* The run id is per engine process, so a `--retry` yields several; they collapse to ONE run per repo#issue, the latest
|
|
63
|
+
* by start timestamp (tie: greater run id), matching scripts/cost-report.sh (last RUN_START slice). Finished/usage
|
|
64
|
+
* filtering stays downstream, so an unfinished latest run still excludes the issue. Field names are the ones bin/engine/telemetry-seam.mjs writes (docs/TELEMETRY.md).
|
|
65
|
+
*/
|
|
66
|
+
export function runsFromRecords(records) {
|
|
67
|
+
const byRun = new Map();
|
|
68
|
+
for (const r of records) {
|
|
69
|
+
if ((r.event !== "phase_attempt" && r.event !== "run_terminal") || r.issue == null) continue;
|
|
70
|
+
const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
|
|
71
|
+
const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
|
|
72
|
+
finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
|
|
73
|
+
phases: {}, _first: null };
|
|
74
|
+
byRun.set(key, run);
|
|
75
|
+
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
76
|
+
if (r.event === "run_terminal") {
|
|
77
|
+
run.finished = true; run.reason = r.reason ?? null; run.code = r.code ?? null; run.endTs = r.ts ?? null; run.wallMs = num(r.wall_ms);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const id = r.phase ?? "other";
|
|
81
|
+
const p = (run.phases[id] ??= { cr: 0, out: 0, usd: null, ms: null, attempts: 0 });
|
|
82
|
+
const hasUsage = [r.input_tokens, r.output_tokens, r.cache_read_tokens].some((x) => num(x) != null);
|
|
83
|
+
if (hasUsage) run.usageEvents++;
|
|
84
|
+
p.cr += num(r.cache_read_tokens) ?? 0;
|
|
85
|
+
p.out += num(r.output_tokens) ?? 0;
|
|
86
|
+
p.attempts++;
|
|
87
|
+
if (num(r.duration_ms) != null) p.ms = (p.ms ?? 0) + num(r.duration_ms);
|
|
88
|
+
if (num(r.cost_usd) != null) p.usd = (p.usd ?? 0) + num(r.cost_usd);
|
|
89
|
+
if (id === "remediate") run.remediated = true;
|
|
90
|
+
}
|
|
91
|
+
const latest = new Map();
|
|
92
|
+
for (const run of byRun.values()) {
|
|
93
|
+
const k = `${run.repo}#${run.issue}`;
|
|
94
|
+
const cur = latest.get(k);
|
|
95
|
+
const a = run._first ?? "", b = cur?._first ?? "";
|
|
96
|
+
if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
|
|
97
|
+
}
|
|
98
|
+
return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Gate/review records (everything that is not a run record) become aggregate events. */
|
|
102
|
+
export const gateEvents = (records) => records.filter((r) => r.event !== "phase_attempt" && r.event !== "run_terminal")
|
|
103
|
+
.map((r) => ({ ...r, version: r.forge_version ?? null }));
|
|
104
|
+
|
|
105
|
+
const CHECKPOINT_EVERY = 25;
|
|
106
|
+
const PAGE_SIZE = 100;
|
|
107
|
+
// The next incremental run re-reads this much before the persisted watermark (an overlap window), so a write that races a
|
|
108
|
+
// crawl or a small clock skew is re-listed. Re-reads are idempotent: items are keyed by issue number.
|
|
109
|
+
const WATERMARK_OVERLAP_MS = 5 * 60 * 1000;
|
|
110
|
+
const MAX_LIST_REQUESTS = 100000;
|
|
111
|
+
|
|
112
|
+
/** Shift an ISO-8601 timestamp by `ms`; an unparsable value is returned unchanged. */
|
|
113
|
+
export function shiftIso(ts, ms) {
|
|
114
|
+
const t = Date.parse(ts);
|
|
115
|
+
return Number.isNaN(t) ? ts : new Date(t + ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sinceParam = (ts) => encodeURIComponent(ts).replace(/%3A/g, ":");
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keyset crawl of the issue listing. The listing is sorted by a MUTABLE key (updated_at), so page-number pagination
|
|
122
|
+
* (`gh --paginate`) can skip an item updated mid-crawl: it moves to the end of the ordering and the items behind it shift
|
|
123
|
+
* one page earlier. Instead every request is page 1 from `since=CURSOR`, the cursor moves to the newest `updated_at` read,
|
|
124
|
+
* and already-seen (number, updated_at) pairs are dropped (the `since` boundary is inclusive). A full page that cannot move
|
|
125
|
+
* the cursor (more than PAGE_SIZE items share one timestamp) is drained with `page=N` for that timestamp only.
|
|
126
|
+
* Yields each page's unseen items sorted updated-asc.
|
|
127
|
+
*/
|
|
128
|
+
function* listIssuePages(gh, repo, startSince) {
|
|
129
|
+
const base = ["state=all", "sort=updated", "direction=asc", `per_page=${PAGE_SIZE}`];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const key = (it) => `${it.number}@${it.updated_at}`;
|
|
132
|
+
const take = (page) => {
|
|
133
|
+
const fresh = page.filter((it) => Number.isInteger(it?.number) && !seen.has(key(it)));
|
|
134
|
+
for (const it of fresh) seen.add(key(it));
|
|
135
|
+
return fresh.sort((a, b) => String(a.updated_at).localeCompare(String(b.updated_at)));
|
|
136
|
+
};
|
|
137
|
+
const maxTs = (page) => page.reduce((m, it) => (it?.updated_at && (!m || it.updated_at > m) ? it.updated_at : m), null);
|
|
138
|
+
let cursor = startSince ?? null;
|
|
139
|
+
for (let n = 0; n < MAX_LIST_REQUESTS; n++) {
|
|
140
|
+
const get = (extra = []) => parsePages(gh(["api", `repos/${repo}/issues?${[...base, ...(cursor ? [`since=${sinceParam(cursor)}`] : []), ...extra].join("&")}`]));
|
|
141
|
+
const page = get();
|
|
142
|
+
const fresh = take(page);
|
|
143
|
+
if (fresh.length > 0) yield fresh;
|
|
144
|
+
if (page.length < PAGE_SIZE) return;
|
|
145
|
+
const next = maxTs(page);
|
|
146
|
+
if (next && next !== cursor) { cursor = next; continue; }
|
|
147
|
+
// No cursor progress on a full page: drain this timestamp by page number, then step past it.
|
|
148
|
+
for (let p = 2; p < MAX_LIST_REQUESTS; p++) {
|
|
149
|
+
const more = get([`page=${p}`]);
|
|
150
|
+
const freshMore = take(more);
|
|
151
|
+
if (freshMore.length > 0) yield freshMore;
|
|
152
|
+
if (more.length < PAGE_SIZE || freshMore.length === 0) break;
|
|
153
|
+
}
|
|
154
|
+
if (!cursor) return;
|
|
155
|
+
cursor = shiftIso(cursor, 1000);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const meta = (it) => ({ number: it.number, isPr: Boolean(it.pull_request), createdAt: it.created_at ?? null, closedAt: it.closed_at ?? null,
|
|
160
|
+
updatedAt: it.updated_at ?? null, labels: (it.labels ?? []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), authorType: it.user?.type ?? null });
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch (or incrementally refresh) the repo's issues, PRs and trusted telemetry records.
|
|
164
|
+
* Returns { items, records, fetched, fromCache }. `refresh` ignores the cache; `useCache:false` never touches disk.
|
|
165
|
+
*/
|
|
166
|
+
export function fetchGithubRecords({ repo, refresh = false, useCache = true, gh = defaultGh, env = process.env } = {}) {
|
|
167
|
+
if (!isValidRepo(repo)) throw new Error(`invalid repo: ${repo}`);
|
|
168
|
+
const cache = useCache && !refresh ? loadCache(repo, env) : emptyCache();
|
|
169
|
+
const startSince = cache.watermark ? shiftIso(cache.watermark, -WATERMARK_OVERLAP_MS) : null;
|
|
170
|
+
const items = { ...cache.items };
|
|
171
|
+
let watermark = cache.watermark;
|
|
172
|
+
let fetched = 0;
|
|
173
|
+
// Checkpoint: the watermark is the newest updated_at actually processed, and the next run starts WATERMARK_OVERLAP_MS
|
|
174
|
+
// before it, so it is a safe resume point. A failure mid-crawl (rate limit, 5xx, a hook timeout) then keeps the
|
|
175
|
+
// progress instead of restarting at zero.
|
|
176
|
+
const checkpoint = () => { if (useCache) saveCache(repo, { ...emptyCache(), watermark, items }, env); };
|
|
177
|
+
try {
|
|
178
|
+
for (const page of listIssuePages(gh, repo, startSince)) for (const it of page) {
|
|
179
|
+
const m = meta(it);
|
|
180
|
+
let records = items[it.number]?.records ?? [];
|
|
181
|
+
// A telemetry comment is edited in place (which may not bump the issue's updated_at), so every listed item with
|
|
182
|
+
// comments is re-read rather than trusting a cached copy.
|
|
183
|
+
if ((it.comments ?? 0) > 0) {
|
|
184
|
+
records = parseTelemetryComments(parsePages(gh(["api", "--paginate", `repos/${repo}/issues/${it.number}/comments`])), env);
|
|
185
|
+
fetched++;
|
|
186
|
+
}
|
|
187
|
+
items[it.number] = { ...m, records };
|
|
188
|
+
if (m.updatedAt && (!watermark || m.updatedAt > watermark)) watermark = m.updatedAt;
|
|
189
|
+
if (fetched > 0 && fetched % CHECKPOINT_EVERY === 0 && (it.comments ?? 0) > 0) checkpoint();
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
checkpoint();
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
checkpoint();
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const records = [];
|
|
198
|
+
for (const it of Object.values(items)) for (const r of it.records ?? []) {
|
|
199
|
+
if (!seen.has(r.eid)) { seen.add(r.eid); records.push(r); }
|
|
200
|
+
}
|
|
201
|
+
return { items: Object.values(items), records, fetched, fromCache: cache.watermark != null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Aggregate-model inputs for one repo from a fetch result. */
|
|
205
|
+
export function toModel(repo, result) {
|
|
206
|
+
return {
|
|
207
|
+
runs: runsFromRecords(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
|
|
208
|
+
events: gateEvents(result.records.map((r) => ({ ...r, repo: r.repo ?? repo }))),
|
|
209
|
+
issues: result.items.map((i) => ({ repo, number: i.number, isPr: i.isPr, createdAt: i.createdAt, closedAt: i.closedAt, labels: i.labels, authorType: i.authorType })),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forgedock stats (forge#3835): a deterministic version-over-version report, no model in the loop.
|
|
5
|
+
* GitHub is the primary source (trusted FORGE:TELEMETRY comments + labels); ~/.forge/stats-cache is an optional,
|
|
6
|
+
* deletable cache; ~/.forge/runs is an optional extra source for runs that predate telemetry.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
13
|
+
import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
|
+
import { readLocalRuns, runsDir } from "./local-source.mjs";
|
|
15
|
+
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
20
|
+
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
21
|
+
|
|
22
|
+
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
23
|
+
delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
|
|
24
|
+
|
|
25
|
+
--since <v|date> only versions >= <v> (e.g. 1.16.0) or activity on/after <date> (YYYY-MM-DD)
|
|
26
|
+
--repo <owner/repo> target repo (default: forge.yaml project, else the git origin remote)
|
|
27
|
+
--json machine-readable output
|
|
28
|
+
--refresh ignore the local cache and re-read everything from GitHub
|
|
29
|
+
--compact one-row-per-version table (embedded in the staging review summary)
|
|
30
|
+
--no-local ignore ~/.forge/runs (FORGE_RUNS_DIR)
|
|
31
|
+
--no-cache do not read or write ~/.forge/stats-cache
|
|
32
|
+
--min-n N low-n threshold (default ${DEFAULT_MIN_N})
|
|
33
|
+
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
34
|
+
(default: git tags v* of the ForgeDock checkout)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export function parseArgs(argv) {
|
|
38
|
+
const o = { since: null, repo: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
39
|
+
const val = (i, name) => {
|
|
40
|
+
const v = argv[i + 1];
|
|
41
|
+
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
42
|
+
return v;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
const eq = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : null;
|
|
47
|
+
const name = eq ? eq[0] : a;
|
|
48
|
+
const take = () => { if (eq) return eq[1]; const v = val(i, name); i++; return v; };
|
|
49
|
+
if (name === "--since") o.since = take();
|
|
50
|
+
else if (name === "--repo") o.repo = take();
|
|
51
|
+
else if (name === "--release-times") o.releaseTimes = take();
|
|
52
|
+
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
53
|
+
else if (name === "--json") o.json = true;
|
|
54
|
+
else if (name === "--refresh") o.refresh = true;
|
|
55
|
+
else if (name === "--compact") o.compact = true;
|
|
56
|
+
else if (name === "--no-local") o.local = false;
|
|
57
|
+
else if (name === "--no-cache") o.cache = false;
|
|
58
|
+
else if (name === "--help" || name === "-h") o.help = true;
|
|
59
|
+
else throw new Error(`unknown argument: ${a}`);
|
|
60
|
+
}
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "--since" is a date (YYYY-MM-DD[...]) or a version (1.16.0). */
|
|
65
|
+
export function parseSince(since) {
|
|
66
|
+
if (!since) return {};
|
|
67
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(since)) {
|
|
68
|
+
const ms = Date.parse(since);
|
|
69
|
+
if (Number.isNaN(ms)) throw new Error(`invalid --since date: ${since}`);
|
|
70
|
+
return { sinceMs: ms };
|
|
71
|
+
}
|
|
72
|
+
if (/^v?\d+(\.\d+)*$/.test(since)) return { sinceVersion: since.replace(/^v/, "") };
|
|
73
|
+
throw new Error(`--since must be a version (1.16.0) or a date (YYYY-MM-DD): ${since}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseReleaseTimes(text) {
|
|
77
|
+
return String(text ?? "").split("\n").map((l) => l.trim().split(/\s+/))
|
|
78
|
+
.filter((p) => p.length === 2 && /^\d+$/.test(p[1])).map((p) => ({ v: p[0].replace(/^v/, ""), t: Number(p[1]) }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function gitTagTimes() {
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync("git", ["-C", join(here, "..", ".."), "for-each-ref", "refs/tags", "--format=%(refname:short) %(creatordate:unix)"],
|
|
84
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
85
|
+
return parseReleaseTimes(out.split("\n").filter((l) => /^v\d/.test(l)).join("\n"));
|
|
86
|
+
} catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** --repo, else forge.yaml project.owner/repo, else the git origin remote. */
|
|
90
|
+
export function resolveRepo(explicit, cwd = process.cwd()) {
|
|
91
|
+
if (explicit) return explicit;
|
|
92
|
+
for (const f of ["forge.yaml", ".forge.yaml"]) {
|
|
93
|
+
const p = join(cwd, f);
|
|
94
|
+
if (!existsSync(p)) continue;
|
|
95
|
+
try {
|
|
96
|
+
const m = /^project:\s*\n((?:[ \t]+.*\n?|\s*\n)*)/m.exec(readFileSync(p, "utf8"));
|
|
97
|
+
const owner = /^[ \t]+owner:\s*"?([\w.-]+)"?/m.exec(m?.[1] ?? "")?.[1];
|
|
98
|
+
const repo = /^[ \t]+repo:\s*"?([\w.-]+)"?/m.exec(m?.[1] ?? "")?.[1];
|
|
99
|
+
if (owner && repo) return `${owner}/${repo}`;
|
|
100
|
+
} catch { /* fall through */ }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const url = execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
104
|
+
const m = /[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/.exec(url);
|
|
105
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
106
|
+
} catch { /* none */ }
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build the report object. deps: { gh, env, localRuns, now } for tests. Never touches the network when `gh` is stubbed. */
|
|
111
|
+
export function buildReport(opts, deps = {}) {
|
|
112
|
+
const env = deps.env ?? process.env;
|
|
113
|
+
const repo = opts.repo;
|
|
114
|
+
const gh = fetchGithubRecords({ repo, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env });
|
|
115
|
+
const model = toModel(repo, gh);
|
|
116
|
+
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
117
|
+
let local = [];
|
|
118
|
+
if (opts.local) {
|
|
119
|
+
local = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo === repo && !ghKeys.has(`${r.repo}#${r.issue}`));
|
|
120
|
+
}
|
|
121
|
+
let releaseTimes = [];
|
|
122
|
+
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
123
|
+
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
124
|
+
else releaseTimes = gitTagTimes();
|
|
125
|
+
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
126
|
+
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
127
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runStats(argv, deps = {}) {
|
|
131
|
+
let opts;
|
|
132
|
+
try { opts = parseArgs(argv); } catch (err) {
|
|
133
|
+
process.stderr.write(`forgedock stats: ${err.message}\n\n${HELP}`);
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
137
|
+
opts.repo = resolveRepo(opts.repo);
|
|
138
|
+
if (!opts.repo || !isValidRepo(opts.repo)) {
|
|
139
|
+
process.stderr.write(`forgedock stats: could not resolve a valid owner/repo (pass --repo owner/repo)\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const report = buildReport(opts, deps);
|
|
144
|
+
process.stdout.write(`${opts.json ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
process.stderr.write(`forgedock stats: ${err.message}\n`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
153
|
+
process.exitCode = await runStats(process.argv.slice(2));
|
|
154
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Optional extra source for `forgedock stats` (forge#3835): the engine run logs in ~/.forge/runs (FORGE_RUNS_DIR).
|
|
5
|
+
* Needed only for runs that predate the GitHub `phase_attempt` / `run_terminal` records. Same counting rules as
|
|
6
|
+
* scripts/cost-report.sh. Old logs lack `ts`, `code`, `version` and `repo`: those fields become null.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const runsDir = (env = process.env) => env.FORGE_RUNS_DIR || join(homedir(), ".forge", "runs");
|
|
13
|
+
|
|
14
|
+
function parseLog(text) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try { const e = JSON.parse(line); if (e && typeof e === "object") out.push(e); } catch { /* truncated or corrupt line */ }
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const n = (x) => (Number.isFinite(x) ? x : 0);
|
|
24
|
+
|
|
25
|
+
/** Summarise one log: the last RUN_START..end slice. Returns null when there is no RUN_START. */
|
|
26
|
+
export function summariseLog(events, fallbackIssue = null) {
|
|
27
|
+
let s = -1;
|
|
28
|
+
events.forEach((e, i) => { if (e.event === "RUN_START") s = i; });
|
|
29
|
+
if (s < 0) return null;
|
|
30
|
+
const ev = events.slice(s);
|
|
31
|
+
const start = ev[0];
|
|
32
|
+
let t = -1; let r = -1;
|
|
33
|
+
ev.forEach((e, i) => {
|
|
34
|
+
if (e.event === "RUN_TERMINAL") t = i;
|
|
35
|
+
if (e.event === "RUN_REOPEN" || e.event === "RUN_NEXT_CYCLE") r = i;
|
|
36
|
+
});
|
|
37
|
+
const attemptPhases = new Set(ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE").map((e) => e.phase));
|
|
38
|
+
const phases = {};
|
|
39
|
+
let usageEvents = 0;
|
|
40
|
+
for (const e of ev) {
|
|
41
|
+
const attempt = e.event === "PHASE_ATTEMPT_USAGE";
|
|
42
|
+
const legacy = (e.event === "PHASE_COMMIT" || e.event === "PHASE_FAILED") && !attemptPhases.has(e.phase);
|
|
43
|
+
if (!(attempt || legacy) || !e.usage || typeof e.usage !== "object") continue;
|
|
44
|
+
usageEvents++;
|
|
45
|
+
const p = (phases[e.phase ?? "other"] ??= { cr: 0, out: 0, usd: null, ms: null, attempts: 0 });
|
|
46
|
+
p.cr += n(e.usage.cache_read_input_tokens);
|
|
47
|
+
p.out += n(e.usage.output_tokens);
|
|
48
|
+
p.attempts++;
|
|
49
|
+
if (attempt && Number.isFinite(e.duration_ms)) p.ms = (p.ms ?? 0) + e.duration_ms;
|
|
50
|
+
if (attempt && Number.isFinite(e.cost_usd)) p.usd = (p.usd ?? 0) + e.cost_usd;
|
|
51
|
+
}
|
|
52
|
+
const finished = t >= 0 && (r < 0 || t > r);
|
|
53
|
+
const startTs = start.ts ?? null;
|
|
54
|
+
const endTs = finished ? ev[t].ts ?? null : null;
|
|
55
|
+
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
56
|
+
return {
|
|
57
|
+
source: "local", repo: start.repo ?? null, version: start.version ?? null, issue: start.issue ?? fallbackIssue, run: start.run ?? null,
|
|
58
|
+
finished, hydrated: start.hydrated === true, usageEvents,
|
|
59
|
+
reason: finished ? ev[t].reason ?? null : null, code: finished ? ev[t].code ?? null : null,
|
|
60
|
+
remediated: ev.some((e) => e.event === "PHASE_COMMIT" && e.phase === "remediate"),
|
|
61
|
+
startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
|
|
62
|
+
phases,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). */
|
|
67
|
+
export function readLocalRuns(dir = runsDir()) {
|
|
68
|
+
if (!existsSync(dir)) return [];
|
|
69
|
+
const runs = [];
|
|
70
|
+
for (const f of readdirSync(dir).sort()) {
|
|
71
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
72
|
+
try {
|
|
73
|
+
const m = /^(\d+)\.jsonl$/.exec(f);
|
|
74
|
+
const sum = summariseLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null);
|
|
75
|
+
if (sum) runs.push({ ...sum, mtimeTs: new Date(statSync(join(dir, f)).mtimeMs).toISOString() });
|
|
76
|
+
} catch { /* unreadable log */ }
|
|
77
|
+
}
|
|
78
|
+
return runs;
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** Rendering for `forgedock stats` (forge#3835): full markdown, compact table (review-pr-staging) and JSON. */
|
|
4
|
+
|
|
5
|
+
const NA = "n/a";
|
|
6
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
7
|
+
const k = (x) => (isNum(x) ? `${Math.round(x / 1000)}k` : NA);
|
|
8
|
+
const pct = (x) => (isNum(x) ? `${Math.round(x * 100)}%` : NA);
|
|
9
|
+
const usd = (x) => (isNum(x) ? `$${x.toFixed(2)}` : NA);
|
|
10
|
+
const f1 = (x) => (isNum(x) ? (Math.round(x * 10) / 10).toString() : NA);
|
|
11
|
+
const dur = (ms) => {
|
|
12
|
+
if (!isNum(ms)) return NA;
|
|
13
|
+
const s = ms / 1000;
|
|
14
|
+
if (s < 90) return `${Math.round(s)}s`;
|
|
15
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
16
|
+
return `${f1(s / 3600)}h`;
|
|
17
|
+
};
|
|
18
|
+
const signed = (x, fmt) => (isNum(x) ? `${x > 0 ? "+" : x < 0 ? "-" : ""}${fmt(Math.abs(x))}` : NA);
|
|
19
|
+
const dpp = (x) => signed(x, (v) => `${Math.round(v * 100)}pp`);
|
|
20
|
+
const flag = (g) => (g.lowN ? ` (low-n <${g.minN})` : "");
|
|
21
|
+
const table = (head, rows) => [`| ${head.join(" | ")} |`, `|${head.map(() => "---").join("|")}|`, ...rows.map((r) => `| ${r.join(" | ")} |`)];
|
|
22
|
+
|
|
23
|
+
export const renderJson = (report) => JSON.stringify(report, null, 2);
|
|
24
|
+
|
|
25
|
+
const label = (g) => `${g.version} / ${g.repo}`;
|
|
26
|
+
|
|
27
|
+
function sections(g) {
|
|
28
|
+
const out = [`## ${label(g)} n=${g.n}${flag(g)}`, ""];
|
|
29
|
+
const c = g.cost;
|
|
30
|
+
out.push("### Cost", ...table(["metric", "value"], [
|
|
31
|
+
["cache-read tokens per issue (mean / median)", `${k(c.cacheReadMean)} / ${k(c.cacheReadMedian)}`],
|
|
32
|
+
["output tokens per issue (mean)", k(c.outputMean)],
|
|
33
|
+
["per phase (mean)", Object.entries(c.perPhase).filter(([, v]) => isNum(v)).map(([p, v]) => `${p} ${k(v)}`).join(", ") || NA],
|
|
34
|
+
["cache-read tokens per merged issue", k(c.perMergedIssue)],
|
|
35
|
+
["USD per issue (n=" + c.usdN + ")", usd(c.usdMean)],
|
|
36
|
+
]), "");
|
|
37
|
+
const t = g.time;
|
|
38
|
+
const phaseRows = Object.entries(t.phases).map(([p, v]) => [p, String(v.n), dur(v.p50), dur(v.p90)]);
|
|
39
|
+
out.push("### Time", ...table(["phase", "n", "p50", "p90"], [...phaseRows, ["wall per issue", String(t.wall.n), dur(t.wall.p50), dur(t.wall.p90)]]), "");
|
|
40
|
+
const f = g.flow;
|
|
41
|
+
out.push("### Flow", ...table(["metric", "value"], [
|
|
42
|
+
["merged", pct(f.mergedPct)], ["needs-human", pct(f.needsHumanPct)], ["remediation rate", pct(f.remediationPct)],
|
|
43
|
+
["top outcome codes (n=" + f.codesN + ")", f.codes.slice(0, 5).map((x) => `${x.code} ${x.count} (${pct(x.pct)})`).join(", ") || NA],
|
|
44
|
+
]), "");
|
|
45
|
+
const r = g.funnel;
|
|
46
|
+
const sev = Object.entries(r.bySeverity).map(([s, d]) => [s, String(d.total), f1(d.perReview), String(d.ISSUE), String(d.NOTE), String(d.INPR_FIX)]);
|
|
47
|
+
out.push("### Review funnel", `${r.reviews} reviews, ${r.findings} findings (${f1(r.perReview)} per review). Validated issues: ${r.validated}, false positives: ${r.falsePositive}.`, "",
|
|
48
|
+
...(sev.length ? table(["severity", "findings", "per review", "ISSUE", "NOTE", "in-PR fix"], sev) : [`No finding records: ${NA}`]), "");
|
|
49
|
+
const q = g.gates;
|
|
50
|
+
const rate = (x) => (x.n ? `${pct(x.rate)} (${x.blocked}/${x.n})` : NA);
|
|
51
|
+
out.push("### Gates", ...table(["gate", "block rate"], [
|
|
52
|
+
["acceptance", rate(q.acceptance)], ["diff size (OVER)", rate(q.diffSize)], ["ci", rate(q.ci)], ["liveness", rate(q.liveness)],
|
|
53
|
+
]), `Malformed-check rate: ${pct(q.malformedRate)}. Diff size: COHESIVE ${q.diffSize.cohesive}, OVER ${q.diffSize.over}.`, "");
|
|
54
|
+
const i = g.issueFlow;
|
|
55
|
+
const kv = (o) => Object.entries(o).map(([a, b]) => `${a} ${b}`).join(", ") || NA;
|
|
56
|
+
out.push("### Issue flow", ...table(["metric", "value"], [
|
|
57
|
+
["created by source", kv(i.created)], ["closed by kind", kv(i.closed)],
|
|
58
|
+
["net flow (created - closed)", isNum(i.net) ? signed(i.net, String) : NA], ["findings per merged issue", f1(i.findingsPerMerge)],
|
|
59
|
+
]), "");
|
|
60
|
+
if (g.delta) {
|
|
61
|
+
const d = g.delta;
|
|
62
|
+
out.push(`Delta vs ${d.against}: cache-read/issue ${signed(d.cacheReadMean, k)}, merged ${dpp(d.mergedPct)}, needs-human ${dpp(d.needsHumanPct)}, remediation ${dpp(d.remediationPct)}, wall p50 ${signed(d.wallP50, dur)}`, "");
|
|
63
|
+
} else out.push(`Delta: ${NA} (no previous version)`, "");
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderMarkdown(report) {
|
|
68
|
+
const out = ["# ForgeDock stats", ""];
|
|
69
|
+
if (!report.groups.length) out.push("No data: no finished runs, telemetry records or issues were found.", "");
|
|
70
|
+
for (const g of report.groups) out.push(...sections(g));
|
|
71
|
+
const e = report.excluded;
|
|
72
|
+
out.push(`Finished runs only. Excluded: ${e.unfinished} unfinished, ${e.noUsage} without usage data (hydrated or empty). "~" = version estimated from release times; "unknown" = no version available. Groups with n < ${report.minN} are flagged low-n.`);
|
|
73
|
+
return out.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One row per (version, repo): the embedded table for the staging review summary. */
|
|
77
|
+
export function renderCompact(report) {
|
|
78
|
+
if (!report.groups.length) return "No stats data.";
|
|
79
|
+
const rows = report.groups.map((g) => [g.version, g.repo, String(g.n), k(g.cost.cacheReadMean), pct(g.flow.mergedPct), pct(g.flow.needsHumanPct),
|
|
80
|
+
pct(g.flow.remediationPct), dur(g.time.wall.p50), f1(g.funnel.perReview), pct(g.gates.acceptance.rate), g.delta ? signed(g.delta.cacheReadMean, k) : NA, g.lowN ? `low-n (<${g.minN})` : "ok"]);
|
|
81
|
+
return table(["Version", "Repo", "n", "Cache-read/issue", "Merged", "Needs-human", "Remediated", "Wall p50", "Findings/review", "Acceptance block", "Delta tokens", "Confidence"], rows).join("\n");
|
|
82
|
+
}
|