pi-gauntlet 8.3.0 → 8.4.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/bin/gauntlet-performance.mjs +477 -64
  4. package/bin/gauntlet-performance.test.mjs +27 -27
  5. package/bin/gauntlet-spec-index.mjs +290 -47
  6. package/bin/gauntlet-spec-index.test.mjs +42 -18
  7. package/bin/gauntlet-telemetry-seal.mjs +220 -77
  8. package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
  9. package/extensions/lib/gauntlet-settings.test.ts +12 -14
  10. package/extensions/lib/gauntlet-settings.ts +7 -16
  11. package/extensions/lib/telemetry-branch.test.ts +102 -0
  12. package/extensions/lib/telemetry-branch.ts +145 -0
  13. package/extensions/lib/telemetry-diff.test.ts +2 -2
  14. package/extensions/lib/telemetry-diff.ts +1 -1
  15. package/extensions/lib/telemetry-leg.ts +163 -0
  16. package/extensions/lib/telemetry-record.ts +2 -0
  17. package/extensions/lib/vcs.ts +6 -23
  18. package/extensions/phase-tracker.test.ts +6 -3
  19. package/extensions/phase-tracker.ts +2 -0
  20. package/extensions/plan-tracker.test.ts +4 -2
  21. package/extensions/plan-tracker.ts +1 -0
  22. package/extensions/telemetry-leg-failures.test.ts +138 -0
  23. package/extensions/telemetry-leg-fields.test.ts +130 -0
  24. package/extensions/telemetry-leg-no-write.test.ts +90 -0
  25. package/extensions/telemetry-leg-rounds.test.ts +157 -0
  26. package/extensions/telemetry-leg-trackers.test.ts +114 -0
  27. package/extensions/telemetry-leg.test.ts +151 -0
  28. package/extensions/telemetry-settlement-contract.test.ts +67 -0
  29. package/extensions/telemetry.test.ts +44 -27
  30. package/extensions/telemetry.ts +68 -48
  31. package/extensions/test-support/git-env.mjs +13 -0
  32. package/package.json +1 -1
  33. package/skills/brainstorming/SKILL.md +1 -1
  34. package/skills/check-delivery/SKILL.md +2 -0
  35. package/skills/finishing-a-development-branch/SKILL.md +13 -13
  36. package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
  37. package/skills/gatekeep-pr/reference/batch.md +3 -1
  38. package/skills/gatekeep-pr/reference/cleanup.md +1 -1
  39. package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
  40. package/skills/gauntlet-resume/reference/seed.md +1 -6
  41. package/skills/shape-ticket/SKILL.md +1 -1
  42. package/skills/shape-ticket/reference/repo-audit.md +2 -2
@@ -6,21 +6,19 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/bins/gauntlet-performance.mjs
9
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
9
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, realpathSync, statSync as statSync2 } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { basename, join, resolve } from "node:path";
12
- import { spawnSync } from "node:child_process";
13
- import process from "node:process";
11
+ import { basename as basename2, join as join3, resolve } from "node:path";
12
+ import { spawnSync as spawnSync2 } from "node:child_process";
13
+ import process2 from "node:process";
14
14
  import { parse as parseYaml } from "yaml";
15
15
 
16
16
  // extensions/lib/gauntlet-settings.ts
17
- import path from "node:path";
18
17
  function mergeGauntlet(preset, repo) {
19
18
  return { ...preset ?? {}, ...repo ?? {} };
20
19
  }
21
20
  var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
22
21
  var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
23
- var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
24
22
  var DEFAULT_TELEMETRY_BUCKETS = [
25
23
  ["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
26
24
  ["docs", ["**/*.md"]],
@@ -30,13 +28,8 @@ function resolveTelemetry(g) {
30
28
  const t = g.telemetry;
31
29
  const warnings = [];
32
30
  const enabled = t?.enabled !== false;
33
- let dir = DEFAULT_TELEMETRY_DIR;
34
- if (t?.dir !== void 0) {
35
- const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
36
- const canonical = value.replace(/\\/g, "/");
37
- const normalized = path.posix.normalize(canonical);
38
- if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
39
- else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
31
+ if (t && Object.hasOwn(t, "dir")) {
32
+ warnings.push("piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
40
33
  }
41
34
  let buckets = DEFAULT_TELEMETRY_BUCKETS;
42
35
  if (t?.buckets !== void 0) {
@@ -45,7 +38,212 @@ function resolveTelemetry(g) {
45
38
  if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
46
39
  else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
47
40
  }
48
- return { enabled, dir, buckets, warning: joinWarn(warnings) };
41
+ const remote = nonEmptyString(t?.remote) ? t.remote.trim() : void 0;
42
+ if (t?.remote !== void 0 && !remote) warnings.push("telemetry.remote must be a non-empty URL; using origin");
43
+ return { enabled, ...remote ? { remote } : {}, buckets, warning: joinWarn(warnings) };
44
+ }
45
+
46
+ // extensions/lib/telemetry-branch.ts
47
+ import { existsSync as existsSync2, mkdtempSync, readFileSync, rmSync } from "node:fs";
48
+ import { tmpdir } from "node:os";
49
+ import { basename, join as join2 } from "node:path";
50
+
51
+ // extensions/lib/checkout.ts
52
+ import { execFileSync } from "node:child_process";
53
+ import { existsSync, statSync } from "node:fs";
54
+ import { dirname, join } from "node:path";
55
+ var CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
56
+ function parseCheckout(r) {
57
+ if (r.code !== 0) return void 0;
58
+ const lines = r.stdout.trim().split("\n").map((l) => l.trim());
59
+ if (lines.length < 3 || !lines[0]) return void 0;
60
+ return { toplevel: lines[0], isPrimary: lines[1] === lines[2] };
61
+ }
62
+ var isDir = (p) => {
63
+ try {
64
+ return statSync(p).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ };
69
+ function nearestExistingDir(absPath) {
70
+ let p = absPath;
71
+ while (!(existsSync(p) && isDir(p))) {
72
+ const parent = dirname(p);
73
+ if (parent === p) return p;
74
+ p = parent;
75
+ }
76
+ return p;
77
+ }
78
+ var JJ_ROOT_ARGS = ["root"];
79
+ function parseJjRoot(r) {
80
+ if (r.code !== 0) return void 0;
81
+ const toplevel = r.stdout.trim();
82
+ if (!toplevel) return void 0;
83
+ return { toplevel, isPrimary: isDir(join(toplevel, ".jj", "repo")) };
84
+ }
85
+ function checkoutOfSync(absPath, git = gitSync, jj = jjSync) {
86
+ const cwd = nearestExistingDir(absPath);
87
+ const g = parseCheckout(git(CHECKOUT_ARGS, cwd));
88
+ if (g) return { ...g, via: "git" };
89
+ const j = parseJjRoot(jj(JJ_ROOT_ARGS, cwd));
90
+ return j ? { ...j, via: "jj" } : void 0;
91
+ }
92
+ var commandSync = (bin) => (args, cwd) => {
93
+ try {
94
+ const stdout = execFileSync(bin, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
95
+ return { code: 0, stdout };
96
+ } catch (e) {
97
+ const status = e.status;
98
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
99
+ }
100
+ };
101
+ var gitSync = commandSync("git");
102
+ var jjSync = commandSync("jj");
103
+
104
+ // extensions/lib/vcs.ts
105
+ import { spawnSync } from "node:child_process";
106
+
107
+ // extensions/lib/phase-tracker-helpers.ts
108
+ var STMT_START = "(?:^|[\\n;&|(])\\s*";
109
+
110
+ // extensions/lib/telemetry-paths.ts
111
+ var GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
112
+ var SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
113
+ var SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
114
+
115
+ // extensions/lib/telemetry-diff.ts
116
+ var JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
117
+ var JJ_FORK_POINT = `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`;
118
+
119
+ // extensions/lib/vcs.ts
120
+ var NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
121
+ var runBinary = (bin) => (args, cwd, timeoutMs = 1e4, options = {}) => {
122
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", input: options.input, env: { ...NO_PROMPT_ENV, ...options.env }, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
123
+ const err = r.error;
124
+ const timedOut = err?.code === "ETIMEDOUT";
125
+ const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
126
+ return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: options.raw ? r.stdout ?? "" : (r.stdout ?? "").trim(), stderr };
127
+ };
128
+ var gitRun = runBinary("git");
129
+
130
+ // extensions/lib/telemetry-branch.ts
131
+ var TELEMETRY_REF = "refs/heads/gauntlet/telemetry";
132
+ function telemetryRepo(root) {
133
+ const result = gitRun(["config", "--get", "remote.origin.url"], root);
134
+ const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
135
+ const path = match?.[1].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
136
+ if (result.ok && path) return path;
137
+ const top = checkoutOfSync(root)?.toplevel ?? root;
138
+ for (const file of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
139
+ const path2 = join2(top, file);
140
+ if (!existsSync2(path2)) continue;
141
+ const section = readFileSync(path2, "utf8").split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
142
+ const repo = /^-[\t ]*repo:[\t ]*(\S[^\r\n]*)$/m.exec(section)?.[1].trim();
143
+ if (repo) return repo;
144
+ break;
145
+ }
146
+ const worktrees = gitRun(["worktree", "list", "--porcelain", "-z"], root);
147
+ const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : void 0;
148
+ return `local/${basename(primary ?? top)}`;
149
+ }
150
+ var trackingRef = (remote) => `refs/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
151
+ var fetchBranch = (root, remote) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
152
+ function syncTelemetryBranch(root, records, remote = "origin") {
153
+ const scratch = mkdtempSync(join2(tmpdir(), "gauntlet-telemetry-index-"));
154
+ const env = { GIT_INDEX_FILE: join2(scratch, "index") };
155
+ const git = (args, input) => {
156
+ const result = gitRun(args, root, void 0, { env, input });
157
+ if (!result.ok) throw new Error(result.stderr);
158
+ return result.stdout;
159
+ };
160
+ const tracking = trackingRef(remote);
161
+ let fetchWarning;
162
+ const fetch = () => {
163
+ const result = fetchBranch(root, remote);
164
+ fetchWarning = result.ok ? void 0 : `fetch failed: ${result.stderr}`;
165
+ return result;
166
+ };
167
+ const resolve2 = (ref) => {
168
+ const result = gitRun(["rev-parse", "--verify", ref], root);
169
+ return result.ok ? result.stdout : void 0;
170
+ };
171
+ const commit = (tree, parent, message) => git(["-c", "commit.gpgsign=false", "commit-tree", tree, ...parent ? ["-p", parent] : [], "-m", message]);
172
+ let committed = false;
173
+ try {
174
+ let tip = resolve2(TELEMETRY_REF);
175
+ committed = records.size === 0 && !!tip;
176
+ fetch();
177
+ let upstream = resolve2(tracking);
178
+ if (records.size) {
179
+ const parent = tip ?? upstream;
180
+ git(parent ? ["read-tree", parent] : ["read-tree", "--empty"]);
181
+ for (const [path, content] of records) {
182
+ const blob = git(["hash-object", "-w", "--stdin"], content);
183
+ git(["update-index", "--add", "--cacheinfo", "100644", blob, path]);
184
+ }
185
+ const tree = git(["write-tree"]);
186
+ const next = parent && git(["rev-parse", `${parent}^{tree}`]) === tree ? parent : commit(tree, parent, "telemetry: update records");
187
+ git(["update-ref", TELEMETRY_REF, next, tip ?? ""]);
188
+ tip = next;
189
+ committed = true;
190
+ }
191
+ const pending = () => tip ? git(["rev-list", "--reverse", "--topo-order", tip, ...upstream ? [`^${upstream}`] : []]).split("\n").filter(Boolean) : [];
192
+ if (!pending().length) return { ok: true, committed, pushed: false, warning: fetchWarning };
193
+ const push = () => gitRun(["push", remote, `${TELEMETRY_REF}:${TELEMETRY_REF}`], root);
194
+ const first = push();
195
+ if (first.ok) return { ok: true, committed, pushed: true, warning: fetchWarning };
196
+ const fetched = fetch();
197
+ if (!fetched.ok) throw new Error(`${first.stderr}; refresh failed: ${fetched.stderr}`);
198
+ upstream = resolve2(tracking);
199
+ let replayed = upstream;
200
+ for (const sha of pending()) {
201
+ git(replayed ? ["read-tree", replayed] : ["read-tree", "--empty"]);
202
+ const changes = git(["diff-tree", "--root", "--no-commit-id", "--raw", "--no-abbrev", "--no-renames", "-r", "-z", sha]).split("\0");
203
+ for (let i = 0; i + 1 < changes.length; i += 2) {
204
+ const [, mode, , blob] = changes[i].split(" ");
205
+ const path = changes[i + 1];
206
+ git(mode === "000000" ? ["update-index", "--force-remove", "--", path] : ["update-index", "--add", "--cacheinfo", mode, blob, path]);
207
+ }
208
+ replayed = commit(git(["write-tree"]), replayed, git(["log", "-1", "--format=%B", sha]));
209
+ }
210
+ if (replayed) git(["update-ref", TELEMETRY_REF, replayed, tip]);
211
+ const second = push();
212
+ if (!second.ok) throw new Error(second.stderr);
213
+ return { ok: true, committed, pushed: true };
214
+ } catch (error) {
215
+ const reason = error.message;
216
+ return { ok: false, committed, pushed: false, reason, warning: [fetchWarning, `telemetry: not pushed (${reason})`].filter(Boolean).join("; ") };
217
+ } finally {
218
+ rmSync(scratch, { recursive: true, force: true });
219
+ }
220
+ }
221
+ function readTelemetryBranch(root, { remote = "origin", prefix = "" } = {}) {
222
+ const records = [];
223
+ let warning;
224
+ try {
225
+ const synced = syncTelemetryBranch(root, /* @__PURE__ */ new Map(), remote);
226
+ warning = synced.warning;
227
+ if (synced.pushed) {
228
+ const fetched = fetchBranch(root, remote);
229
+ warning = fetched.ok ? void 0 : `fetch failed: ${fetched.stderr}`;
230
+ }
231
+ const tip = gitRun(["rev-parse", "--verify", trackingRef(remote)], root);
232
+ if (!tip.ok) return { records, warning: [warning, "no telemetry ref fetched yet"].filter(Boolean).join("; ") };
233
+ const tree = gitRun(["ls-tree", "-r", "-z", tip.stdout, ...prefix ? ["--", prefix] : []], root);
234
+ if (!tree.ok) throw new Error(tree.stderr);
235
+ for (const entry of tree.stdout.split("\0").filter(Boolean)) {
236
+ const [meta, path] = entry.split(" ");
237
+ const [, type, sha] = meta.split(" ");
238
+ if (type !== "blob" || !path.startsWith(prefix) || !path.endsWith(".yaml")) continue;
239
+ const blob = gitRun(["cat-file", "blob", sha], root, void 0, { raw: true });
240
+ if (!blob.ok) throw new Error(blob.stderr);
241
+ records.push({ path, bytes: blob.stdout });
242
+ }
243
+ return { records, warning };
244
+ } catch (error) {
245
+ return { records, warning: [warning, `read failed: ${error.message}`].filter(Boolean).join("; ") };
246
+ }
49
247
  }
50
248
 
51
249
  // src/bins/performance/shared.mjs
@@ -127,7 +325,7 @@ __export(versions_exports, {
127
325
  renderText: () => renderText2
128
326
  });
129
327
  var key2 = "by_version";
130
- var HEADER2 = ["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"];
328
+ var HEADER2 = ["repo", "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"];
131
329
  var versionOrder = (a, b) => {
132
330
  const x = semver(a), y = semver(b);
133
331
  return x && y ? cmpSemver(x, y) : x ? -1 : y ? 1 : 0;
@@ -136,15 +334,17 @@ function aggregate2(entries) {
136
334
  const rows = entries.map((e) => e.row);
137
335
  const groups = /* @__PURE__ */ new Map();
138
336
  for (const r of rows) {
139
- if (!groups.has(r.version)) groups.set(r.version, []);
140
- groups.get(r.version).push(r);
337
+ const key4 = JSON.stringify([r.repo, r.version]);
338
+ if (!groups.has(key4)) groups.set(key4, []);
339
+ groups.get(key4).push(r);
141
340
  }
142
- return [...groups.entries()].sort(([a], [b]) => versionOrder(a, b)).map(([version, all]) => {
341
+ return [...groups.values()].sort(([a], [b]) => a.repo.localeCompare(b.repo) || versionOrder(a.version, b.version)).map((all) => {
143
342
  const shipped = all.filter((r) => r.status === "shipped" && !r.truncated);
144
343
  const models = {};
145
344
  for (const r of shipped) for (const m of r.models) models[m] = (models[m] ?? 0) + 1;
146
345
  return {
147
- version,
346
+ repo: all[0].repo,
347
+ version: all[0].version,
148
348
  n: all.length,
149
349
  shipped: shipped.length,
150
350
  truncated: all.filter((r) => r.truncated).length,
@@ -165,6 +365,7 @@ function aggregate2(entries) {
165
365
  });
166
366
  }
167
367
  var versionRow = (g) => [
368
+ g.repo,
168
369
  g.version,
169
370
  String(g.n),
170
371
  String(g.shipped),
@@ -222,8 +423,8 @@ function aggregate3(entries) {
222
423
  const members = obj(obj(e.council)?.members);
223
424
  if (!members) continue;
224
425
  const roster = Object.keys(members).sort();
225
- const k = roster.join(", ");
226
- if (!groups.has(k)) groups.set(k, { roster, entries: [] });
426
+ const k = JSON.stringify([e.row.repo, roster]);
427
+ if (!groups.has(k)) groups.set(k, { repo: e.row.repo, roster, entries: [] });
227
428
  groups.get(k).entries.push(e);
228
429
  groups.get(k).last = i;
229
430
  }
@@ -249,14 +450,14 @@ function aggregate3(entries) {
249
450
  }
250
451
  for (const m of g.roster) members[m].unique_applied_nonminor_per_run = (members[m].unique_applied.blocker + members[m].unique_applied.major) / runs;
251
452
  const chairList = [...chairs.values()].sort((a, b) => b.runs - a.runs || a.model.localeCompare(b.model)).map((c) => ({ model: c.model, runs: c.runs, avg_clusters: c.clusters / c.runs, avg_members_reported: c.reported / c.runs, retried_runs: c.retried_runs }));
252
- return { roster: g.roster, runs, members, chairs: chairList, flags: runs >= MIN_RUNS ? flagsFor(members, runs) : [] };
453
+ return { repo: g.repo, roster: g.roster, runs, members, chairs: chairList, flags: runs >= MIN_RUNS ? flagsFor(members, runs) : [] };
253
454
  });
254
455
  }
255
456
  function renderText3(agg) {
256
457
  if (!agg.length) return "council\nno council data in selected records";
257
458
  const out = ["council", "severity is the chair's consolidated severity (highest any co-raiser assigned), not the member's own"];
258
459
  for (const g of agg) {
259
- out.push(`roster (${g.runs} runs): ${g.roster.join(", ")}`);
460
+ out.push(`repo: ${g.repo}`, `roster (${g.runs} runs): ${g.roster.join(", ")}`);
260
461
  const rows = g.roster.map((m) => {
261
462
  const x = g.members[m];
262
463
  return [m, cell(x.total), cell(x.unique), cell(x.applied), cell(x.unique_applied), cell(x.deferred), cell(x.rejected), x.unique_applied_nonminor_per_run.toFixed(2)];
@@ -269,11 +470,196 @@ function renderText3(agg) {
269
470
  return out.join("\n");
270
471
  }
271
472
 
473
+ // src/bins/performance/leg-rounds.mjs
474
+ var LEG_SKILLS = ["shape-ticket", "gatekeep-pr", "check-delivery"];
475
+ function loadLeg(doc) {
476
+ if (![doc.session, doc.ts].every((v) => typeof v === "string" && v.length) || !Array.isArray(doc.items) || !doc.items.every((item) => obj(item) && ["ticket", "pr"].includes(item.kind) && str(item.outcome) !== null && (item.key === void 0 || str(item.key) !== null))) {
477
+ return { skip: "not a leg record" };
478
+ }
479
+ return { leg: { ...doc, repo: str(doc.repo) ?? "unknown repo" } };
480
+ }
481
+ var legId = (leg) => JSON.stringify([leg.repo, leg.skill, leg.session, leg.ts]);
482
+ function itemKey(key4, repo) {
483
+ if (!key4) return null;
484
+ const ref = key4.replace(/@[a-f\d]+$/i, "");
485
+ if (/^#\d+$/.test(ref)) return `${repo}${ref}`;
486
+ const github = /^https?:\/\/[^/]+\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)\/?$/.exec(ref);
487
+ if (github) return `${github[1]}#${github[2]}`;
488
+ const linear = /^https?:\/\/[^/]+\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)(?:\/.*)?$/.exec(ref);
489
+ return linear ? linear[1] : ref;
490
+ }
491
+ function groupRounds(legs, skill) {
492
+ const groups = /* @__PURE__ */ new Map();
493
+ for (const leg of legs.filter((leg2) => leg2.skill === skill).sort((a, b) => a.ts.localeCompare(b.ts) || a.session.localeCompare(b.session))) {
494
+ const keys = new Set(leg.items.map((item) => itemKey(item.key, leg.repo)).filter(Boolean));
495
+ for (const key4 of keys) {
496
+ const id2 = JSON.stringify([leg.repo, key4]);
497
+ if (!groups.has(id2)) groups.set(id2, { repo: leg.repo, key: key4, rounds: [] });
498
+ groups.get(id2).rounds.push(leg);
499
+ }
500
+ }
501
+ return [...groups.values()].sort((a, b) => a.repo.localeCompare(b.repo) || a.key.localeCompare(b.key));
502
+ }
503
+
504
+ // src/bins/performance/costs.mjs
505
+ var allocationNote = "costs: batch and multi-ticket records are split equally across their items; the rounding remainder lands on the last row";
506
+ var columns = ["shape", "run", "gatekeep", "delivery"];
507
+ var skillColumn = { "shape-ticket": "shape", "gatekeep-pr": "gatekeep", "check-delivery": "delivery" };
508
+ var id = (repo, key4) => JSON.stringify([repo, key4]);
509
+ var ordered = (map) => [...map.values()].sort((a, b) => a.repo.localeCompare(b.repo) || a.key.localeCompare(b.key));
510
+ var add = (row, column, cost) => {
511
+ if (cost !== null) row[column] = (row[column] ?? 0) + cost;
512
+ };
513
+ var allocations = /* @__PURE__ */ new WeakMap();
514
+ var split = (cost, count) => Array(count).fill(cost === null ? null : cost / count);
515
+ function aggregateCosts(entries, legs) {
516
+ const tickets = /* @__PURE__ */ new Map(), prs = /* @__PURE__ */ new Map(), unattributed = /* @__PURE__ */ new Map();
517
+ const splits = [], links = [], cells = /* @__PURE__ */ new Map();
518
+ const cell2 = (row, column) => {
519
+ if (!cells.has(row)) cells.set(row, /* @__PURE__ */ new Map());
520
+ const columns2 = cells.get(row);
521
+ if (!columns2.has(column)) columns2.set(column, { row, column });
522
+ return columns2.get(column);
523
+ };
524
+ const ensure = (map, repo, key4) => {
525
+ const identity = id(repo, key4);
526
+ if (!map.has(identity)) map.set(identity, { repo, key: key4, shape: null, run: null, gatekeep: null, delivery: null, rounds: [] });
527
+ return map.get(identity);
528
+ };
529
+ for (const { row } of entries) {
530
+ const keys = [...new Set(row.tickets.map((ref) => itemKey(ref, row.repo)))].sort();
531
+ const shares = split(row.cost, keys.length || 1);
532
+ if (!keys.length) add(ensure(unattributed, row.repo, row.session), "run", shares[0]);
533
+ const recipients = keys.map((key4) => ensure(tickets, row.repo, key4));
534
+ recipients.forEach((recipient, i) => add(recipient, "run", shares[i]));
535
+ splits.push(recipients.map((row2) => cell2(row2, "run")));
536
+ }
537
+ const ticketPrs = /* @__PURE__ */ new Map();
538
+ for (const leg of [...legs].sort((a, b) => a.ts.localeCompare(b.ts) || a.session.localeCompare(b.session))) {
539
+ const items = [...leg.items].sort((a, b) => (itemKey(a.key, leg.repo) ?? "\uFFFF").localeCompare(itemKey(b.key, leg.repo) ?? "\uFFFF"));
540
+ const shares = split(num(leg.cost), items.length || 1);
541
+ const recipients = [];
542
+ if (!items.length) add(ensure(unattributed, leg.repo, leg.session), skillColumn[leg.skill], shares[0]);
543
+ const roundShares = /* @__PURE__ */ new Map();
544
+ items.forEach((item, i) => {
545
+ const key4 = itemKey(item.key, leg.repo);
546
+ const ticket = item.kind === "ticket" ? key4 : itemKey(item.ticket, leg.repo);
547
+ const row = ticket ? ensure(tickets, leg.repo, ticket) : ensure(unattributed, leg.repo, item.kind === "pr" && key4 ? key4 : leg.session);
548
+ add(row, skillColumn[leg.skill], shares[i]);
549
+ recipients.push(cell2(row, skillColumn[leg.skill]));
550
+ if (item.kind !== "pr" || !key4 || leg.skill !== "gatekeep-pr") return;
551
+ const pr = ensure(prs, leg.repo, key4);
552
+ if (!roundShares.has(pr)) roundShares.set(pr, null);
553
+ roundShares.set(pr, sumOrNull([roundShares.get(pr), shares[i]]));
554
+ if (ticket) {
555
+ const identity = id(leg.repo, ticket);
556
+ if (!ticketPrs.has(identity)) ticketPrs.set(identity, /* @__PURE__ */ new Set());
557
+ ticketPrs.get(identity).add(pr);
558
+ }
559
+ });
560
+ splits.push(recipients);
561
+ const roundCells = [];
562
+ for (const [pr, cost] of roundShares) {
563
+ pr.rounds.push(leg);
564
+ (pr.round_costs ??= []).push(cost);
565
+ roundCells.push(cell2(pr.round_costs, pr.round_costs.length - 1));
566
+ }
567
+ splits.push(roundCells);
568
+ }
569
+ for (const [identity, linked] of ticketPrs) {
570
+ const cost = tickets.get(identity).run;
571
+ const rows = [...linked].sort((a, b) => a.key.localeCompare(b.key));
572
+ const shares = split(cost, rows.length);
573
+ rows.forEach((row, i) => add(row, "run", shares[i]));
574
+ links.push({ source: cell2(tickets.get(identity), "run"), targets: rows.map((row) => cell2(row, "run")) });
575
+ }
576
+ for (const group of groupRounds(legs, "shape-ticket")) {
577
+ const row = tickets.get(id(group.repo, group.key));
578
+ if (row) row.rounds = group.rounds;
579
+ }
580
+ for (const row of [...tickets.values(), ...unattributed.values()]) row.total = sumOrNull(columns.map((column) => row[column]));
581
+ const prRows = ordered(prs);
582
+ for (const row of prRows) {
583
+ for (const column of ["shape", "gatekeep", "delivery"]) delete row[column];
584
+ row.total = sumOrNull([row.run, ...row.round_costs]);
585
+ }
586
+ const result = { tickets: ordered(tickets), prs: prRows, unattributed: ordered(unattributed) };
587
+ allocations.set(result, { splits, links });
588
+ return result;
589
+ }
590
+ function displayCosts(costs) {
591
+ const copies = /* @__PURE__ */ new Map();
592
+ const result = Object.fromEntries(Object.entries(costs).map(([table2, rows]) => [table2, rows.map((row) => {
593
+ const copy = { ...row };
594
+ for (const column of columns) if (typeof copy[column] === "number") copy[column] = Math.round(copy[column] * 100) / 100;
595
+ copies.set(row, copy);
596
+ if (row.round_costs) {
597
+ copy.round_costs = row.round_costs.map((cost) => cost === null ? null : Math.round(cost * 100) / 100);
598
+ copies.set(row.round_costs, copy.round_costs);
599
+ }
600
+ return copy;
601
+ })]));
602
+ const { splits, links } = allocations.get(costs);
603
+ const order = new Map([...copies.keys()].map((row, i) => [row, i]));
604
+ const round = (groups, linked = false) => {
605
+ const components = [];
606
+ for (const group of groups) {
607
+ const cells = new Set(linked ? group.targets : group);
608
+ const sources = new Set(linked ? [group.source] : []);
609
+ for (let i = components.length - 1; i >= 0; i--) {
610
+ if (![...components[i].cells].some((cell2) => cells.has(cell2))) continue;
611
+ for (const cell2 of components[i].cells) cells.add(cell2);
612
+ for (const source of components[i].sources) sources.add(source);
613
+ components.splice(i, 1);
614
+ }
615
+ if (cells.size) components.push({ cells, sources });
616
+ }
617
+ for (const { cells, sources } of components) {
618
+ const active = [...cells].filter(({ row, column }) => row[column] !== null).sort((a, b) => order.get(a.row) - order.get(b.row));
619
+ const total = sumOrNull([...linked ? sources : active].map(({ row, column }) => (linked ? copies.get(row) : row)[column]));
620
+ const shares = active.map(({ row, column }, index) => {
621
+ const value = row[column] * 100;
622
+ const cents = Math.trunc(value);
623
+ return { row, column, index, cents, fraction: value - cents };
624
+ });
625
+ const residual = Math.round(total * 100) - shares.reduce((sum2, share) => sum2 + share.cents, 0);
626
+ const direction = Math.sign(residual);
627
+ const ranked = shares.filter((share) => share.fraction * direction > 0).sort((a, b) => direction * (b.fraction - a.fraction) || (Math.abs(a.fraction) < 0.5 ? b.index - a.index : a.index - b.index));
628
+ const distributed = Math.min(Math.abs(residual), ranked.length);
629
+ for (let i = 0; i < distributed; i++) ranked[i].cents += direction;
630
+ if (distributed < Math.abs(residual)) shares.at(-1).cents += residual - distributed * direction;
631
+ for (const { row, column, cents } of shares) copies.get(row)[column] = cents / 100;
632
+ }
633
+ };
634
+ round(splits);
635
+ round(links, true);
636
+ for (const rows of Object.values(costs)) for (const row of rows) {
637
+ const copy = copies.get(row);
638
+ const displayed = copy.round_costs ? [copy.run, ...copy.round_costs] : columns.map((column) => copy[column]);
639
+ copy.total = sumOrNull(displayed);
640
+ }
641
+ return result;
642
+ }
643
+ function renderCosts(aggregate4) {
644
+ const { tickets, prs, unattributed } = displayCosts(aggregate4);
645
+ const roundCount = Math.max(0, ...prs.map((row) => row.round_costs.length));
646
+ const costs = (rows) => rows.map((row) => [row.repo, row.key, ...columns.map((column) => fmtCost(row[column])), fmtCost(row.total)]);
647
+ return [
648
+ `tickets
649
+ ${table(["repo", "ticket", "rounds", ...columns, "total"], costs(tickets).map((cells, i) => [...cells.slice(0, 2), String(tickets[i].rounds.length), ...cells.slice(2)]))}`,
650
+ `prs
651
+ ${table(["repo", "PR", "rounds", "run", ...Array.from({ length: roundCount }, (_, i) => `round ${i + 1}`), "total"], prs.map((row) => [row.repo, row.key, String(row.rounds.length), fmtCost(row.run), ...Array.from({ length: roundCount }, (_, i) => fmtCost(row.round_costs[i] ?? null)), fmtCost(row.total)]))}`,
652
+ `unattributed
653
+ ${table(["repo", "PR/session", ...columns, "total"], costs(unattributed))}`,
654
+ allocationNote
655
+ ].join("\n\n");
656
+ }
657
+
272
658
  // src/bins/gauntlet-performance.mjs
273
659
  var SECTIONS = [runs_exports, versions_exports, council_exports];
274
660
  var usage = () => {
275
- process.stderr.write("usage: gauntlet-performance [--dir <repo root or telemetry dir>]... [--since <version>] [--json]\n");
276
- process.exit(1);
661
+ process2.stderr.write("usage: gauntlet-performance [--dir <telemetry dir>]... [--since <version>] [--json]\n");
662
+ process2.exit(1);
277
663
  };
278
664
  function parseArgs(argv) {
279
665
  const opts = { dirs: [], since: void 0, json: false };
@@ -288,57 +674,61 @@ function parseArgs(argv) {
288
674
  return opts;
289
675
  }
290
676
  function readLayer(file) {
291
- if (!existsSync(file)) return {};
677
+ if (!existsSync3(file)) return {};
292
678
  try {
293
- return JSON.parse(readFileSync(file, "utf8"));
679
+ return JSON.parse(readFileSync2(file, "utf8"));
294
680
  } catch (e) {
295
- process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
681
+ process2.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
296
682
  `);
297
683
  return {};
298
684
  }
299
685
  }
300
- function telemetryDirOf(root) {
301
- const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
302
- const preset = readLayer(join(agentDir, "settings.json"));
303
- const repo = readLayer(join(root, ".pi", "settings.json"));
686
+ function branchCorpus(root) {
687
+ const agentDir = process2.env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent");
688
+ const preset = readLayer(join3(agentDir, "settings.json"));
689
+ const repo = readLayer(join3(root, ".pi", "settings.json"));
304
690
  const t = resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
305
- if (t.warning) process.stderr.write(`warning: ${t.warning}
691
+ if (t.warning) process2.stderr.write(`warning: ${t.warning}
692
+ `);
693
+ const label = telemetryRepo(root);
694
+ if (!t.enabled) {
695
+ process2.stderr.write("telemetry disabled\n");
696
+ return { label, records: [] };
697
+ }
698
+ const result = readTelemetryBranch(root, { remote: t.remote, prefix: `${label}/` });
699
+ if (result.warning) process2.stderr.write(`warning: ${result.warning}
306
700
  `);
307
- return join(root, t.dir);
701
+ return { label, records: result.records };
308
702
  }
309
703
  var gitToplevel = (cwd) => {
310
- const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1e4 });
704
+ const r = spawnSync2("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1e4 });
311
705
  return r.status === 0 ? r.stdout.trim() : void 0;
312
706
  };
313
- function corpusFor(path2) {
314
- const label = basename(resolve(path2));
315
- if (!existsSync(path2)) return { label, skip: "not found" };
316
- const abs = realpathSync(path2);
317
- const top = gitToplevel(abs);
318
- if (top !== void 0 && realpathSync(top) === abs) {
319
- const dir = telemetryDirOf(abs);
320
- return existsSync(dir) ? { label, dir } : { label, skip: "no telemetry dir" };
321
- }
322
- if (!statSync(abs).isDirectory()) return { label, skip: "not a directory" };
707
+ function corpusFor(path) {
708
+ const label = basename2(resolve(path));
709
+ if (!existsSync3(path)) return { label, skip: "not found" };
710
+ const abs = realpathSync(path);
711
+ if (!statSync2(abs).isDirectory()) return { label, skip: "not a directory" };
323
712
  return { label, dir: abs };
324
713
  }
325
714
  function* yamlFiles(dir) {
326
- if (!existsSync(dir)) return;
715
+ if (!existsSync3(dir)) return;
327
716
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
328
- const p = join(dir, e.name);
717
+ const p = join3(dir, e.name);
329
718
  if (e.isDirectory()) yield* yamlFiles(p);
330
719
  else if (e.isFile() && e.name.endsWith(".yaml")) yield p;
331
720
  }
332
721
  }
333
- function loadRecord(file, label) {
722
+ function loadRecord(file, bytes) {
334
723
  let doc;
335
724
  try {
336
- doc = parseYaml(readFileSync(file, "utf8"));
725
+ doc = parseYaml(bytes);
337
726
  } catch {
338
727
  return { skip: "unparseable" };
339
728
  }
340
729
  const d = obj(doc);
341
730
  if (!d) return { skip: "unparseable" };
731
+ if (LEG_SKILLS.includes(d.skill)) return loadLeg(d);
342
732
  if (d.schema !== 1) return { skip: `schema ${d.schema === void 0 ? "missing" : String(d.schema)}` };
343
733
  if (typeof d.spec !== "string" || typeof d.run_id !== "string") return { skip: "not a record" };
344
734
  const derived = obj(d.derived) ?? {};
@@ -386,10 +776,12 @@ function loadRecord(file, label) {
386
776
  return {
387
777
  row: {
388
778
  run_id: d.run_id,
779
+ session: Array.isArray(d.sessions) && typeof d.sessions[0] === "string" ? d.sessions[0] : d.run_id,
780
+ tickets: Array.isArray(d.tickets) ? d.tickets.filter((ref) => typeof ref === "string" && ref.length) : [],
389
781
  engine: str(derived.engine) ?? "waves",
390
782
  iterations: num(derived.iterations),
391
- repo: label,
392
- spec: basename(file, ".yaml"),
783
+ repo: str(d.repo) ?? "unknown repo",
784
+ spec: basename2(file, ".yaml"),
393
785
  version: semver(version) ? version.trim() : "unknown",
394
786
  status: str(d.status),
395
787
  created_at: str(d.created_at),
@@ -423,13 +815,15 @@ function loadRecord(file, label) {
423
815
  };
424
816
  }
425
817
  function main() {
426
- const opts = parseArgs(process.argv.slice(2));
818
+ const opts = parseArgs(process2.argv.slice(2));
427
819
  const corpora = [];
428
820
  const skipped = [];
429
- const top = gitToplevel(process.cwd());
430
- if (top === void 0) process.stderr.write(`not a git checkout: ${process.cwd()}
821
+ if (!opts.dirs.length) {
822
+ const top = gitToplevel(process2.cwd());
823
+ if (top === void 0) process2.stderr.write(`not a git checkout: ${process2.cwd()}
431
824
  `);
432
- else corpora.push({ label: basename(top), dir: telemetryDirOf(top) });
825
+ else corpora.push(branchCorpus(top));
826
+ }
433
827
  for (const p of opts.dirs) {
434
828
  const c = corpusFor(p);
435
829
  if (c.skip) skipped.push({ file: p, reason: c.skip });
@@ -437,21 +831,38 @@ function main() {
437
831
  }
438
832
  const since = opts.since === void 0 ? void 0 : semver(opts.since);
439
833
  const seen = /* @__PURE__ */ new Set();
834
+ const seenLegs = /* @__PURE__ */ new Set();
440
835
  const corpus = {};
441
836
  const entries = [];
837
+ const legs = [];
442
838
  for (const c of corpora) {
443
839
  corpus[c.label] = corpus[c.label] ?? 0;
444
- for (const file of yamlFiles(c.dir)) {
445
- const res = loadRecord(file, c.label);
840
+ const records = c.records ?? [...yamlFiles(c.dir)].map((path) => ({ path, bytes: readFileSync2(path, "utf8") }));
841
+ for (const { path: file, bytes } of records) {
842
+ const res = loadRecord(file, bytes);
446
843
  if (res.skip) {
447
844
  skipped.push({ file, reason: res.skip });
448
845
  continue;
449
846
  }
450
- if (seen.has(res.row.run_id)) {
847
+ if (res.leg) {
848
+ const id2 = legId(res.leg);
849
+ if (seenLegs.has(id2)) {
850
+ skipped.push({ file, reason: "duplicate leg" });
851
+ continue;
852
+ }
853
+ seenLegs.add(id2);
854
+ const v2 = semver(res.leg.gauntlet_version);
855
+ if (since && (!v2 || cmpSemver(v2, since) < 0)) continue;
856
+ corpus[c.label]++;
857
+ legs.push(res.leg);
858
+ continue;
859
+ }
860
+ const runId = JSON.stringify([res.row.repo, res.row.run_id]);
861
+ if (seen.has(runId)) {
451
862
  skipped.push({ file, reason: "duplicate run_id" });
452
863
  continue;
453
864
  }
454
- seen.add(res.row.run_id);
865
+ seen.add(runId);
455
866
  const v = semver(res.row.version);
456
867
  if (since && (!v || cmpSemver(v, since) < 0)) continue;
457
868
  corpus[c.label]++;
@@ -460,20 +871,22 @@ function main() {
460
871
  }
461
872
  entries.sort((a, b) => (a.row.created_at ?? "").localeCompare(b.row.created_at ?? "") || a.row.run_id.localeCompare(b.row.run_id));
462
873
  const aggregates = SECTIONS.map((s) => [s, s.aggregate(entries)]);
874
+ const hasRecords = entries.length + legs.length > 0;
463
875
  if (opts.json) {
464
876
  const out = { corpus, since: opts.since ?? null };
465
877
  for (const [s, agg] of aggregates) out[s.key] = agg;
878
+ Object.assign(out, aggregateCosts(entries, legs));
466
879
  out.skipped = skipped;
467
880
  console.log(JSON.stringify(out, null, 2));
468
881
  return;
469
882
  }
470
883
  console.log(`corpus: ${Object.entries(corpus).map(([k, v]) => `${k}=${v}`).join(", ")}${opts.since === void 0 ? "" : ` since: ${opts.since}`}`);
471
- if (entries.length === 0) {
472
- console.log(`no records found in ${corpora.map((c) => c.dir).join(", ") || process.cwd()}`);
884
+ if (!hasRecords) {
885
+ console.log(`no records found in ${corpora.map((c) => c.dir ?? c.label).join(", ") || process2.cwd()}`);
473
886
  } else {
474
- console.log(aggregates.map(([s, agg]) => s.renderText(agg)).join("\n\n"));
887
+ console.log([...aggregates.map(([s, agg]) => s.renderText(agg)), renderCosts(aggregateCosts(entries, legs, true))].join("\n\n"));
475
888
  }
476
- if (entries.length && skipped.length) console.log("");
477
- if (entries.length) for (const s of skipped) console.log(`skipped: ${s.file}: ${s.reason}`);
889
+ if (hasRecords && skipped.length) console.log("");
890
+ if (hasRecords) for (const s of skipped) console.log(`skipped: ${s.file}: ${s.reason}`);
478
891
  }
479
892
  main();