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 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
- // 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.
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
- 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"];
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
- const usage = () => {
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])] : undefined;
57
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : void 0;
25
58
  };
26
- const cmpSemver = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
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: undefined, json: false };
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] !== 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];
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 !== undefined && !semver(opts.since)) usage();
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\n`);
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}\n`);
87
+ if (t.warning) process.stderr.write(`warning: ${t.warning}
88
+ `);
58
89
  return join(root, t.dir);
59
90
  }
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;
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
- // 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);
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 !== undefined && realpathSync(top) === abs) {
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
- 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) => {
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 === undefined ? "missing" : String(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
- ? 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
- }));
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
- const stat = (rows, pick) => {
171
- const xs = rows.map(pick).filter((v) => v !== null && v !== undefined);
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
- const versionOrder = (a, b) => {
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
- 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) => {
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
- 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),
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
- 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),
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 === undefined) process.stderr.write(`not a git checkout: ${process.cwd()}\n`);
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 === undefined ? undefined : semver(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) { skipped.push({ file, reason: res.skip }); continue; }
255
- if (seen.has(res.row.run_id)) { skipped.push({ file, reason: "duplicate run_id" }); continue; }
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 === undefined ? "" : ` since: ${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
- // Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
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. 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
- const GIT_TIMEOUT_MS = 10_000;
18
- const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
19
- const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
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
- const usage = () => {
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: undefined, base: undefined, dir: undefined, check: false };
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] !== undefined && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
31
- else if (a === "--base" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
32
- else if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
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\n`);
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 undefined;
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
- const stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
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 !== undefined) {
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 === undefined
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}\n`);
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}\n`);
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.0",
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
  }