pi-gauntlet 5.10.2 → 5.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.12.0 - 2026-09-19
4
+
5
+ - New human-only `/skill:gauntlet-performance` and parse-only `gauntlet-performance` bin: the CLI digests committed telemetry records (current repo, plus `--dir <path>` repos; `--since <version>` narrows; `--json`) into per-run rows and per-version p50/max, and the skill turns the digest into one example-led recommendation, cornerstones, and a three-item menu (render on request, ticket via `shape-ticket`, drill-down). `gauntlet-telemetry-salvage` now stamps a record still `in_progress` with no ship phase as `status: shipped` + `shipped_at` at landing (`present`/`restored ... (marked shipped)`, `unfinished` under `--check`); `finishing-a-development-branch` and `gatekeep-pr` carry the new lines. The gh-37 record on this repo is backfilled to `shipped`. (#35)
6
+
7
+ ## v5.11.0 - 2026-09-18
8
+
9
+ - `checkoutOf` falls back to `jj root` when `git rev-parse` fails, so `plan_check`, Guard 2, and telemetry resolve the checkout inside a plain (non-colocated) jj workspace; colocated jj repos still resolve through git first. The settings loader goes through the same resolution via a new sync `checkoutOfSync`, and telemetry records bound to a jj workspace carry a one-time `record written, not committed: not a git checkout` warning instead of a failed `git commit` per checkpoint. (#38)
10
+
3
11
  ## v5.10.2 - 2026-09-18
4
12
 
5
13
  - The telemetry record is a deliverable: brainstorming, `finishing-a-development-branch`, and `gatekeep-pr` name it beside the spec, and a new shipped `gauntlet-telemetry-salvage` bin restores a record that a plan strip or a fix commit deleted (from the deleting commit's parent, as one `telemetry: ` commit, exit 0 for valid invocations, never pushes). Finishing strips the plan on the feature branch before the Option 1 squash; gatekeep-pr checks at assessment, restores after each fix wave, and restores as the first step of a merge course. CI pins the bin, its test, its tarball imports, and the rule's presence in the three skills.
package/README.md CHANGED
@@ -69,13 +69,13 @@ Everything between gate 1 and gate 2 - task breakdown, implementation, both revi
69
69
 
70
70
  pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
71
71
 
72
- - **18 skills** - the workflow logic. Thirteen activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Five more are explicit-invocation-only (`disable-model-invocation: true`): `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, then a deterministic authorship-aware menu with stable finding IDs (P#/L#/C#/F#) and numbered pre-composed courses (fixes execute as a single parallel-safe wave: one gate run, one re-review, one push); nothing mutates (fixes, pushes, reviews, merges) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a pi-cohort `/handoff` brief (file or pasted) or a bare worktree that already holds a spec, restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), never creates a worktree, and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief>] [<worktree>]`.
72
+ - **19 skills** - the workflow logic. Thirteen activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Six more are explicit-invocation-only (`disable-model-invocation: true`): `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, then a deterministic authorship-aware menu with stable finding IDs (P#/L#/C#/F#) and numbered pre-composed courses (fixes execute as a single parallel-safe wave: one gate run, one re-review, one push); nothing mutates (fixes, pushes, reviews, merges) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a pi-cohort `/handoff` brief (file or pasted) or a bare worktree that already holds a spec, restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), never creates a worktree, and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief>] [<worktree>]`.
73
73
  - **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
74
- - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one committed YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - finishing and the PR gate restore a stripped record with `gauntlet-telemetry-salvage` - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
74
+ - **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one committed YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - finishing and the PR gate restore a stripped record, and stamp a record left `in_progress` with no ship phase as `shipped`, with `gauntlet-telemetry-salvage` - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
75
75
 
76
76
  pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
77
77
 
78
- **Worktree contract.** pi runs in the primary checkout; the work happens in `.worktrees/<branch>`. Skills never `cd` there - the worktree path is a value from the `using-git-worktrees` report (or the handoff brief on `gauntlet-resume`), carried as dispatch `cwd: "<path>"`, as `git -C <path>`, or as `(cd "<path>" && <cmd>)` for other cwd-bound commands; the runtime extensions derive the checkout from the artifact path they act on. Requires git >= 2.31.
78
+ **Worktree contract.** pi runs in the primary checkout; the work happens in `.worktrees/<branch>`. Skills never `cd` there - the worktree path is a value from the `using-git-worktrees` report (or the handoff brief on `gauntlet-resume`), carried as dispatch `cwd: "<path>"`, as `git -C <path>`, or as `(cd "<path>" && <cmd>)` for other cwd-bound commands; the runtime extensions derive the checkout from the artifact path they act on. Requires git >= 2.31; a plain (non-colocated) jj workspace has no `.git`, so the resolver falls back to `jj root` there, reading primary-vs-workspace from `.jj/repo` (a directory in the primary checkout, a pointer file in added workspaces).
79
79
 
80
80
  ## Key concepts
81
81
 
@@ -122,6 +122,10 @@ Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md
122
122
 
123
123
  `gauntlet-spec-index` provides lexical search across `doc/specs/*.md` at the repository root and one service level down. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--limit N]`; it requires Node >=24.15.0, refreshes its FTS5 index on every query, and prints tab-separated `score`, `path`, `service`, `title`, `status`, `shipped_at`, `files`, and `snippet` columns. The per-worktree cache lives at `.pi/gauntlet/index.sqlite`, and its first creation adds `/.pi/gauntlet/index.sqlite*` to Git's `info/exclude` so the database and SQLite sidecars stay out of `git status`.
124
124
 
125
+ ## Performance digest
126
+
127
+ `gauntlet-performance` digests telemetry records without judging them: `node <pi-gauntlet-package>/bin/gauntlet-performance.mjs [--dir <repo root or telemetry dir>]... [--since <version>] [--json]` prints a `corpus:` line, one row per run (`run_id`, repo, spec, version, status - `shipped*` when the record has no ship phase - wall, per-phase minutes, tokens, cost, models, dispatches, `fix_round_grants`, reopens, conformance loops, reviewer findings, council dispatches), a `by version` block of p50/max over shipped, non-truncated runs, and `skipped:` lines for files it could not use. Default corpus is the current repo's telemetry dir; each `--dir` adds a repo root (its own `telemetry.dir` setting is honoured) or a bare telemetry dir. `--since` compares versions numerically. Exit 0 always except usage errors. `/skill:gauntlet-performance` is the reasoning layer on top.
128
+
125
129
  For local development against a checkout instead of npm:
126
130
 
127
131
  ```bash
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ // Digest committed gauntlet telemetry records: one row per run plus p50/max per pi-gauntlet
3
+ // version. Parse and aggregate only; the gauntlet-performance skill reasons over the output.
4
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, join, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import process from "node:process";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
11
+
12
+ const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
13
+ const TOKEN_KEYS = ["input", "output", "cache_read", "cache_write"];
14
+ const RUN_HEADER = ["run_id", "repo", "spec", "version", "status", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council"];
15
+ const VERSION_HEADER = ["version", "n", "shipped", "truncated", "wall p50/max", "tokens p50/max", "cost p50/max", "disp p50", "grants p50/max", "reopens p50/max", "loops p50/max", "findings p50 b/M/m", "models"];
16
+
17
+ const usage = () => {
18
+ process.stderr.write("usage: gauntlet-performance [--dir <repo root or telemetry dir>]... [--since <version>] [--json]\n");
19
+ process.exit(1);
20
+ };
21
+
22
+ const semver = (s) => {
23
+ const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(typeof s === "string" ? s.trim() : "");
24
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
25
+ };
26
+ const cmpSemver = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
27
+
28
+ function parseArgs(argv) {
29
+ const opts = { dirs: [], since: undefined, json: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dirs.push(argv[++i]);
33
+ else if (a === "--since" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.since = argv[++i];
34
+ else if (a === "--json") opts.json = true;
35
+ else usage();
36
+ }
37
+ if (opts.since !== undefined && !semver(opts.since)) usage();
38
+ return opts;
39
+ }
40
+
41
+ function readLayer(file) {
42
+ if (!existsSync(file)) return {};
43
+ try {
44
+ return JSON.parse(readFileSync(file, "utf8"));
45
+ } catch (e) {
46
+ process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer\n`);
47
+ return {};
48
+ }
49
+ }
50
+
51
+ // Same two layers the recorder and salvage read.
52
+ function telemetryDirOf(root) {
53
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
54
+ const preset = readLayer(join(agentDir, "settings.json"));
55
+ const repo = readLayer(join(root, ".pi", "settings.json"));
56
+ const t = resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
57
+ if (t.warning) process.stderr.write(`warning: ${t.warning}\n`);
58
+ return join(root, t.dir);
59
+ }
60
+
61
+ const gitToplevel = (cwd) => {
62
+ const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 10_000 });
63
+ return r.status === 0 ? r.stdout.trim() : undefined;
64
+ };
65
+
66
+ // A git toplevel contributes its resolved telemetry dir; anything else is a telemetry dir itself.
67
+ function corpusFor(path) {
68
+ const label = basename(resolve(path));
69
+ if (!existsSync(path)) return { label, skip: "not found" };
70
+ const abs = realpathSync(path);
71
+ const top = gitToplevel(abs);
72
+ if (top !== undefined && realpathSync(top) === abs) {
73
+ const dir = telemetryDirOf(abs);
74
+ return existsSync(dir) ? { label, dir } : { label, skip: "no telemetry dir" };
75
+ }
76
+ if (!statSync(abs).isDirectory()) return { label, skip: "not a directory" };
77
+ return { label, dir: abs };
78
+ }
79
+
80
+ function* yamlFiles(dir) {
81
+ if (!existsSync(dir)) return;
82
+ for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
83
+ const p = join(dir, e.name);
84
+ if (e.isDirectory()) yield* yamlFiles(p);
85
+ else if (e.isFile() && e.name.endsWith(".yaml")) yield p;
86
+ }
87
+ }
88
+
89
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
90
+ const str = (v) => (typeof v === "string" ? v : null);
91
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
92
+ const sumOrNull = (vals) => {
93
+ const xs = vals.filter((v) => v !== null);
94
+ return xs.length ? xs.reduce((a, b) => a + b, 0) : null;
95
+ };
96
+
97
+ function loadRecord(file, label) {
98
+ let doc;
99
+ try {
100
+ doc = parseYaml(readFileSync(file, "utf8"));
101
+ } catch {
102
+ return { skip: "unparseable" };
103
+ }
104
+ const d = obj(doc);
105
+ if (!d) return { skip: "unparseable" };
106
+ if (d.schema !== 1) return { skip: `schema ${d.schema === undefined ? "missing" : String(d.schema)}` };
107
+ if (typeof d.spec !== "string" || typeof d.run_id !== "string") return { skip: "not a record" };
108
+ const derived = obj(d.derived) ?? {};
109
+ const phases = obj(derived.phases) ?? {};
110
+ const ph = (p) => obj(phases[p]);
111
+ const tok = (p) => obj(ph(p)?.tokens);
112
+ const personas = obj(derived.personas) ?? {};
113
+ const reviews = obj(derived.reviews);
114
+ const gates = obj(derived.gates) ?? {};
115
+ const version = str(obj(d.versions)?.["pi-gauntlet"]);
116
+ const reviewEntries = reviews === null ? null : Object.values(reviews);
117
+ const findings = reviewEntries === null
118
+ ? null
119
+ : reviewEntries.length === 0
120
+ ? { blocker: 0, major: 0, minor: 0 }
121
+ : Object.fromEntries(["blocker", "major", "minor"].map((severity) => {
122
+ const counters = reviewEntries.map((r) => {
123
+ const review = obj(r);
124
+ if (review === null) return null;
125
+ if (!Object.hasOwn(review, "findings")) return 0;
126
+ const reviewFindings = obj(review.findings);
127
+ if (reviewFindings === null) return null;
128
+ return Object.hasOwn(reviewFindings, severity) ? num(reviewFindings[severity]) : 0;
129
+ });
130
+ return [severity, counters.includes(null) ? null : counters.reduce((a, b) => a + b, 0)];
131
+ }));
132
+ const truncated = ph("ship") === null;
133
+ return {
134
+ row: {
135
+ run_id: d.run_id,
136
+ repo: label,
137
+ spec: basename(file, ".yaml"),
138
+ version: semver(version) ? version.trim() : "unknown",
139
+ status: str(d.status),
140
+ created_at: str(d.created_at),
141
+ truncated,
142
+ wall_s: truncated ? null : num(derived.duration_s),
143
+ phase_min: Object.fromEntries(PHASES.map((p) => {
144
+ const s = num(ph(p)?.duration_s);
145
+ return [p, s === null ? null : s / 60];
146
+ })),
147
+ tokens: sumOrNull(PHASES.flatMap((p) => TOKEN_KEYS.map((k) => num(tok(p)?.[k])))),
148
+ cost: sumOrNull(PHASES.map((p) => num(tok(p)?.cost))),
149
+ models: [...new Set(PHASES.map((p) => str(ph(p)?.model)).filter((m) => m !== null))].sort(),
150
+ dispatches: sumOrNull(Object.values(personas).map((p) => num(obj(p)?.dispatches))),
151
+ grants: num(gates.fix_round_grants),
152
+ reopens: num(gates.task_reopens),
153
+ spec_rounds: num(gates.spec_rounds),
154
+ plan_rounds: num(gates.plan_rounds),
155
+ loops: num(derived.conformance_loops),
156
+ open_gaps: num(derived.conformance_open_gaps),
157
+ findings,
158
+ council: num(obj(personas["spec-council-member"])?.dispatches),
159
+ ship_option: str(gates.ship_option),
160
+ tests: str(obj(derived.tests)?.result),
161
+ },
162
+ };
163
+ }
164
+
165
+ const p50 = (xs) => {
166
+ const s = [...xs].sort((a, b) => a - b);
167
+ const m = s.length >> 1;
168
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
169
+ };
170
+ const stat = (rows, pick) => {
171
+ const xs = rows.map(pick).filter((v) => v !== null && v !== undefined);
172
+ return xs.length ? { p50: p50(xs), max: Math.max(...xs) } : { p50: null, max: null };
173
+ };
174
+ const versionOrder = (a, b) => {
175
+ const x = semver(a), y = semver(b);
176
+ return x && y ? cmpSemver(x, y) : x ? -1 : y ? 1 : 0;
177
+ };
178
+
179
+ function aggregate(rows) {
180
+ const groups = new Map();
181
+ for (const r of rows) {
182
+ if (!groups.has(r.version)) groups.set(r.version, []);
183
+ groups.get(r.version).push(r);
184
+ }
185
+ return [...groups.entries()].sort(([a], [b]) => versionOrder(a, b)).map(([version, all]) => {
186
+ const shipped = all.filter((r) => r.status === "shipped" && !r.truncated);
187
+ const models = {};
188
+ for (const r of shipped) for (const m of r.models) models[m] = (models[m] ?? 0) + 1;
189
+ return {
190
+ version,
191
+ n: all.length,
192
+ shipped: shipped.length,
193
+ truncated: all.filter((r) => r.truncated).length,
194
+ wall_s: stat(shipped, (r) => r.wall_s),
195
+ tokens: stat(shipped, (r) => r.tokens),
196
+ cost: stat(shipped, (r) => r.cost),
197
+ dispatches: { p50: stat(shipped, (r) => r.dispatches).p50 },
198
+ grants: stat(shipped, (r) => r.grants),
199
+ reopens: stat(shipped, (r) => r.reopens),
200
+ loops: stat(shipped, (r) => r.loops),
201
+ findings: {
202
+ blocker: { p50: stat(shipped, (r) => r.findings?.blocker ?? null).p50 },
203
+ major: { p50: stat(shipped, (r) => r.findings?.major ?? null).p50 },
204
+ minor: { p50: stat(shipped, (r) => r.findings?.minor ?? null).p50 },
205
+ },
206
+ models,
207
+ };
208
+ });
209
+ }
210
+
211
+ const dash = (v) => (v === null || v === undefined ? "-" : String(v));
212
+ const fmtMin = (s) => (s === null ? "-" : `${Math.round(s / 60)}m`);
213
+ const fmtCount = (n) => (n === null ? "-" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
214
+ const fmtCost = (c) => (c === null ? "-" : c.toFixed(2));
215
+ const pair = (s, f) => `${f(s.p50)}/${f(s.max)}`;
216
+ const findingsCell = (f) => (f === null ? "-" : `${dash(f.blocker)}/${dash(f.major)}/${dash(f.minor)}`);
217
+ const table = (header, rows) => {
218
+ const w = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
219
+ return [header, ...rows].map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd()).join("\n");
220
+ };
221
+ const runRow = (r) => [
222
+ r.run_id.slice(0, 8), r.repo, r.spec, r.version, `${dash(r.status)}${r.truncated ? "*" : ""}`, fmtMin(r.wall_s),
223
+ PHASES.map((p) => (r.phase_min[p] === null ? "-" : String(Math.round(r.phase_min[p])))).join("/"),
224
+ fmtCount(r.tokens), fmtCost(r.cost), r.models.join(",") || "-", dash(r.dispatches), dash(r.grants), dash(r.reopens), dash(r.loops),
225
+ findingsCell(r.findings), dash(r.council),
226
+ ];
227
+ const versionRow = (g) => [
228
+ g.version, String(g.n), String(g.shipped), String(g.truncated), pair(g.wall_s, fmtMin), pair(g.tokens, fmtCount), pair(g.cost, fmtCost),
229
+ dash(g.dispatches.p50), pair(g.grants, dash), pair(g.reopens, dash), pair(g.loops, dash),
230
+ `${dash(g.findings.blocker.p50)}/${dash(g.findings.major.p50)}/${dash(g.findings.minor.p50)}`,
231
+ Object.entries(g.models).map(([m, n]) => `${m}:${n}`).join(",") || "-",
232
+ ];
233
+
234
+ function main() {
235
+ const opts = parseArgs(process.argv.slice(2));
236
+ const corpora = [];
237
+ const skipped = [];
238
+ const top = gitToplevel(process.cwd());
239
+ if (top === undefined) process.stderr.write(`not a git checkout: ${process.cwd()}\n`);
240
+ else corpora.push({ label: basename(top), dir: telemetryDirOf(top) });
241
+ for (const p of opts.dirs) {
242
+ const c = corpusFor(p);
243
+ if (c.skip) skipped.push({ file: p, reason: c.skip });
244
+ else corpora.push(c);
245
+ }
246
+ const since = opts.since === undefined ? undefined : semver(opts.since);
247
+ const seen = new Set();
248
+ const corpus = {};
249
+ const rows = [];
250
+ for (const c of corpora) {
251
+ corpus[c.label] = corpus[c.label] ?? 0;
252
+ for (const file of yamlFiles(c.dir)) {
253
+ const res = loadRecord(file, c.label);
254
+ if (res.skip) { skipped.push({ file, reason: res.skip }); continue; }
255
+ if (seen.has(res.row.run_id)) { skipped.push({ file, reason: "duplicate run_id" }); continue; }
256
+ seen.add(res.row.run_id);
257
+ const v = semver(res.row.version);
258
+ if (since && (!v || cmpSemver(v, since) < 0)) continue;
259
+ corpus[c.label]++;
260
+ rows.push(res.row);
261
+ }
262
+ }
263
+ rows.sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.run_id.localeCompare(b.run_id));
264
+ const byVersion = rows.length ? aggregate(rows) : [];
265
+ if (opts.json) {
266
+ console.log(JSON.stringify({ corpus, since: opts.since ?? null, runs: rows, by_version: byVersion, skipped }, null, 2));
267
+ return;
268
+ }
269
+ console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since === undefined ? "" : ` since: ${opts.since}`}`);
270
+ if (rows.length === 0) {
271
+ console.log(`no records found in ${corpora.map((c) => c.dir).join(", ") || process.cwd()}`);
272
+ } else {
273
+ console.log(`runs (${rows.length})`);
274
+ console.log(table(RUN_HEADER, rows.map(runRow)));
275
+ console.log("");
276
+ console.log("by version");
277
+ console.log(table(VERSION_HEADER, byVersion.map(versionRow)));
278
+ }
279
+ if (rows.length && skipped.length) console.log("");
280
+ if (rows.length) for (const s of skipped) console.log(`skipped: ${s.file}: ${s.reason}`);
281
+ }
282
+
283
+ main();