pi-gauntlet 5.12.0 → 5.12.1
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/bin/gauntlet-performance.mjs +136 -92
- package/bin/gauntlet-telemetry-salvage.mjs +136 -46
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.12.1 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
- Fixed: `gauntlet-telemetry-salvage` and `gauntlet-performance` no longer crash with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` when run from an npm-installed copy - both bins are now committed esbuild bundles (sources in `src/bins/`, rebuild with `npm run build:bins`), guarded by a CI freshness check, bundle pack assertions, and a packed-install smoke test. (#39)
|
|
6
|
+
|
|
3
7
|
## v5.12.0 - 2026-09-19
|
|
4
8
|
|
|
5
9
|
- 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)
|
|
@@ -1,82 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
//
|
|
2
|
+
|
|
3
|
+
// src/bins/gauntlet-performance.mjs
|
|
4
4
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, join, resolve } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
|
-
import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
// extensions/lib/gauntlet-settings.ts
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
function mergeGauntlet(preset, repo) {
|
|
14
|
+
return { ...preset ?? {}, ...repo ?? {} };
|
|
15
|
+
}
|
|
16
|
+
var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
17
|
+
var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
|
|
18
|
+
var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
|
|
19
|
+
var DEFAULT_TELEMETRY_BUCKETS = [
|
|
20
|
+
["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
|
|
21
|
+
["docs", ["**/*.md"]],
|
|
22
|
+
["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]]
|
|
23
|
+
];
|
|
24
|
+
function resolveTelemetry(g) {
|
|
25
|
+
const t = g.telemetry;
|
|
26
|
+
const warnings = [];
|
|
27
|
+
const enabled = t?.enabled !== false;
|
|
28
|
+
let dir = DEFAULT_TELEMETRY_DIR;
|
|
29
|
+
if (t?.dir !== void 0) {
|
|
30
|
+
const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
|
|
31
|
+
const canonical = value.replace(/\\/g, "/");
|
|
32
|
+
const normalized = path.posix.normalize(canonical);
|
|
33
|
+
if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
|
|
34
|
+
else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
|
|
35
|
+
}
|
|
36
|
+
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
37
|
+
if (t?.buckets !== void 0) {
|
|
38
|
+
const b = t.buckets;
|
|
39
|
+
const valid = b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length > 0 && Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
|
|
40
|
+
if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
|
|
41
|
+
else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
42
|
+
}
|
|
43
|
+
return { enabled, dir, buckets, warning: joinWarn(warnings) };
|
|
44
|
+
}
|
|
16
45
|
|
|
17
|
-
|
|
46
|
+
// src/bins/gauntlet-performance.mjs
|
|
47
|
+
var PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
|
|
48
|
+
var TOKEN_KEYS = ["input", "output", "cache_read", "cache_write"];
|
|
49
|
+
var RUN_HEADER = ["run_id", "repo", "spec", "version", "status", "wall", "b/p/i/v/s min", "tokens", "cost", "models", "disp", "grants", "reopens", "loops", "findings", "council"];
|
|
50
|
+
var 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"];
|
|
51
|
+
var usage = () => {
|
|
18
52
|
process.stderr.write("usage: gauntlet-performance [--dir <repo root or telemetry dir>]... [--since <version>] [--json]\n");
|
|
19
53
|
process.exit(1);
|
|
20
54
|
};
|
|
21
|
-
|
|
22
|
-
const semver = (s) => {
|
|
55
|
+
var semver = (s) => {
|
|
23
56
|
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])] :
|
|
57
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : void 0;
|
|
25
58
|
};
|
|
26
|
-
|
|
27
|
-
|
|
59
|
+
var cmpSemver = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
28
60
|
function parseArgs(argv) {
|
|
29
|
-
const opts = { dirs: [], since:
|
|
61
|
+
const opts = { dirs: [], since: void 0, json: false };
|
|
30
62
|
for (let i = 0; i < argv.length; i++) {
|
|
31
63
|
const a = argv[i];
|
|
32
|
-
if (a === "--dir" && argv[i + 1] !==
|
|
33
|
-
else if (a === "--since" && argv[i + 1] !==
|
|
64
|
+
if (a === "--dir" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.dirs.push(argv[++i]);
|
|
65
|
+
else if (a === "--since" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.since = argv[++i];
|
|
34
66
|
else if (a === "--json") opts.json = true;
|
|
35
67
|
else usage();
|
|
36
68
|
}
|
|
37
|
-
if (opts.since !==
|
|
69
|
+
if (opts.since !== void 0 && !semver(opts.since)) usage();
|
|
38
70
|
return opts;
|
|
39
71
|
}
|
|
40
|
-
|
|
41
72
|
function readLayer(file) {
|
|
42
73
|
if (!existsSync(file)) return {};
|
|
43
74
|
try {
|
|
44
75
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
45
76
|
} catch (e) {
|
|
46
|
-
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
77
|
+
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
78
|
+
`);
|
|
47
79
|
return {};
|
|
48
80
|
}
|
|
49
81
|
}
|
|
50
|
-
|
|
51
|
-
// Same two layers the recorder and salvage read.
|
|
52
82
|
function telemetryDirOf(root) {
|
|
53
83
|
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
54
84
|
const preset = readLayer(join(agentDir, "settings.json"));
|
|
55
85
|
const repo = readLayer(join(root, ".pi", "settings.json"));
|
|
56
86
|
const t = resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
|
|
57
|
-
if (t.warning) process.stderr.write(`warning: ${t.warning}
|
|
87
|
+
if (t.warning) process.stderr.write(`warning: ${t.warning}
|
|
88
|
+
`);
|
|
58
89
|
return join(root, t.dir);
|
|
59
90
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
91
|
+
var gitToplevel = (cwd) => {
|
|
92
|
+
const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1e4 });
|
|
93
|
+
return r.status === 0 ? r.stdout.trim() : void 0;
|
|
64
94
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (!existsSync(path)) return { label, skip: "not found" };
|
|
70
|
-
const abs = realpathSync(path);
|
|
95
|
+
function corpusFor(path2) {
|
|
96
|
+
const label = basename(resolve(path2));
|
|
97
|
+
if (!existsSync(path2)) return { label, skip: "not found" };
|
|
98
|
+
const abs = realpathSync(path2);
|
|
71
99
|
const top = gitToplevel(abs);
|
|
72
|
-
if (top !==
|
|
100
|
+
if (top !== void 0 && realpathSync(top) === abs) {
|
|
73
101
|
const dir = telemetryDirOf(abs);
|
|
74
102
|
return existsSync(dir) ? { label, dir } : { label, skip: "no telemetry dir" };
|
|
75
103
|
}
|
|
76
104
|
if (!statSync(abs).isDirectory()) return { label, skip: "not a directory" };
|
|
77
105
|
return { label, dir: abs };
|
|
78
106
|
}
|
|
79
|
-
|
|
80
107
|
function* yamlFiles(dir) {
|
|
81
108
|
if (!existsSync(dir)) return;
|
|
82
109
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -85,15 +112,13 @@ function* yamlFiles(dir) {
|
|
|
85
112
|
else if (e.isFile() && e.name.endsWith(".yaml")) yield p;
|
|
86
113
|
}
|
|
87
114
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const sumOrNull = (vals) => {
|
|
115
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
116
|
+
var str = (v) => typeof v === "string" ? v : null;
|
|
117
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
118
|
+
var sumOrNull = (vals) => {
|
|
93
119
|
const xs = vals.filter((v) => v !== null);
|
|
94
120
|
return xs.length ? xs.reduce((a, b) => a + b, 0) : null;
|
|
95
121
|
};
|
|
96
|
-
|
|
97
122
|
function loadRecord(file, label) {
|
|
98
123
|
let doc;
|
|
99
124
|
try {
|
|
@@ -103,7 +128,7 @@ function loadRecord(file, label) {
|
|
|
103
128
|
}
|
|
104
129
|
const d = obj(doc);
|
|
105
130
|
if (!d) return { skip: "unparseable" };
|
|
106
|
-
if (d.schema !== 1) return { skip: `schema ${d.schema ===
|
|
131
|
+
if (d.schema !== 1) return { skip: `schema ${d.schema === void 0 ? "missing" : String(d.schema)}` };
|
|
107
132
|
if (typeof d.spec !== "string" || typeof d.run_id !== "string") return { skip: "not a record" };
|
|
108
133
|
const derived = obj(d.derived) ?? {};
|
|
109
134
|
const phases = obj(derived.phases) ?? {};
|
|
@@ -114,21 +139,17 @@ function loadRecord(file, label) {
|
|
|
114
139
|
const gates = obj(derived.gates) ?? {};
|
|
115
140
|
const version = str(obj(d.versions)?.["pi-gauntlet"]);
|
|
116
141
|
const reviewEntries = reviews === null ? null : Object.values(reviews);
|
|
117
|
-
const findings = reviewEntries === null
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
}));
|
|
142
|
+
const findings = reviewEntries === null ? null : reviewEntries.length === 0 ? { blocker: 0, major: 0, minor: 0 } : Object.fromEntries(["blocker", "major", "minor"].map((severity) => {
|
|
143
|
+
const counters = reviewEntries.map((r) => {
|
|
144
|
+
const review = obj(r);
|
|
145
|
+
if (review === null) return null;
|
|
146
|
+
if (!Object.hasOwn(review, "findings")) return 0;
|
|
147
|
+
const reviewFindings = obj(review.findings);
|
|
148
|
+
if (reviewFindings === null) return null;
|
|
149
|
+
return Object.hasOwn(reviewFindings, severity) ? num(reviewFindings[severity]) : 0;
|
|
150
|
+
});
|
|
151
|
+
return [severity, counters.includes(null) ? null : counters.reduce((a, b) => a + b, 0)];
|
|
152
|
+
}));
|
|
132
153
|
const truncated = ph("ship") === null;
|
|
133
154
|
return {
|
|
134
155
|
row: {
|
|
@@ -157,27 +178,25 @@ function loadRecord(file, label) {
|
|
|
157
178
|
findings,
|
|
158
179
|
council: num(obj(personas["spec-council-member"])?.dispatches),
|
|
159
180
|
ship_option: str(gates.ship_option),
|
|
160
|
-
tests: str(obj(derived.tests)?.result)
|
|
161
|
-
}
|
|
181
|
+
tests: str(obj(derived.tests)?.result)
|
|
182
|
+
}
|
|
162
183
|
};
|
|
163
184
|
}
|
|
164
|
-
|
|
165
|
-
const p50 = (xs) => {
|
|
185
|
+
var p50 = (xs) => {
|
|
166
186
|
const s = [...xs].sort((a, b) => a - b);
|
|
167
187
|
const m = s.length >> 1;
|
|
168
188
|
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
169
189
|
};
|
|
170
|
-
|
|
171
|
-
const xs = rows.map(pick).filter((v) => v !== null && v !==
|
|
190
|
+
var stat = (rows, pick) => {
|
|
191
|
+
const xs = rows.map(pick).filter((v) => v !== null && v !== void 0);
|
|
172
192
|
return xs.length ? { p50: p50(xs), max: Math.max(...xs) } : { p50: null, max: null };
|
|
173
193
|
};
|
|
174
|
-
|
|
194
|
+
var versionOrder = (a, b) => {
|
|
175
195
|
const x = semver(a), y = semver(b);
|
|
176
196
|
return x && y ? cmpSemver(x, y) : x ? -1 : y ? 1 : 0;
|
|
177
197
|
};
|
|
178
|
-
|
|
179
198
|
function aggregate(rows) {
|
|
180
|
-
const groups = new Map();
|
|
199
|
+
const groups = /* @__PURE__ */ new Map();
|
|
181
200
|
for (const r of rows) {
|
|
182
201
|
if (!groups.has(r.version)) groups.set(r.version, []);
|
|
183
202
|
groups.get(r.version).push(r);
|
|
@@ -201,58 +220,84 @@ function aggregate(rows) {
|
|
|
201
220
|
findings: {
|
|
202
221
|
blocker: { p50: stat(shipped, (r) => r.findings?.blocker ?? null).p50 },
|
|
203
222
|
major: { p50: stat(shipped, (r) => r.findings?.major ?? null).p50 },
|
|
204
|
-
minor: { p50: stat(shipped, (r) => r.findings?.minor ?? null).p50 }
|
|
223
|
+
minor: { p50: stat(shipped, (r) => r.findings?.minor ?? null).p50 }
|
|
205
224
|
},
|
|
206
|
-
models
|
|
225
|
+
models
|
|
207
226
|
};
|
|
208
227
|
});
|
|
209
228
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const table = (header, rows) => {
|
|
229
|
+
var dash = (v) => v === null || v === void 0 ? "-" : String(v);
|
|
230
|
+
var fmtMin = (s) => s === null ? "-" : `${Math.round(s / 60)}m`;
|
|
231
|
+
var fmtCount = (n) => n === null ? "-" : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
|
232
|
+
var fmtCost = (c) => c === null ? "-" : c.toFixed(2);
|
|
233
|
+
var pair = (s, f) => `${f(s.p50)}/${f(s.max)}`;
|
|
234
|
+
var findingsCell = (f) => f === null ? "-" : `${dash(f.blocker)}/${dash(f.major)}/${dash(f.minor)}`;
|
|
235
|
+
var table = (header, rows) => {
|
|
218
236
|
const w = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
219
237
|
return [header, ...rows].map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd()).join("\n");
|
|
220
238
|
};
|
|
221
|
-
|
|
222
|
-
r.run_id.slice(0, 8),
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
239
|
+
var runRow = (r) => [
|
|
240
|
+
r.run_id.slice(0, 8),
|
|
241
|
+
r.repo,
|
|
242
|
+
r.spec,
|
|
243
|
+
r.version,
|
|
244
|
+
`${dash(r.status)}${r.truncated ? "*" : ""}`,
|
|
245
|
+
fmtMin(r.wall_s),
|
|
246
|
+
PHASES.map((p) => r.phase_min[p] === null ? "-" : String(Math.round(r.phase_min[p]))).join("/"),
|
|
247
|
+
fmtCount(r.tokens),
|
|
248
|
+
fmtCost(r.cost),
|
|
249
|
+
r.models.join(",") || "-",
|
|
250
|
+
dash(r.dispatches),
|
|
251
|
+
dash(r.grants),
|
|
252
|
+
dash(r.reopens),
|
|
253
|
+
dash(r.loops),
|
|
254
|
+
findingsCell(r.findings),
|
|
255
|
+
dash(r.council)
|
|
226
256
|
];
|
|
227
|
-
|
|
228
|
-
g.version,
|
|
229
|
-
|
|
257
|
+
var versionRow = (g) => [
|
|
258
|
+
g.version,
|
|
259
|
+
String(g.n),
|
|
260
|
+
String(g.shipped),
|
|
261
|
+
String(g.truncated),
|
|
262
|
+
pair(g.wall_s, fmtMin),
|
|
263
|
+
pair(g.tokens, fmtCount),
|
|
264
|
+
pair(g.cost, fmtCost),
|
|
265
|
+
dash(g.dispatches.p50),
|
|
266
|
+
pair(g.grants, dash),
|
|
267
|
+
pair(g.reopens, dash),
|
|
268
|
+
pair(g.loops, dash),
|
|
230
269
|
`${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(",") || "-"
|
|
270
|
+
Object.entries(g.models).map(([m, n]) => `${m}:${n}`).join(",") || "-"
|
|
232
271
|
];
|
|
233
|
-
|
|
234
272
|
function main() {
|
|
235
273
|
const opts = parseArgs(process.argv.slice(2));
|
|
236
274
|
const corpora = [];
|
|
237
275
|
const skipped = [];
|
|
238
276
|
const top = gitToplevel(process.cwd());
|
|
239
|
-
if (top ===
|
|
277
|
+
if (top === void 0) process.stderr.write(`not a git checkout: ${process.cwd()}
|
|
278
|
+
`);
|
|
240
279
|
else corpora.push({ label: basename(top), dir: telemetryDirOf(top) });
|
|
241
280
|
for (const p of opts.dirs) {
|
|
242
281
|
const c = corpusFor(p);
|
|
243
282
|
if (c.skip) skipped.push({ file: p, reason: c.skip });
|
|
244
283
|
else corpora.push(c);
|
|
245
284
|
}
|
|
246
|
-
const since = opts.since ===
|
|
247
|
-
const seen = new Set();
|
|
285
|
+
const since = opts.since === void 0 ? void 0 : semver(opts.since);
|
|
286
|
+
const seen = /* @__PURE__ */ new Set();
|
|
248
287
|
const corpus = {};
|
|
249
288
|
const rows = [];
|
|
250
289
|
for (const c of corpora) {
|
|
251
290
|
corpus[c.label] = corpus[c.label] ?? 0;
|
|
252
291
|
for (const file of yamlFiles(c.dir)) {
|
|
253
292
|
const res = loadRecord(file, c.label);
|
|
254
|
-
if (res.skip) {
|
|
255
|
-
|
|
293
|
+
if (res.skip) {
|
|
294
|
+
skipped.push({ file, reason: res.skip });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (seen.has(res.row.run_id)) {
|
|
298
|
+
skipped.push({ file, reason: "duplicate run_id" });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
256
301
|
seen.add(res.row.run_id);
|
|
257
302
|
const v = semver(res.row.version);
|
|
258
303
|
if (since && (!v || cmpSemver(v, since) < 0)) continue;
|
|
@@ -266,7 +311,7 @@ function main() {
|
|
|
266
311
|
console.log(JSON.stringify({ corpus, since: opts.since ?? null, runs: rows, by_version: byVersion, skipped }, null, 2));
|
|
267
312
|
return;
|
|
268
313
|
}
|
|
269
|
-
console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since ===
|
|
314
|
+
console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since === void 0 ? "" : ` since: ${opts.since}`}`);
|
|
270
315
|
if (rows.length === 0) {
|
|
271
316
|
console.log(`no records found in ${corpora.map((c) => c.dir).join(", ") || process.cwd()}`);
|
|
272
317
|
} else {
|
|
@@ -279,5 +324,4 @@ function main() {
|
|
|
279
324
|
if (rows.length && skipped.length) console.log("");
|
|
280
325
|
if (rows.length) for (const s of skipped) console.log(`skipped: ${s.file}: ${s.reason}`);
|
|
281
326
|
}
|
|
282
|
-
|
|
283
327
|
main();
|
|
@@ -1,86 +1,182 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
//
|
|
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.
|
|
2
|
+
|
|
3
|
+
// src/bins/gauntlet-telemetry-salvage.mjs
|
|
7
4
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
5
|
import { homedir } from "node:os";
|
|
9
6
|
import { isAbsolute, join } from "node:path";
|
|
10
7
|
import { spawnSync } from "node:child_process";
|
|
11
8
|
import process from "node:process";
|
|
12
|
-
import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
|
|
13
|
-
import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
|
|
14
|
-
import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
|
|
15
|
-
import { parseRecord, serializeRecord } from "../extensions/lib/telemetry-record.ts";
|
|
16
9
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
// extensions/lib/gauntlet-settings.ts
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
function mergeGauntlet(preset, repo) {
|
|
13
|
+
return { ...preset ?? {}, ...repo ?? {} };
|
|
14
|
+
}
|
|
15
|
+
var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
16
|
+
var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
|
|
17
|
+
var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
|
|
18
|
+
var DEFAULT_TELEMETRY_BUCKETS = [
|
|
19
|
+
["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
|
|
20
|
+
["docs", ["**/*.md"]],
|
|
21
|
+
["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]]
|
|
22
|
+
];
|
|
23
|
+
function resolveTelemetry(g) {
|
|
24
|
+
const t = g.telemetry;
|
|
25
|
+
const warnings = [];
|
|
26
|
+
const enabled = t?.enabled !== false;
|
|
27
|
+
let dir = DEFAULT_TELEMETRY_DIR;
|
|
28
|
+
if (t?.dir !== void 0) {
|
|
29
|
+
const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
|
|
30
|
+
const canonical = value.replace(/\\/g, "/");
|
|
31
|
+
const normalized = path.posix.normalize(canonical);
|
|
32
|
+
if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
|
|
33
|
+
else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
|
|
34
|
+
}
|
|
35
|
+
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
36
|
+
if (t?.buckets !== void 0) {
|
|
37
|
+
const b = t.buckets;
|
|
38
|
+
const valid = b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length > 0 && Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
|
|
39
|
+
if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
|
|
40
|
+
else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
41
|
+
}
|
|
42
|
+
return { enabled, dir, buckets, warning: joinWarn(warnings) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// extensions/lib/phase-tracker-helpers.ts
|
|
46
|
+
var STMT_START = "(?:^|[\\n;&|(])\\s*";
|
|
47
|
+
|
|
48
|
+
// extensions/lib/telemetry-paths.ts
|
|
49
|
+
var isSpecPath = (rel) => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
|
|
50
|
+
var recordPathFor = (dir, specRel) => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
|
|
51
|
+
var GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
|
|
52
|
+
var SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
|
|
53
|
+
var DISCARD_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:worktree\\s+remove|branch\\s+-D)(?=\\s|$))");
|
|
54
|
+
var SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
|
|
55
|
+
|
|
56
|
+
// extensions/lib/telemetry-ship.ts
|
|
57
|
+
var BASE_REFS = ["origin/HEAD", "main", "master"];
|
|
58
|
+
|
|
59
|
+
// extensions/lib/telemetry-record.ts
|
|
60
|
+
import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
61
|
+
var emptyAccumulators = () => ({ phases: {}, personas: {}, reviews: {}, spec_writes: {}, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, conformance_loops: 0, amendments: 0, spec_edits_after_ship: 0, events_dropped: 0 });
|
|
62
|
+
function newRecord(o) {
|
|
63
|
+
return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { duration_s: 0, phases: {}, personas: {}, conformance_loops: 0, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, amendments: 0, spec_edits_after_ship: 0, spec_writes: {}, events_dropped: 0 }, accumulators: {}, events: [] };
|
|
64
|
+
}
|
|
65
|
+
var stripUndefined = (v) => {
|
|
66
|
+
if (Array.isArray(v)) return v.map(stripUndefined);
|
|
67
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== void 0).map(([k, x]) => [k, stripUndefined(x)]));
|
|
68
|
+
return v;
|
|
69
|
+
};
|
|
70
|
+
var flowLeafChildren = (map, blockLists = /* @__PURE__ */ new Set()) => {
|
|
71
|
+
for (const pair of map.items) {
|
|
72
|
+
const key = String(pair.key);
|
|
73
|
+
const child = pair.value;
|
|
74
|
+
if (!isCollection(child) || blockLists.has(key)) continue;
|
|
75
|
+
if (isMap(child) && child.items.length > 0 && child.items.every((item) => isMap(item.value))) {
|
|
76
|
+
for (const item of child.items) if (isCollection(item.value)) item.value.flow = true;
|
|
77
|
+
} else {
|
|
78
|
+
child.flow = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
var flowModelLists = (node) => {
|
|
83
|
+
if (isMap(node)) {
|
|
84
|
+
for (const pair of node.items) {
|
|
85
|
+
if (String(pair.key) === "models" && isSeq(pair.value)) pair.value.flow = true;
|
|
86
|
+
flowModelLists(pair.value);
|
|
87
|
+
}
|
|
88
|
+
} else if (isSeq(node)) {
|
|
89
|
+
for (const item of node.items) flowModelLists(item);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
function serializeRecord(rec) {
|
|
93
|
+
const { derived, accumulators, events, ...head } = rec;
|
|
94
|
+
const body = stripUndefined({ ...head, derived, accumulators });
|
|
95
|
+
const doc = new Document(body);
|
|
96
|
+
const derivedNode = doc.get("derived", true);
|
|
97
|
+
if (isMap(derivedNode)) flowLeafChildren(derivedNode, /* @__PURE__ */ new Set(["modified_files"]));
|
|
98
|
+
const accumulatorNode = doc.get("accumulators", true);
|
|
99
|
+
if (isMap(accumulatorNode)) {
|
|
100
|
+
for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
|
|
101
|
+
}
|
|
102
|
+
flowModelLists(doc.contents);
|
|
103
|
+
const bodyText = doc.toString({ lineWidth: 0 });
|
|
104
|
+
const eventLines = events.map((e) => " - " + stringifyYaml(stripUndefined(e), { collectionStyle: "flow", lineWidth: 0 }).trim());
|
|
105
|
+
return bodyText + "events:\n" + (eventLines.length ? eventLines.join("\n") + "\n" : "");
|
|
106
|
+
}
|
|
107
|
+
function parseRecord(text) {
|
|
108
|
+
let doc;
|
|
109
|
+
try {
|
|
110
|
+
doc = parseYaml(text);
|
|
111
|
+
} catch {
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
if (!doc || typeof doc !== "object") return void 0;
|
|
115
|
+
const d = doc;
|
|
116
|
+
if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return void 0;
|
|
117
|
+
const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(
|
|
118
|
+
([session, block]) => block && typeof block === "object" && !Array.isArray(block) ? [[session, { ...emptyAccumulators(), ...block }]] : []
|
|
119
|
+
));
|
|
120
|
+
return { ...d, sessions: Array.isArray(d.sessions) ? d.sessions : [], derived: d.derived ?? newRecord({ spec: d.spec, session: "", now: d.created_at ?? "", runId: d.run_id }).derived, accumulators, events: Array.isArray(d.events) ? d.events : [] };
|
|
121
|
+
}
|
|
20
122
|
|
|
21
|
-
|
|
123
|
+
// src/bins/gauntlet-telemetry-salvage.mjs
|
|
124
|
+
var GIT_TIMEOUT_MS = 1e4;
|
|
125
|
+
var COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 3e4;
|
|
126
|
+
var GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
|
|
127
|
+
var usage = () => {
|
|
22
128
|
process.stderr.write("usage: gauntlet-telemetry-salvage --worktree <abs path> [--base <ref>] [--dir <telemetry dir>] [--check]\n");
|
|
23
129
|
process.exit(1);
|
|
24
130
|
};
|
|
25
|
-
|
|
26
131
|
function parseArgs(argv) {
|
|
27
|
-
const opts = { worktree:
|
|
132
|
+
const opts = { worktree: void 0, base: void 0, dir: void 0, check: false };
|
|
28
133
|
for (let i = 0; i < argv.length; i++) {
|
|
29
134
|
const a = argv[i];
|
|
30
|
-
if (a === "--worktree" && argv[i + 1] !==
|
|
31
|
-
else if (a === "--base" && argv[i + 1] !==
|
|
32
|
-
else if (a === "--dir" && argv[i + 1] !==
|
|
135
|
+
if (a === "--worktree" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
|
|
136
|
+
else if (a === "--base" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
|
|
137
|
+
else if (a === "--dir" && argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
|
|
33
138
|
else if (a === "--check") opts.check = true;
|
|
34
139
|
else usage();
|
|
35
140
|
}
|
|
36
141
|
if (!opts.worktree || !isAbsolute(opts.worktree)) usage();
|
|
37
142
|
return opts;
|
|
38
143
|
}
|
|
39
|
-
|
|
40
144
|
function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
|
|
41
145
|
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
|
|
42
146
|
const timedOut = r.error?.code === "ETIMEDOUT";
|
|
43
147
|
const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
|
|
44
148
|
return { ok: !timedOut && r.status === 0, stdout: (r.stdout ?? "").trim(), stderr, timedOut };
|
|
45
149
|
}
|
|
46
|
-
|
|
47
150
|
function readLayer(file) {
|
|
48
151
|
if (!existsSync(file)) return {};
|
|
49
152
|
try {
|
|
50
153
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
51
154
|
} catch (e) {
|
|
52
|
-
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
155
|
+
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
156
|
+
`);
|
|
53
157
|
return {};
|
|
54
158
|
}
|
|
55
159
|
}
|
|
56
|
-
|
|
57
|
-
// Same two layers the recorder reads (gauntlet-settings-loader.ts), without the pi import.
|
|
58
160
|
function telemetrySettings(root) {
|
|
59
161
|
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
60
162
|
const preset = readLayer(join(agentDir, "settings.json"));
|
|
61
163
|
const repo = readLayer(join(root, ".pi", "settings.json"));
|
|
62
164
|
return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
|
|
63
165
|
}
|
|
64
|
-
|
|
65
166
|
function resolveBase(root, explicit) {
|
|
66
167
|
for (const ref of explicit ? [explicit] : BASE_REFS) {
|
|
67
168
|
if (git(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok) return ref;
|
|
68
169
|
}
|
|
69
|
-
return
|
|
170
|
+
return void 0;
|
|
70
171
|
}
|
|
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) => {
|
|
172
|
+
var unfinished = (rec) => {
|
|
75
173
|
const phases = rec?.derived?.phases;
|
|
76
174
|
return rec?.status === "in_progress" && (phases === null || typeof phases !== "object" || !("ship" in phases));
|
|
77
175
|
};
|
|
78
|
-
|
|
79
|
-
|
|
176
|
+
var stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
|
|
80
177
|
function stampPresent(root, rec, check) {
|
|
81
178
|
const line = `present ${rec}`;
|
|
82
179
|
const abs = join(root, rec);
|
|
83
|
-
// Only a copy identical to HEAD in both worktree and index is script-owned.
|
|
84
180
|
if (!existsSync(abs) || !git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok || !git(root, ["diff", "--quiet", "--cached", "HEAD", "--", rec]).ok) return line;
|
|
85
181
|
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
86
182
|
if (!unfinished(parsed)) return line;
|
|
@@ -91,14 +187,13 @@ function stampPresent(root, rec, check) {
|
|
|
91
187
|
git(root, ["reset", "-q", "--", rec]);
|
|
92
188
|
return `restore failed ${rec}: ${reason}`;
|
|
93
189
|
};
|
|
94
|
-
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
190
|
+
writeFileSync(abs, stamped(parsed, (/* @__PURE__ */ new Date()).toISOString()));
|
|
95
191
|
const add = git(root, ["add", "-f", "--", rec]);
|
|
96
192
|
if (!add.ok) return failed(add.stderr);
|
|
97
193
|
const commit = git(root, ["commit", "-q", "-m", `telemetry: mark ${rec} shipped at landing`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
98
194
|
if (!commit.ok) return failed(commit.stderr);
|
|
99
195
|
return `${line} (marked shipped)`;
|
|
100
196
|
}
|
|
101
|
-
|
|
102
197
|
function salvage(root, rec, base, check) {
|
|
103
198
|
const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
|
|
104
199
|
if (presence.timedOut) return `restore failed ${rec}: timed out`;
|
|
@@ -111,11 +206,9 @@ function salvage(root, rec, base, check) {
|
|
|
111
206
|
const del = deletion.stdout;
|
|
112
207
|
if (!del) return `never written ${rec}`;
|
|
113
208
|
if (check) return `stripped ${rec} in ${del}`;
|
|
114
|
-
|
|
115
209
|
const abs = join(root, rec);
|
|
116
210
|
const onDisk = existsSync(abs);
|
|
117
211
|
const preStaged = git(root, ["ls-files", "--", rec]).stdout !== "";
|
|
118
|
-
// A pre-staged record is user-owned and can only be committed when the worktree matches it.
|
|
119
212
|
if (preStaged && (!onDisk || !git(root, ["diff", "--quiet", "--", rec]).ok)) {
|
|
120
213
|
return `restore failed ${rec}: staged copy differs from worktree`;
|
|
121
214
|
}
|
|
@@ -123,7 +216,7 @@ function salvage(root, rec, base, check) {
|
|
|
123
216
|
let stagedByScript = false;
|
|
124
217
|
let priorBytes;
|
|
125
218
|
const rollback = () => {
|
|
126
|
-
if (priorBytes !==
|
|
219
|
+
if (priorBytes !== void 0) {
|
|
127
220
|
writeFileSync(abs, priorBytes);
|
|
128
221
|
if (preStaged) git(root, ["add", "-f", "--", rec]);
|
|
129
222
|
}
|
|
@@ -134,7 +227,6 @@ function salvage(root, rec, base, check) {
|
|
|
134
227
|
rollback();
|
|
135
228
|
return `restore failed ${rec}: ${reason}`;
|
|
136
229
|
};
|
|
137
|
-
|
|
138
230
|
if (!onDisk) {
|
|
139
231
|
const co = git(root, ["checkout", `${del}^`, "--", rec]);
|
|
140
232
|
if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
|
|
@@ -145,7 +237,7 @@ function salvage(root, rec, base, check) {
|
|
|
145
237
|
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
146
238
|
if (unfinished(parsed)) {
|
|
147
239
|
priorBytes = readFileSync(abs);
|
|
148
|
-
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
240
|
+
writeFileSync(abs, stamped(parsed, (/* @__PURE__ */ new Date()).toISOString()));
|
|
149
241
|
suffix = " (marked shipped)";
|
|
150
242
|
}
|
|
151
243
|
const add = git(root, ["add", "-f", "--", rec]);
|
|
@@ -154,21 +246,20 @@ function salvage(root, rec, base, check) {
|
|
|
154
246
|
if (!commit.ok) return failed(commit.stderr);
|
|
155
247
|
return `restored ${rec} from ${del}${suffix}`;
|
|
156
248
|
}
|
|
157
|
-
|
|
158
249
|
function main() {
|
|
159
250
|
const opts = parseArgs(process.argv.slice(2));
|
|
160
|
-
const override = opts.dir ===
|
|
161
|
-
? undefined
|
|
162
|
-
: resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
|
|
251
|
+
const override = opts.dir === void 0 ? void 0 : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
|
|
163
252
|
if (override?.warning) usage();
|
|
164
253
|
const top = git(opts.worktree, ["rev-parse", "--show-toplevel"]);
|
|
165
254
|
if (!existsSync(opts.worktree) || !top.ok) {
|
|
166
|
-
process.stderr.write(`not a git worktree: ${opts.worktree}
|
|
255
|
+
process.stderr.write(`not a git worktree: ${opts.worktree}
|
|
256
|
+
`);
|
|
167
257
|
return;
|
|
168
258
|
}
|
|
169
259
|
const root = top.stdout;
|
|
170
260
|
const telemetry = telemetrySettings(root);
|
|
171
|
-
if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
|
|
261
|
+
if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
|
|
262
|
+
`);
|
|
172
263
|
if (!telemetry.enabled) return console.log("telemetry disabled");
|
|
173
264
|
const dir = override?.dir ?? telemetry.dir;
|
|
174
265
|
const base = resolveBase(root, opts.base);
|
|
@@ -179,5 +270,4 @@ function main() {
|
|
|
179
270
|
if (specs.length === 0) return console.log("no spec on branch");
|
|
180
271
|
for (const spec of specs) console.log(salvage(root, recordPathFor(dir, spec), base, opts.check));
|
|
181
272
|
}
|
|
182
|
-
|
|
183
273
|
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gauntlet",
|
|
3
|
-
"version": "5.12.
|
|
3
|
+
"version": "5.12.1",
|
|
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",
|
|
@@ -58,9 +58,13 @@
|
|
|
58
58
|
"scripts": {
|
|
59
59
|
"postinstall": "node ./bin/install-agents.mjs",
|
|
60
60
|
"link-agents": "node ./bin/install-agents.mjs",
|
|
61
|
+
"build:bins": "node scripts/build-bins.mjs",
|
|
61
62
|
"test": "node scripts/ci.mjs"
|
|
62
63
|
},
|
|
63
64
|
"dependencies": {
|
|
64
65
|
"yaml": "^2.9.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"esbuild": "0.28.2"
|
|
65
69
|
}
|
|
66
70
|
}
|