pi-gauntlet 5.11.0 → 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 +4 -0
- package/README.md +6 -2
- package/bin/gauntlet-performance.mjs +283 -0
- package/bin/gauntlet-performance.test.mjs +350 -0
- package/bin/gauntlet-telemetry-salvage.mjs +52 -10
- package/bin/gauntlet-telemetry-salvage.test.mjs +104 -2
- package/package.json +3 -2
- package/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/skills/gatekeep-pr/SKILL.md +3 -2
- package/skills/gauntlet-performance/SKILL.md +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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
|
+
|
|
3
7
|
## v5.11.0 - 2026-09-18
|
|
4
8
|
|
|
5
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)
|
package/README.md
CHANGED
|
@@ -69,9 +69,9 @@ 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
|
-
- **
|
|
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
|
|
|
@@ -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();
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-performance.mjs");
|
|
10
|
+
const TDIR = ".pi/gauntlet/telemetry/doc/specs";
|
|
11
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gp-empty-agent-"));
|
|
12
|
+
process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
const write = (root, rel, text) => {
|
|
15
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
16
|
+
writeFileSync(join(root, rel), text);
|
|
17
|
+
};
|
|
18
|
+
const gitRepo = (prefix = "gp-") => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
20
|
+
spawnSync("git", ["init", "-q"], { cwd: root });
|
|
21
|
+
return root;
|
|
22
|
+
};
|
|
23
|
+
const invoke = (cwd, args = [], env = {}) => {
|
|
24
|
+
const r = spawnSync(process.execPath, [CLI, ...args], {
|
|
25
|
+
cwd, encoding: "utf8", env: { ...process.env, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env },
|
|
26
|
+
});
|
|
27
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
|
|
28
|
+
};
|
|
29
|
+
const json = (cwd, args = []) => JSON.parse(invoke(cwd, ["--json", ...args]).stdout);
|
|
30
|
+
const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
|
|
31
|
+
|
|
32
|
+
const phase = (extra) => `{ started_at: 2026-09-01T00:00:00Z, completed_at: 2026-09-01T00:10:00Z, duration_s: 600, ${extra} }`;
|
|
33
|
+
const tokens = (input, output, cacheRead, cacheWrite, cost) =>
|
|
34
|
+
`tokens: { input: ${input}, output: ${output}, cache_read: ${cacheRead}, cache_write: ${cacheWrite}, cost: ${cost} }`;
|
|
35
|
+
|
|
36
|
+
// Complete run: cache-dominated tokens, an unphased block that must be ignored, two models.
|
|
37
|
+
const COMPLETE = `schema: 1
|
|
38
|
+
spec: doc/specs/a.md
|
|
39
|
+
run_id: aaaaaaaa-0000-0000-0000-000000000001
|
|
40
|
+
status: shipped
|
|
41
|
+
created_at: 2026-09-01T00:00:00Z
|
|
42
|
+
shipped_at: 2026-09-01T01:00:00Z
|
|
43
|
+
versions: { pi-gauntlet: 5.9.1 }
|
|
44
|
+
derived:
|
|
45
|
+
duration_s: 3600
|
|
46
|
+
phases:
|
|
47
|
+
brainstorm: ${phase(`model: m/alpha, ${tokens(100, 10, 5000, 200, 1.5)}`)}
|
|
48
|
+
plan: ${phase(`model: m/alpha, ${tokens(50, 5, 1000, 100, 0.5)}`)}
|
|
49
|
+
implement: ${phase(`model: m/beta, ${tokens(10, 1, 100, 10, 0.25)}`)}
|
|
50
|
+
verify: ${phase(`model: m/beta`)}
|
|
51
|
+
ship: ${phase(`model: m/beta`)}
|
|
52
|
+
unphased: { ${tokens(99999, 99999, 99999, 99999, 999)} }
|
|
53
|
+
personas:
|
|
54
|
+
worker: { dispatches: 3, models: [m/beta] }
|
|
55
|
+
spec-council-member: { dispatches: 4, models: [m/alpha] }
|
|
56
|
+
reviews:
|
|
57
|
+
spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }
|
|
58
|
+
code-reviewer: { dispatches: 1, nonzero_exit: 0 }
|
|
59
|
+
conformance_loops: 2
|
|
60
|
+
conformance_open_gaps: 0
|
|
61
|
+
gates: { spec_rounds: 1, plan_rounds: 1, fix_round_grants: 1, task_reopens: 0, ship_option: squash }
|
|
62
|
+
tests: { command: npm test, result: pass }
|
|
63
|
+
accumulators: {}
|
|
64
|
+
events: []
|
|
65
|
+
`;
|
|
66
|
+
// Truncated run: stamped shipped by salvage, stale duration_s, no ship phase, zero gates.
|
|
67
|
+
const TRUNCATED = `schema: 1
|
|
68
|
+
spec: doc/specs/b.md
|
|
69
|
+
run_id: bbbbbbbb-0000-0000-0000-000000000002
|
|
70
|
+
status: shipped
|
|
71
|
+
created_at: 2026-09-02T00:00:00Z
|
|
72
|
+
shipped_at: 2026-09-02T02:00:00Z
|
|
73
|
+
versions: { pi-gauntlet: 5.9.1 }
|
|
74
|
+
derived:
|
|
75
|
+
duration_s: 2501
|
|
76
|
+
phases:
|
|
77
|
+
brainstorm: ${phase(`model: m/alpha, ${tokens(1, 1, 1, 1, 0.01)}`)}
|
|
78
|
+
plan: { started_at: 2026-09-02T00:10:00Z, model: m/alpha }
|
|
79
|
+
personas: {}
|
|
80
|
+
conformance_loops: 0
|
|
81
|
+
gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }
|
|
82
|
+
accumulators: {}
|
|
83
|
+
events: []
|
|
84
|
+
`;
|
|
85
|
+
const IN_PROGRESS = `schema: 1
|
|
86
|
+
spec: doc/specs/c.md
|
|
87
|
+
run_id: cccccccc-0000-0000-0000-000000000003
|
|
88
|
+
status: in_progress
|
|
89
|
+
created_at: 2026-09-03T00:00:00Z
|
|
90
|
+
versions: { pi-gauntlet: 5.11.0 }
|
|
91
|
+
derived:
|
|
92
|
+
duration_s: 10
|
|
93
|
+
phases: { brainstorm: { started_at: 2026-09-03T00:00:00Z } }
|
|
94
|
+
personas: {}
|
|
95
|
+
conformance_loops: 0
|
|
96
|
+
gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }
|
|
97
|
+
accumulators: {}
|
|
98
|
+
events: []
|
|
99
|
+
`;
|
|
100
|
+
// Second complete run on 5.9.1 with different numbers: makes p50 over an even count.
|
|
101
|
+
const COMPLETE_2 = COMPLETE
|
|
102
|
+
.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: dddddddd-0000-0000-0000-000000000004")
|
|
103
|
+
.replace("spec: doc/specs/a.md", "spec: doc/specs/d.md")
|
|
104
|
+
.replace("created_at: 2026-09-01T00:00:00Z", "created_at: 2026-09-04T00:00:00Z")
|
|
105
|
+
.replace("duration_s: 3600", "duration_s: 7200")
|
|
106
|
+
.replace("fix_round_grants: 1", "fix_round_grants: 3")
|
|
107
|
+
.replace(/reviews:[\s\S]*?conformance_loops/, "conformance_loops");
|
|
108
|
+
const OLD_VERSION = COMPLETE
|
|
109
|
+
.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: eeeeeeee-0000-0000-0000-000000000005")
|
|
110
|
+
.replace("spec: doc/specs/a.md", "spec: doc/specs/e.md")
|
|
111
|
+
.replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.10.0");
|
|
112
|
+
const BAD_GATES = IN_PROGRESS
|
|
113
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: ffffffff-0000-0000-0000-000000000006")
|
|
114
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/f.md")
|
|
115
|
+
.replace(/gates: \{[^}]*\}/, 'gates: "nope"');
|
|
116
|
+
const EMPTY_REVIEWS = IN_PROGRESS
|
|
117
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: gggggggg-0000-0000-0000-000000000007")
|
|
118
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/g.md")
|
|
119
|
+
.replace("created_at: 2026-09-03T00:00:00Z", "created_at: 2026-09-05T00:00:00Z")
|
|
120
|
+
.replace(" conformance_loops: 0", " reviews: {}\n conformance_loops: 0");
|
|
121
|
+
const REVIEW_WITHOUT_FINDINGS = IN_PROGRESS
|
|
122
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: hhhhhhhh-0000-0000-0000-000000000008")
|
|
123
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/h.md")
|
|
124
|
+
.replace(" conformance_loops: 0", " reviews: { code-reviewer: { dispatches: 1, nonzero_exit: 0 } }\n conformance_loops: 0");
|
|
125
|
+
|
|
126
|
+
const corpus = (root) => {
|
|
127
|
+
write(root, `${TDIR}/a.yaml`, COMPLETE);
|
|
128
|
+
write(root, `${TDIR}/b.yaml`, TRUNCATED);
|
|
129
|
+
write(root, `${TDIR}/c.yaml`, IN_PROGRESS);
|
|
130
|
+
write(root, `${TDIR}/d.yaml`, COMPLETE_2);
|
|
131
|
+
write(root, `${TDIR}/e.yaml`, OLD_VERSION);
|
|
132
|
+
write(root, `${TDIR}/f.yaml`, BAD_GATES);
|
|
133
|
+
write(root, `${TDIR}/present-empty-reviews.yaml`, EMPTY_REVIEWS);
|
|
134
|
+
write(root, `${TDIR}/broken.yaml`, "schema: 1\nspec: [\n");
|
|
135
|
+
write(root, `${TDIR}/v2.yaml`, "schema: 2\nspec: doc/specs/z.md\nrun_id: z\n");
|
|
136
|
+
write(root, `${TDIR}/norec.yaml`, "schema: 1\nspec: doc/specs/z.md\n");
|
|
137
|
+
return root;
|
|
138
|
+
};
|
|
139
|
+
const byId = (runs, prefix) => runs.find((r) => r.run_id.startsWith(prefix));
|
|
140
|
+
|
|
141
|
+
test("per-run fields: typed reads, unphased excluded, truncated wall null, findings null vs 0/0/0", (t) => {
|
|
142
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
143
|
+
const d = json(root);
|
|
144
|
+
assert.deepEqual(d.corpus, { [root.split("/").pop()]: 7 });
|
|
145
|
+
assert.equal(d.since, null);
|
|
146
|
+
const a = byId(d.runs, "aaaaaaaa");
|
|
147
|
+
assert.equal(a.repo, root.split("/").pop());
|
|
148
|
+
assert.equal(a.spec, "a");
|
|
149
|
+
assert.equal(a.version, "5.9.1");
|
|
150
|
+
assert.equal(a.status, "shipped");
|
|
151
|
+
assert.equal(a.truncated, false);
|
|
152
|
+
assert.equal(a.wall_s, 3600);
|
|
153
|
+
assert.deepEqual(a.phase_min, { brainstorm: 10, plan: 10, implement: 10, verify: 10, ship: 10 });
|
|
154
|
+
assert.equal(a.tokens, 100 + 10 + 5000 + 200 + 50 + 5 + 1000 + 100 + 10 + 1 + 100 + 10);
|
|
155
|
+
assert.equal(a.cost, 2.25);
|
|
156
|
+
assert.deepEqual(a.models, ["m/alpha", "m/beta"]);
|
|
157
|
+
assert.equal(a.dispatches, 7);
|
|
158
|
+
assert.equal(a.grants, 1);
|
|
159
|
+
assert.equal(a.reopens, 0);
|
|
160
|
+
assert.equal(a.spec_rounds, 1);
|
|
161
|
+
assert.equal(a.plan_rounds, 1);
|
|
162
|
+
assert.equal(a.loops, 2);
|
|
163
|
+
assert.equal(a.open_gaps, 0);
|
|
164
|
+
assert.deepEqual(a.findings, { blocker: 1, major: 2, minor: 3 });
|
|
165
|
+
assert.equal(a.council, 4);
|
|
166
|
+
assert.equal(a.ship_option, "squash");
|
|
167
|
+
assert.equal(a.tests, "pass");
|
|
168
|
+
const b = byId(d.runs, "bbbbbbbb");
|
|
169
|
+
assert.equal(b.truncated, true);
|
|
170
|
+
assert.equal(b.wall_s, null);
|
|
171
|
+
assert.deepEqual(b.phase_min, { brainstorm: 10, plan: null, implement: null, verify: null, ship: null });
|
|
172
|
+
assert.equal(b.tokens, 4);
|
|
173
|
+
assert.equal(b.dispatches, null);
|
|
174
|
+
assert.equal(b.findings, null);
|
|
175
|
+
assert.equal(b.council, null);
|
|
176
|
+
assert.equal(b.ship_option, null);
|
|
177
|
+
assert.equal(b.tests, null);
|
|
178
|
+
const dd = byId(d.runs, "dddddddd");
|
|
179
|
+
assert.equal(dd.findings, null);
|
|
180
|
+
const f = byId(d.runs, "ffffffff");
|
|
181
|
+
assert.equal(f.grants, null);
|
|
182
|
+
assert.equal(f.status, "in_progress");
|
|
183
|
+
const emptyReviews = byId(d.runs, "gggggggg");
|
|
184
|
+
assert.deepEqual(emptyReviews.findings, { blocker: 0, major: 0, minor: 0 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("review entries without findings contribute zero counters", (t) => {
|
|
188
|
+
const root = gitRepo(); cleanup(t, root);
|
|
189
|
+
write(root, `${TDIR}/review-without-findings.yaml`, REVIEW_WITHOUT_FINDINGS);
|
|
190
|
+
const d = json(root);
|
|
191
|
+
assert.deepEqual(d.runs[0].findings, { blocker: 0, major: 0, minor: 0 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("malformed findings counters remain null in JSON and render as dashes in text", (t) => {
|
|
195
|
+
const root = gitRepo(); cleanup(t, root);
|
|
196
|
+
const malformed = COMPLETE.replace(
|
|
197
|
+
"findings: { blocker: 1, major: 2, minor: 3 }",
|
|
198
|
+
'findings: { blocker: "bad", major: 2, minor: 3 }',
|
|
199
|
+
);
|
|
200
|
+
write(root, `${TDIR}/malformed-findings.yaml`, malformed);
|
|
201
|
+
const d = json(root);
|
|
202
|
+
assert.deepEqual(d.runs[0].findings, { blocker: null, major: 2, minor: 3 });
|
|
203
|
+
const row = invoke(root).lines.find((line) => line.startsWith("aaaaaaaa"));
|
|
204
|
+
assert.match(row, /\s-\/2\/3\s+4$/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("malformed reviewer entries and findings objects contribute null counters", (t) => {
|
|
208
|
+
const root = gitRepo(); cleanup(t, root);
|
|
209
|
+
const malformedFindings = COMPLETE.replace(
|
|
210
|
+
"findings: { blocker: 1, major: 2, minor: 3 }",
|
|
211
|
+
'findings: "bad"',
|
|
212
|
+
);
|
|
213
|
+
const malformedReviewer = COMPLETE.replace(
|
|
214
|
+
"spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }",
|
|
215
|
+
'spec-reviewer: "bad"',
|
|
216
|
+
);
|
|
217
|
+
write(root, `${TDIR}/malformed-findings-object.yaml`, malformedFindings);
|
|
218
|
+
write(root, `${TDIR}/malformed-reviewer.yaml`, malformedReviewer.replace(
|
|
219
|
+
"run_id: aaaaaaaa-0000-0000-0000-000000000001",
|
|
220
|
+
"run_id: 22222222-0000-0000-0000-000000000012",
|
|
221
|
+
));
|
|
222
|
+
const d = json(root);
|
|
223
|
+
assert.deepEqual(byId(d.runs, "aaaaaaaa").findings, { blocker: null, major: null, minor: null });
|
|
224
|
+
assert.deepEqual(byId(d.runs, "22222222").findings, { blocker: null, major: null, minor: null });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("phase minutes remain unrounded in JSON", (t) => {
|
|
228
|
+
const root = gitRepo(); cleanup(t, root);
|
|
229
|
+
write(root, `${TDIR}/phase-61.yaml`, COMPLETE.replace("duration_s: 600", "duration_s: 61"));
|
|
230
|
+
const d = json(root);
|
|
231
|
+
assert.equal(d.runs[0].phase_min.brainstorm, 61 / 60);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("aggregates: only shipped non-truncated rows feed p50/max; even-count p50 is the mean; models tally; unknown group", (t) => {
|
|
235
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
236
|
+
write(root, `${TDIR}/g.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 99999999-0000-0000-0000-000000000009").replace("versions: { pi-gauntlet: 5.9.1 }", "versions: {}"));
|
|
237
|
+
const d = json(root);
|
|
238
|
+
assert.deepEqual(d.by_version.map((g) => g.version), ["5.9.1", "5.10.0", "5.11.0", "unknown"]);
|
|
239
|
+
const v591 = d.by_version[0];
|
|
240
|
+
assert.equal(v591.n, 3);
|
|
241
|
+
assert.equal(v591.shipped, 2);
|
|
242
|
+
assert.equal(v591.truncated, 1);
|
|
243
|
+
assert.deepEqual(v591.wall_s, { p50: 5400, max: 7200 });
|
|
244
|
+
assert.deepEqual(v591.grants, { p50: 2, max: 3 });
|
|
245
|
+
assert.deepEqual(v591.dispatches, { p50: 7 });
|
|
246
|
+
assert.deepEqual(v591.findings, { blocker: { p50: 1 }, major: { p50: 2 }, minor: { p50: 3 } });
|
|
247
|
+
assert.deepEqual(v591.models, { "m/alpha": 2, "m/beta": 2 });
|
|
248
|
+
const v5110 = d.by_version[2];
|
|
249
|
+
assert.equal(v5110.n, 3);
|
|
250
|
+
assert.equal(v5110.shipped, 0);
|
|
251
|
+
assert.deepEqual(v5110.wall_s, { p50: null, max: null });
|
|
252
|
+
assert.deepEqual(v5110.models, {});
|
|
253
|
+
assert.equal(d.by_version[3].shipped, 1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("row order is created_at then run_id; skipped lists unparseable, schema, not a record", (t) => {
|
|
257
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
258
|
+
const d = json(root);
|
|
259
|
+
assert.deepEqual(d.runs.map((r) => r.run_id[0]), ["a", "e", "b", "c", "f", "d", "g"]);
|
|
260
|
+
const reasons = Object.fromEntries(d.skipped.map((s) => [s.file.split("/").pop(), s.reason]));
|
|
261
|
+
assert.equal(reasons["broken.yaml"], "unparseable");
|
|
262
|
+
assert.equal(reasons["v2.yaml"], "schema 2");
|
|
263
|
+
assert.equal(reasons["norec.yaml"], "not a record");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("text output: header, shipped* marker, - cells, by version block, skipped lines", (t) => {
|
|
267
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
268
|
+
const r = invoke(root);
|
|
269
|
+
assert.equal(r.status, 0, r.stderr);
|
|
270
|
+
assert.equal(r.lines[0], `corpus: ${root.split("/").pop()}=7`);
|
|
271
|
+
assert.equal(r.lines[1], "runs (7)");
|
|
272
|
+
assert.match(r.lines[2], /^run_id\s+repo\s+spec\s+version\s+status\s+wall\s+b\/p\/i\/v\/s min\s+tokens\s+cost\s+models\s+disp\s+grants\s+reopens\s+loops\s+findings\s+council$/);
|
|
273
|
+
const bRow = r.lines.find((l) => l.startsWith("bbbbbbbb"));
|
|
274
|
+
assert.match(bRow, /\s5\.9\.1\s+shipped\*\s+-\s+10\/-\/-\/-\/-\s/);
|
|
275
|
+
assert.match(bRow, /\s-\s*$/);
|
|
276
|
+
const aRow = r.lines.find((l) => l.startsWith("aaaaaaaa"));
|
|
277
|
+
assert.match(aRow, /\sshipped\s+60m\s+10\/10\/10\/10\/10\s+6\.6k\s+2\.25\s+m\/alpha,m\/beta\s+7\s+1\s+0\s+2\s+1\/2\/3\s+4$/);
|
|
278
|
+
assert.ok(r.lines.includes("by version"));
|
|
279
|
+
const v = r.lines.find((l) => l.startsWith("5.9.1"));
|
|
280
|
+
assert.match(v, /^5\.9\.1\s+3\s+2\s+1\s+90m\/120m\s/);
|
|
281
|
+
assert.ok(r.lines.some((l) => /^skipped: .*broken\.yaml: unparseable$/.test(l)));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("--since numeric compare: 5.10.0 > 5.9.1, unknown and malformed versions dropped; no rows -> no records found, exit 0", (t) => {
|
|
285
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
286
|
+
write(root, `${TDIR}/g.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 99999999-0000-0000-0000-000000000009").replace("versions: { pi-gauntlet: 5.9.1 }", "versions: {}"));
|
|
287
|
+
write(root, `${TDIR}/malformed-version.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 11111111-0000-0000-0000-000000000011").replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.9.1garbage"));
|
|
288
|
+
write(root, `${TDIR}/leading-zero-version.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 33333333-0000-0000-0000-000000000013").replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.09.1"));
|
|
289
|
+
assert.equal(byId(json(root).runs, "11111111").version, "unknown");
|
|
290
|
+
assert.equal(byId(json(root).runs, "33333333").version, "unknown");
|
|
291
|
+
const sinceFive = json(root, ["--since", "5.0.0"]);
|
|
292
|
+
assert.equal(byId(sinceFive.runs, "33333333"), undefined);
|
|
293
|
+
const d = json(root, ["--since", "5.10.0"]);
|
|
294
|
+
assert.equal(d.since, "5.10.0");
|
|
295
|
+
assert.deepEqual(d.runs.map((r) => r.version).sort(), ["5.10.0", "5.11.0", "5.11.0", "5.11.0"]);
|
|
296
|
+
const r = invoke(root, ["--since", "9.9.9"]);
|
|
297
|
+
assert.equal(r.status, 0);
|
|
298
|
+
assert.equal(r.lines[0], `corpus: ${root.split("/").pop()}=0 since: 9.9.9`);
|
|
299
|
+
assert.match(r.lines[1], /^no records found in .*\.pi\/gauntlet\/telemetry$/);
|
|
300
|
+
assert.equal(r.lines.length, 2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("--dir: repo root with telemetry dir, repo root without (skipped, checkout not walked), bare telemetry dir; duplicate run_id collapses", (t) => {
|
|
304
|
+
const root = corpus(gitRepo());
|
|
305
|
+
const other = gitRepo("gp-other-");
|
|
306
|
+
write(other, `${TDIR}/dup.yaml`, COMPLETE);
|
|
307
|
+
write(other, `${TDIR}/h.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 88888888-0000-0000-0000-000000000008"));
|
|
308
|
+
const empty = gitRepo("gp-empty-");
|
|
309
|
+
write(empty, "doc/specs/decoy.yaml", COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 77777777-0000-0000-0000-000000000007"));
|
|
310
|
+
const bare = mkdtempSync(join(tmpdir(), "gp-bare-"));
|
|
311
|
+
write(bare, "x.yaml", COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 66666666-0000-0000-0000-000000000006"));
|
|
312
|
+
cleanup(t, root, other, empty, bare);
|
|
313
|
+
const d = json(root, ["--dir", other, "--dir", empty, "--dir", bare, "--dir", join(bare, "missing")]);
|
|
314
|
+
assert.equal(d.corpus[other.split("/").pop()], 1);
|
|
315
|
+
assert.equal(d.corpus[bare.split("/").pop()], 1);
|
|
316
|
+
assert.equal(d.corpus[empty.split("/").pop()], undefined);
|
|
317
|
+
assert.ok(d.runs.some((r) => r.run_id.startsWith("88888888")));
|
|
318
|
+
assert.ok(d.runs.some((r) => r.run_id.startsWith("66666666")));
|
|
319
|
+
assert.ok(!d.runs.some((r) => r.run_id.startsWith("77777777")));
|
|
320
|
+
assert.equal(d.runs.filter((r) => r.run_id.startsWith("aaaaaaaa")).length, 1);
|
|
321
|
+
assert.ok(d.skipped.some((s) => s.file.endsWith("dup.yaml") && s.reason === "duplicate run_id"));
|
|
322
|
+
assert.ok(d.skipped.some((s) => s.file === empty && s.reason === "no telemetry dir"));
|
|
323
|
+
assert.ok(d.skipped.some((s) => s.file === join(bare, "missing") && s.reason === "not found"));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("--dir skips an existing regular file as not a directory", (t) => {
|
|
327
|
+
const root = gitRepo();
|
|
328
|
+
const file = join(root, "telemetry.yaml");
|
|
329
|
+
writeFileSync(file, COMPLETE);
|
|
330
|
+
cleanup(t, root);
|
|
331
|
+
const r = invoke(root, ["--json", "--dir", file]);
|
|
332
|
+
assert.equal(r.status, 0, r.stderr);
|
|
333
|
+
const d = JSON.parse(r.stdout);
|
|
334
|
+
assert.ok(d.skipped.some((s) => s.file === file && s.reason === "not a directory"));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("repo .pi/settings.json telemetry.dir is honoured for the default corpus", (t) => {
|
|
338
|
+
const root = gitRepo(); cleanup(t, root);
|
|
339
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { dir: "custom/dir" } } }));
|
|
340
|
+
write(root, "custom/dir/doc/specs/a.yaml", COMPLETE);
|
|
341
|
+
const d = json(root);
|
|
342
|
+
assert.equal(d.runs.length, 1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("usage error exits 1", (t) => {
|
|
346
|
+
const root = gitRepo(); cleanup(t, root);
|
|
347
|
+
assert.equal(invoke(root, ["--bogus"]).status, 1);
|
|
348
|
+
assert.equal(invoke(root, ["--since", "abc"]).status, 1);
|
|
349
|
+
assert.equal(invoke(root, ["--since", "5.09.1"]).status, 1);
|
|
350
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
|
|
3
3
|
// from the branch. The recorder pathspec-commits the record at every checkpoint, so the
|
|
4
|
-
// newest copy is always in the deleting commit's parent.
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// newest copy is always in the deleting commit's parent. An in_progress record with no
|
|
5
|
+
// ship phase is stamped shipped at landing. Exit 0 on every outcome: the callers are ship
|
|
6
|
+
// paths and this script never blocks one.
|
|
7
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { isAbsolute, join } from "node:path";
|
|
9
10
|
import { spawnSync } from "node:child_process";
|
|
@@ -11,6 +12,7 @@ import process from "node:process";
|
|
|
11
12
|
import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
|
|
12
13
|
import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
|
|
13
14
|
import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
|
|
15
|
+
import { parseRecord, serializeRecord } from "../extensions/lib/telemetry-record.ts";
|
|
14
16
|
|
|
15
17
|
const GIT_TIMEOUT_MS = 10_000;
|
|
16
18
|
const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
|
|
@@ -67,10 +69,40 @@ function resolveBase(root, explicit) {
|
|
|
67
69
|
return undefined;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// in_progress with no ship phase: the recorder lost its binding before the ship, so the
|
|
73
|
+
// landing finishes the record instead of leaving it in_progress on main forever.
|
|
74
|
+
const unfinished = (rec) => {
|
|
75
|
+
const phases = rec?.derived?.phases;
|
|
76
|
+
return rec?.status === "in_progress" && (phases === null || typeof phases !== "object" || !("ship" in phases));
|
|
77
|
+
};
|
|
78
|
+
const stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
|
|
79
|
+
|
|
80
|
+
function stampPresent(root, rec, check) {
|
|
81
|
+
const line = `present ${rec}`;
|
|
82
|
+
const abs = join(root, rec);
|
|
83
|
+
// Only a copy identical to HEAD in both worktree and index is script-owned.
|
|
84
|
+
if (!existsSync(abs) || !git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok || !git(root, ["diff", "--quiet", "--cached", "HEAD", "--", rec]).ok) return line;
|
|
85
|
+
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
86
|
+
if (!unfinished(parsed)) return line;
|
|
87
|
+
if (check) return `unfinished ${rec}`;
|
|
88
|
+
const prior = readFileSync(abs);
|
|
89
|
+
const failed = (reason) => {
|
|
90
|
+
writeFileSync(abs, prior);
|
|
91
|
+
git(root, ["reset", "-q", "--", rec]);
|
|
92
|
+
return `restore failed ${rec}: ${reason}`;
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
95
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
96
|
+
if (!add.ok) return failed(add.stderr);
|
|
97
|
+
const commit = git(root, ["commit", "-q", "-m", `telemetry: mark ${rec} shipped at landing`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
98
|
+
if (!commit.ok) return failed(commit.stderr);
|
|
99
|
+
return `${line} (marked shipped)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
function salvage(root, rec, base, check) {
|
|
71
103
|
const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
|
|
72
104
|
if (presence.timedOut) return `restore failed ${rec}: timed out`;
|
|
73
|
-
if (presence.ok) return
|
|
105
|
+
if (presence.ok) return stampPresent(root, rec, check);
|
|
74
106
|
const history = git(root, ["log", `${base}..HEAD`, "--grep=^telemetry: ", "-1", "--format=%H"]);
|
|
75
107
|
if (history.timedOut) return `restore failed ${rec}: timed out`;
|
|
76
108
|
if (!history.stdout) return `no telemetry run ${rec}`;
|
|
@@ -89,7 +121,12 @@ function salvage(root, rec, base, check) {
|
|
|
89
121
|
}
|
|
90
122
|
let created = false;
|
|
91
123
|
let stagedByScript = false;
|
|
124
|
+
let priorBytes;
|
|
92
125
|
const rollback = () => {
|
|
126
|
+
if (priorBytes !== undefined) {
|
|
127
|
+
writeFileSync(abs, priorBytes);
|
|
128
|
+
if (preStaged) git(root, ["add", "-f", "--", rec]);
|
|
129
|
+
}
|
|
93
130
|
if (stagedByScript) git(root, ["reset", "-q", "--", rec]);
|
|
94
131
|
if (created) rmSync(abs, { force: true });
|
|
95
132
|
};
|
|
@@ -102,15 +139,20 @@ function salvage(root, rec, base, check) {
|
|
|
102
139
|
const co = git(root, ["checkout", `${del}^`, "--", rec]);
|
|
103
140
|
if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
|
|
104
141
|
created = true;
|
|
105
|
-
stagedByScript = true;
|
|
106
|
-
} else if (!preStaged) {
|
|
107
|
-
const add = git(root, ["add", "-f", "--", rec]);
|
|
108
|
-
if (!add.ok) return failed(add.stderr);
|
|
109
|
-
stagedByScript = true;
|
|
110
142
|
}
|
|
143
|
+
stagedByScript = !preStaged;
|
|
144
|
+
let suffix = "";
|
|
145
|
+
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
146
|
+
if (unfinished(parsed)) {
|
|
147
|
+
priorBytes = readFileSync(abs);
|
|
148
|
+
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
149
|
+
suffix = " (marked shipped)";
|
|
150
|
+
}
|
|
151
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
152
|
+
if (!add.ok) return failed(add.stderr);
|
|
111
153
|
const commit = git(root, ["commit", "-q", "-m", `telemetry: restore record stripped in ${del.slice(0, 10)}`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
112
154
|
if (!commit.ok) return failed(commit.stderr);
|
|
113
|
-
return `restored ${rec} from ${del}`;
|
|
155
|
+
return `restored ${rec} from ${del}${suffix}`;
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
function main() {
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
|
|
12
12
|
const RECORD_V1 = "schema: 1\nspec: doc/specs/x.md\nstatus: in_progress\n";
|
|
13
13
|
const RECORD_V2 = "schema: 1\nspec: doc/specs/x.md\nstatus: shipped\n";
|
|
14
|
+
// Records parseRecord accepts (run_id present). UNFINISHED: in_progress, no ship phase.
|
|
15
|
+
const UNFINISHED = "schema: 1\nspec: doc/specs/x.md\nrun_id: r-1\nstatus: in_progress\ncreated_at: 2026-09-17T16:11:45Z\nderived:\n duration_s: 60\n phases:\n brainstorm: { started_at: 2026-09-17T16:11:45Z, duration_s: 60 }\n";
|
|
16
|
+
const SHIP_PHASE = UNFINISHED.replace(" brainstorm:", " ship: { started_at: 2026-09-17T16:12:45Z }\n brainstorm:");
|
|
17
|
+
const SCALAR_PHASES = UNFINISHED.replace(" phases:\n brainstorm: { started_at: 2026-09-17T16:11:45Z, duration_s: 60 }", " phases: nope");
|
|
18
|
+
const FINISHED = UNFINISHED.replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:56:13Z");
|
|
14
19
|
|
|
15
20
|
const git = (cwd, args, env = {}) =>
|
|
16
21
|
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], {
|
|
@@ -32,7 +37,7 @@
|
|
|
32
37
|
return out(root, ["rev-parse", "HEAD"]);
|
|
33
38
|
};
|
|
34
39
|
// main: README only. feat: spec (+ record when withRecord).
|
|
35
|
-
const repo = ({ withRecord = true } = {}) => {
|
|
40
|
+
const repo = ({ withRecord = true, record = RECORD_V1 } = {}) => {
|
|
36
41
|
const root = mkdtempSync(join(tmpdir(), "gts-"));
|
|
37
42
|
git(root, ["init", "-q", "-b", "main"]);
|
|
38
43
|
write(root, "README.md", "# fixture\n");
|
|
@@ -41,7 +46,7 @@
|
|
|
41
46
|
write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
|
|
42
47
|
commit(root, "Add spec", SPEC);
|
|
43
48
|
if (withRecord) {
|
|
44
|
-
write(root, REC,
|
|
49
|
+
write(root, REC, record);
|
|
45
50
|
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
46
51
|
}
|
|
47
52
|
return root;
|
|
@@ -348,6 +353,103 @@ PATH="${process.env.PATH.split(":").filter((part) => part !== bin).join(":")}" e
|
|
|
348
353
|
assert.equal(out(remote, ["for-each-ref"]), remoteBefore);
|
|
349
354
|
});
|
|
350
355
|
|
|
356
|
+
test("present + unfinished -> stamped shipped, one telemetry: commit, clean tree", (t) => {
|
|
357
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
358
|
+
const n = commitCount(root);
|
|
359
|
+
const r = run(root);
|
|
360
|
+
assert.equal(r.status, 0, r.stderr);
|
|
361
|
+
assert.deepEqual(r.lines, [`present ${REC} (marked shipped)`]);
|
|
362
|
+
const text = readFileSync(join(root, REC), "utf8");
|
|
363
|
+
assert.match(text, /^status: shipped$/m);
|
|
364
|
+
assert.match(text, /^shipped_at: \d{4}-\d{2}-\d{2}T/m);
|
|
365
|
+
assert.doesNotMatch(text, /in_progress/);
|
|
366
|
+
assert.equal(commitCount(root), n + 1);
|
|
367
|
+
assert.equal(headSubject(root), `telemetry: mark ${REC} shipped at landing`);
|
|
368
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
369
|
+
assert.equal(porcelain(root), "");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("present + scalar phases -> stamped shipped, exit 0", (t) => {
|
|
373
|
+
const root = repo({ record: SCALAR_PHASES }); cleanup(t, root);
|
|
374
|
+
const r = run(root);
|
|
375
|
+
assert.equal(r.status, 0, r.stderr);
|
|
376
|
+
assert.deepEqual(r.lines, [`present ${REC} (marked shipped)`]);
|
|
377
|
+
assert.match(readFileSync(join(root, REC), "utf8"), /^status: shipped$/m);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("present + in_progress with a ship phase -> untouched", (t) => {
|
|
381
|
+
const root = repo({ record: SHIP_PHASE }); cleanup(t, root);
|
|
382
|
+
const before = head(root);
|
|
383
|
+
const r = run(root);
|
|
384
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
385
|
+
assert.equal(head(root), before);
|
|
386
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), SHIP_PHASE);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("present + unfinished but worktree copy dirty -> untouched, present", (t) => {
|
|
390
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
391
|
+
write(root, REC, UNFINISHED + "# local edit\n");
|
|
392
|
+
const before = head(root);
|
|
393
|
+
const r = run(root);
|
|
394
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
395
|
+
assert.equal(head(root), before);
|
|
396
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED + "# local edit\n");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("present + already shipped -> byte-identical, no commit", (t) => {
|
|
400
|
+
const root = repo({ record: FINISHED }); cleanup(t, root);
|
|
401
|
+
const before = head(root);
|
|
402
|
+
const r = run(root);
|
|
403
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
404
|
+
assert.equal(head(root), before);
|
|
405
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), FINISHED);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("restore with scalar phases -> stamped shipped, exit 0", (t) => {
|
|
409
|
+
const root = repo({ record: SCALAR_PHASES }); cleanup(t, root);
|
|
410
|
+
const del = rmCommit(root, "strip", REC);
|
|
411
|
+
const r = run(root);
|
|
412
|
+
assert.equal(r.status, 0, r.stderr);
|
|
413
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del} (marked shipped)`]);
|
|
414
|
+
assert.match(out(root, ["show", `HEAD:${REC}`]), /^status: shipped$/m);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("restore of an unfinished record -> one commit whose bytes carry the stamp", (t) => {
|
|
418
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
419
|
+
const del = rmCommit(root, "strip", REC);
|
|
420
|
+
write(root, REC, UNFINISHED);
|
|
421
|
+
const n = commitCount(root);
|
|
422
|
+
const r = run(root);
|
|
423
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del} (marked shipped)`]);
|
|
424
|
+
assert.equal(commitCount(root), n + 1);
|
|
425
|
+
const committed = out(root, ["show", `HEAD:${REC}`]);
|
|
426
|
+
assert.match(committed, /^status: shipped$/m);
|
|
427
|
+
assert.match(committed, /^shipped_at: /m);
|
|
428
|
+
assert.equal(porcelain(root), "");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("--check on present + unfinished -> unfinished <rec>, nothing changes", (t) => {
|
|
432
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
433
|
+
const before = head(root);
|
|
434
|
+
const r = run(root, ["--check"]);
|
|
435
|
+
assert.deepEqual(r.lines, [`unfinished ${REC}`]);
|
|
436
|
+
assert.equal(head(root), before);
|
|
437
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED);
|
|
438
|
+
assert.equal(porcelain(root), "");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("present + unfinished, rejecting pre-commit hook -> restore failed, HEAD bytes and index restored", (t) => {
|
|
442
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
443
|
+
write(root, ".git/hooks/pre-commit", "#!/bin/sh\nexit 1\n");
|
|
444
|
+
chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
|
|
445
|
+
const before = head(root);
|
|
446
|
+
const r = run(root);
|
|
447
|
+
assert.match(r.stdout, new RegExp(`^restore failed ${REC.replace(/\./g, "\\.")}: `));
|
|
448
|
+
assert.equal(head(root), before);
|
|
449
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED);
|
|
450
|
+
assert.equal(porcelain(root), "");
|
|
451
|
+
});
|
|
452
|
+
|
|
351
453
|
test("finishing Option 1 fixture: strip-on-branch + salvage + merge --squash leaves the record, not the plan, staged", (t) => {
|
|
352
454
|
const root = repo(); cleanup(t, root);
|
|
353
455
|
const plan = "doc/plans/x.md";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.0",
|
|
4
4
|
"description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
],
|
|
34
34
|
"bin": {
|
|
35
35
|
"gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
|
|
36
|
-
"gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs"
|
|
36
|
+
"gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs",
|
|
37
|
+
"gauntlet-performance": "bin/gauntlet-performance.mjs"
|
|
37
38
|
},
|
|
38
39
|
"engines": {
|
|
39
40
|
"node": ">=24.15.0"
|
|
@@ -177,7 +177,7 @@ fi
|
|
|
177
177
|
node <bin>/gauntlet-telemetry-salvage.mjs --worktree "$WORKTREE" --base <base-branch>
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
The salvage prints one line per spec on the branch (`present`, `restored <path> from <sha>`, `no telemetry run`, `never written`, `restore failed <path>: <reason>`) and always exits 0. Print its stdout verbatim in the ship completion message. A `restore failed` line is reported, never retried, and never blocks the ship - the record stays recoverable from the branch ref.
|
|
180
|
+
The salvage prints one line per spec on the branch (`present`, `present <path> (marked shipped)`, `restored <path> from <sha>`, `restored <path> from <sha> (marked shipped)`, `no telemetry run`, `never written`, `restore failed <path>: <reason>`) and always exits 0. `(marked shipped)` means the record was still `in_progress` with no ship phase (the recorder lost its binding) and the salvage committed `status: shipped` + `shipped_at` as one `telemetry:` commit; it rides the squash or push like any branch commit. Print its stdout verbatim in the ship completion message. A `restore failed` line is reported, never retried, and never blocks the ship - the record stays recoverable from the branch ref.
|
|
181
181
|
|
|
182
182
|
#### Option 1: Squash-merge to base
|
|
183
183
|
|
|
@@ -174,6 +174,7 @@ verification command may write to the tree while the Reviewer reads it):
|
|
|
174
174
|
squash. On a cell with no push row (fork overlay, report-only states) the same
|
|
175
175
|
finding is a non-blocking follow-up instead: the record stays recoverable from the
|
|
176
176
|
PR head ref after merge, and blocking would stop a ship the gate cannot repair.
|
|
177
|
+
`unfinished <path>` (record still `in_progress` with no ship phase) also lands in `## Evidence` as one line and is non-blocking: pre-landing `in_progress` is normal, and the merge course's salvage run stamps it.
|
|
177
178
|
- **Evidence:** On the CI path, list each satisfying check's name, conclusion, assessed SHA, and run URL - there is no command or raw_tail to paste. On the local path, paste each run's `command` and `raw_tail` verbatim, fenced - never
|
|
178
179
|
paraphrased. Any authored summary is labeled as a summary and never substitutes for
|
|
179
180
|
`raw_tail`.
|
|
@@ -256,7 +257,7 @@ are never bundled into one selection, with one scoped exception: the selected me
|
|
|
256
257
|
course first runs `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned
|
|
257
258
|
path> --base origin/<baseRefName>` (no `--check`). `present` -> merge as-is. `restored
|
|
258
259
|
<path> from <sha>` -> push that single `telemetry: restore` commit as part of this
|
|
259
|
-
course, re-fetch `headRefOid`, and pass the new SHA to `--match-head-commit`. `restore
|
|
260
|
+
course, re-fetch `headRefOid`, and pass the new SHA to `--match-head-commit`. A line ending `(marked shipped)` (`present` or `restored`) is handled the same way: push that single `telemetry:` commit, re-fetch `headRefOid`, pass the new SHA. `restore
|
|
260
261
|
failed` -> merge proceeds, the reason is printed, and the follow-up names recovery
|
|
261
262
|
from the PR head ref.
|
|
262
263
|
|
|
@@ -508,7 +509,7 @@ The menu is a state machine, not a one-shot report:
|
|
|
508
509
|
reviewed doc edits selected alongside them (one commit, or one per batch
|
|
509
510
|
sequentially; subjects name the fixes) - then re-resolves the evidence for the new head **once** (the brief's stale-head row: prior evidence is stale; the local command executes only on a fallback/opt-out resolution). Before the push, run
|
|
510
511
|
`node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned path> --base
|
|
511
|
-
origin/<baseRefName>` (no `--check`); a `restored` commit rides the wave's single
|
|
512
|
+
origin/<baseRefName>` (no `--check`); a `restored` or `(marked shipped)` commit rides the wave's single
|
|
512
513
|
push and the pushed SHA becomes the assessed head under the course's-own-push rule
|
|
513
514
|
in step 1. Print its stdout in the re-rendered report's `## Evidence`. **On
|
|
514
515
|
green**, push **once**; gate and push are per-wave invariants, never per-fix or
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gauntlet-performance
|
|
3
|
+
description: Use when a human asks how gauntlet runs perform across the recorded telemetry - explicit invocation only (/skill:gauntlet-performance [--dir <path>]... [--since <version>]).
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Gauntlet Performance
|
|
8
|
+
|
|
9
|
+
The CLI parses and aggregates; you reason. Never open a telemetry YAML record yourself.
|
|
10
|
+
|
|
11
|
+
## Run the digest
|
|
12
|
+
|
|
13
|
+
1. Resolve the CLI: `<directory of this SKILL.md>/../../bin/gauntlet-performance.mjs`.
|
|
14
|
+
2. Run it from the repo root, passing the user's `--dir` and `--since` arguments verbatim:
|
|
15
|
+
`node <bin>/gauntlet-performance.mjs [--dir <path>]... [--since <version>]`.
|
|
16
|
+
Default corpus is the current repo; each `--dir` adds a repo root or a telemetry dir.
|
|
17
|
+
3. If the command is missing or the output contains `no records found`, relay that line and stop - no menu.
|
|
18
|
+
|
|
19
|
+
## Read the digest
|
|
20
|
+
|
|
21
|
+
`runs` has one row per record; `shipped*` marks a truncated run (no ship phase recorded - salvage stamped it at landing; its `wall` is `-` and it feeds no aggregate). `by version` groups by pi-gauntlet version: `n` counts every row, `shipped` counts the rows behind the p50/max columns. `grants` is `fix_round_grants` - the fix-round proxy (human-granted extra review rounds); schema 1 has no code-review round count.
|
|
22
|
+
|
|
23
|
+
## Reply - exactly this, in this order
|
|
24
|
+
|
|
25
|
+
1. **Recommendation** (2-4 sentences). One claim, led by the run that exemplifies it: quote its `spec` slug, `run_id`, and the 1-3 numbers that carry the claim. When `grants` is the evidence, call it the fix-round proxy. If no version group has `shipped >= 2`, the recommendation is "sample too small" with the `n`/`shipped` counts per version.
|
|
26
|
+
2. **Cornerstones**: 3-5 bullets of aggregate facts from `by version` - corpus size, truncated count, the p50s and model tallies that moved between versions.
|
|
27
|
+
3. **Menu**, numbered, at most 3 items, rendered exactly as:
|
|
28
|
+
- `1. render report` - ask for a target path; write markdown there: the digest verbatim, then the recommendation and cornerstones above. If the file exists, ask before overwriting. Write nothing unless this item is chosen.
|
|
29
|
+
- `2. open recommendation as ticket` - hand the claim and its numbers to `/skill:shape-ticket`; never create a ticket directly.
|
|
30
|
+
- `3. drill into <slug>` - re-run the CLI with `--json` and show that run's fields.
|
|
31
|
+
|
|
32
|
+
Nothing else: no preamble, no restated digest, no file written before item 1 is chosen.
|
|
33
|
+
|
|
34
|
+
## Project overrides
|
|
35
|
+
|
|
36
|
+
If a gauntlet overrides file exists - checked in order:
|
|
37
|
+
`.pi/gauntlet-overrides.md`, `<repo root>/gauntlet-overrides.md`,
|
|
38
|
+
`<repo root>/doc/gauntlet-overrides.md`; first found wins - read it. Any
|
|
39
|
+
sections relevant to this skill - by name match, by topic (routing,
|
|
40
|
+
verification, worktrees, etc.), or by workflow convention - override or
|
|
41
|
+
extend the instructions above. Project-local `AGENTS.md` is already in
|
|
42
|
+
context - check it for project-specific routing tables, service paths, and
|
|
43
|
+
verification commands.
|