pi-gauntlet 8.3.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +3 -3
  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/telemetry-leg-failures.test.ts +138 -0
  19. package/extensions/telemetry-leg-fields.test.ts +130 -0
  20. package/extensions/telemetry-leg-no-write.test.ts +90 -0
  21. package/extensions/telemetry-leg-rounds.test.ts +157 -0
  22. package/extensions/telemetry-leg-trackers.test.ts +114 -0
  23. package/extensions/telemetry-leg.test.ts +151 -0
  24. package/extensions/telemetry-settlement-contract.test.ts +67 -0
  25. package/extensions/telemetry.test.ts +44 -27
  26. package/extensions/telemetry.ts +68 -48
  27. package/extensions/test-support/git-env.mjs +13 -0
  28. package/package.json +1 -1
  29. package/skills/brainstorming/SKILL.md +1 -1
  30. package/skills/check-delivery/SKILL.md +2 -0
  31. package/skills/finishing-a-development-branch/SKILL.md +13 -13
  32. package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
  33. package/skills/gatekeep-pr/reference/batch.md +3 -1
  34. package/skills/gatekeep-pr/reference/cleanup.md +1 -1
  35. package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
  36. package/skills/gauntlet-resume/reference/seed.md +1 -6
  37. package/skills/shape-ticket/SKILL.md +1 -1
  38. package/skills/shape-ticket/reference/repo-audit.md +2 -2
@@ -6,9 +6,28 @@ import { tmpdir } from "node:os";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { DatabaseSync } from "node:sqlite";
9
+ import { isolatedGitEnv } from "../extensions/test-support/git-env.mjs";
9
10
 
10
11
  const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-spec-index.mjs");
11
12
  const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gsi-empty-agent-"));
13
+ const GIT_ENV = isolatedGitEnv(EMPTY_AGENT);
14
+ process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
15
+ const git = (root, args, options = {}) => {
16
+ const result = spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
17
+ cwd: root, encoding: "utf8", ...options, env: { ...process.env, ...GIT_ENV, ...options.env },
18
+ });
19
+ assert.equal(result.status, 0, result.stderr);
20
+ return result.stdout.trim();
21
+ };
22
+ const telemetry = (root, path, text) => {
23
+ const env = { GIT_INDEX_FILE: join(root, ".git/telemetry-index") };
24
+ git(root, ["read-tree", "gauntlet/telemetry"], { env });
25
+ const blob = git(root, ["hash-object", "-w", "--stdin"], { input: text });
26
+ git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, `team/product/brainstorming/${path}`], { env });
27
+ const tree = git(root, ["write-tree"], { env });
28
+ const commit = git(root, ["commit-tree", tree, "-p", "gauntlet/telemetry", "-m", "telemetry fixture"]);
29
+ git(root, ["update-ref", "refs/heads/gauntlet/telemetry", commit]);
30
+ };
12
31
  const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
13
32
  const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
14
33
  const DOCS_HEADER = ["score", "path", "title", "snippet"];
@@ -35,12 +54,17 @@ const write = (root, rel, text) => {
35
54
 
36
55
  const gitRepo = () => {
37
56
  const root = mkdtempSync(join(tmpdir(), "gsi-"));
38
- spawnSync("git", ["init", "-q"], { cwd: root });
57
+ git(root, ["init", "-q"]);
58
+ git(root, ["remote", "add", "origin", "https://example.test/team/product.git"]);
59
+ git(root, ["config", `url.${root}.insteadOf`, "https://example.test/team/product.git"]);
60
+ const tree = git(root, ["mktree"], { input: "" });
61
+ const tip = git(root, ["commit-tree", tree, "-m", "empty telemetry fixture"]);
62
+ git(root, ["update-ref", "refs/heads/gauntlet/telemetry", tip]);
39
63
  return root;
40
64
  };
41
65
  const commit = (root) => {
42
- spawnSync("git", ["add", "-A"], { cwd: root });
43
- spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "fixture"], { cwd: root });
66
+ git(root, ["add", "-A"]);
67
+ git(root, ["commit", "-q", "-m", "fixture"]);
44
68
  };
45
69
  // Fillers keep the corpus large enough that two-document probe terms stay below N/2.
46
70
  const filler = (n) => `# Filler ${n}\n\n**Goal:** filler goal ${n}.\n\n## Design\n\nnothing of note here\n`;
@@ -69,7 +93,7 @@ const repo = () => {
69
93
  };
70
94
 
71
95
  const run = (cwd, args) => {
72
- const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env, PI_CODING_AGENT_DIR: EMPTY_AGENT } });
96
+ const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env, ...GIT_ENV, PI_CODING_AGENT_DIR: EMPTY_AGENT } });
73
97
  const lines = r.stdout.split("\n").filter(Boolean);
74
98
  return { status: r.status, stderr: r.stderr, stdout: r.stdout, header: lines[0]?.split("\t"), rows: lines.slice(1).map((l) => l.split("\t")) };
75
99
  };
@@ -129,7 +153,7 @@ test("1: corpus boundary, ordering, draft skip, git status clean, exclude writte
129
153
  assert.equal(r1.status, 0, r1.stderr);
130
154
  assert.deepEqual(paths(r1), ["doc/specs/a.md", "svc-a/doc/specs/b.md"]);
131
155
  assert.deepEqual(r1.rows.map((r) => r[2]), ["root", "svc-a"]);
132
- const status = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).stdout;
156
+ const status = git(root, ["status", "--porcelain"]);
133
157
  assert.equal(status, "");
134
158
  run(root, ["--query", "zephyr widget"]);
135
159
  const exclude = readFileSync(join(root, ".git/info/exclude"), "utf8");
@@ -190,16 +214,16 @@ test("6: telemetry join is output-only and tolerant", (t) => {
190
214
  const filesBefore = withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all()));
191
215
  write(root, "x", "x\n");
192
216
  write(root, "y", "y\n");
193
- write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nshipped_at: 2026-09-17T10:00:00Z\nderived:\n modified_files:\n - x\n - y\n");
217
+ telemetry(root, "doc/specs/a.yaml", "status: shipped\nshipped_at: 2026-09-17T10:00:00Z\nderived:\n modified_files:\n - x\n - y\n");
194
218
  let r = run(root, ["--query", "zephyr widget"]);
195
219
  assert.deepEqual(r.rows[0].slice(4, 8), ["shipped", "2026-09-17T10:00:00Z", "live", "x;y"]);
196
220
  assert.equal(r.rows[0].length, 9);
197
221
  assert.equal(r.rows[1][7], "");
198
222
  assert.equal(withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all())), filesBefore);
199
- write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: in_progress\nshipped_at: 2026-09-18T10:00:00Z\n");
223
+ telemetry(root, "doc/specs/a.yaml", "status: in_progress\nshipped_at: 2026-09-18T10:00:00Z\n");
200
224
  r = run(root, ["--query", "zephyr widget"]);
201
225
  assert.deepEqual(r.rows[0].slice(4, 8), ["in_progress", "2026-09-18T10:00:00Z", "live", "missing"]);
202
- write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: [unclosed\n");
226
+ telemetry(root, "doc/specs/a.yaml", "status: [unclosed\n");
203
227
  r = run(root, ["--query", "zephyr widget"]);
204
228
  assert.equal(r.status, 0);
205
229
  assert.deepEqual(r.rows[0].slice(4, 8), ["", "", "live", ""]);
@@ -213,8 +237,8 @@ test("6b: files cell is the AC fixture - present paths kept, gone paths dropped,
213
237
  write(root, "doc/specs/b.md", "# Beta quokka wallaby\n\n**Goal:** quokka too.\n");
214
238
  for (const n of ["one", "two", "three"]) write(root, `doc/specs/filler-${n}.md`, filler(n));
215
239
  write(root, "src/x.ts", "export {};\n");
216
- write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nderived:\n modified_files:\n - src/x.ts\n - src/gone.ts\n");
217
- write(root, ".pi/gauntlet/telemetry/doc/specs/b.yaml", "status: shipped\n");
240
+ telemetry(root, "doc/specs/a.yaml", "status: shipped\nderived:\n modified_files:\n - src/x.ts\n - src/gone.ts\n");
241
+ telemetry(root, "doc/specs/b.yaml", "status: shipped\n");
218
242
  commit(root);
219
243
  const r = run(root, ["--query", "quokka wallaby"]);
220
244
  assert.equal(r.status, 0, r.stderr);
@@ -227,26 +251,26 @@ test("6b: files cell is the AC fixture - present paths kept, gone paths dropped,
227
251
  test("6c: files cell edge cases - order, spaces kept, all gone, non-array, null record", (t) => {
228
252
  const root = repo();
229
253
  t.after(() => rmSync(root, { recursive: true, force: true }));
230
- const yaml = ".pi/gauntlet/telemetry/doc/specs/a.yaml";
254
+ const yaml = "doc/specs/a.yaml";
231
255
  const files = () => run(root, ["--query", "zephyr widget"]).rows[0][7];
232
256
  write(root, "b.js", "");
233
257
  write(root, "a.js", "");
234
258
  write(root, "sp aced.txt", "");
235
- write(root, yaml, "status: shipped\nderived:\n modified_files:\n - a.js\n - b.js\n");
259
+ telemetry(root, yaml, "status: shipped\nderived:\n modified_files:\n - a.js\n - b.js\n");
236
260
  assert.equal(files(), "a.js;b.js");
237
261
  write(root, "tab\there.js", "");
238
- write(root, yaml, 'status: shipped\nderived:\n modified_files:\n - a.js\n - "tab\\there.js"\n');
262
+ telemetry(root, yaml, 'status: shipped\nderived:\n modified_files:\n - a.js\n - "tab\\there.js"\n');
239
263
  assert.equal(files(), "a.js;tab here.js");
240
264
  assert.equal(run(root, ["--query", "zephyr widget"]).rows[0].length, 9);
241
- write(root, yaml, "status: shipped\nderived:\n modified_files:\n - 'sp aced.txt'\n - 7\n");
265
+ telemetry(root, yaml, "status: shipped\nderived:\n modified_files:\n - 'sp aced.txt'\n - 7\n");
242
266
  assert.equal(files(), "sp aced.txt");
243
- write(root, yaml, "status: shipped\nderived:\n modified_files:\n - nope1\n - nope2\n");
267
+ telemetry(root, yaml, "status: shipped\nderived:\n modified_files:\n - nope1\n - nope2\n");
244
268
  assert.equal(files(), "");
245
- write(root, yaml, 'status: shipped\nderived:\n modified_files: "x"\n');
269
+ telemetry(root, yaml, 'status: shipped\nderived:\n modified_files: "x"\n');
246
270
  assert.equal(files(), "missing");
247
- write(root, yaml, "");
271
+ telemetry(root, yaml, "");
248
272
  assert.equal(files(), "");
249
- write(root, yaml, "- a\n- b\n");
273
+ telemetry(root, yaml, "- a\n- b\n");
250
274
  assert.equal(files(), "");
251
275
  });
252
276
 
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bins/gauntlet-telemetry-seal.mjs
4
- import { existsSync as existsSync2, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, realpathSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { isAbsolute as isAbsolute2, join as join2, relative as relative2 } from "node:path";
6
+ import { isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve2 } from "node:path";
7
7
  import process2 from "node:process";
8
8
 
9
9
  // extensions/lib/gauntlet-settings.ts
10
- import path from "node:path";
11
10
  function mergeGauntlet(preset, repo) {
12
11
  return { ...preset ?? {}, ...repo ?? {} };
13
12
  }
@@ -41,13 +40,8 @@ function resolveTelemetry(g) {
41
40
  const t = g.telemetry;
42
41
  const warnings = [];
43
42
  const enabled = t?.enabled !== false;
44
- let dir = DEFAULT_TELEMETRY_DIR;
45
- if (t?.dir !== void 0) {
46
- const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
47
- const canonical = value.replace(/\\/g, "/");
48
- const normalized = path.posix.normalize(canonical);
49
- if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
50
- else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
43
+ if (t && Object.hasOwn(t, "dir")) {
44
+ warnings.push("piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
51
45
  }
52
46
  let buckets = DEFAULT_TELEMETRY_BUCKETS;
53
47
  if (t?.buckets !== void 0) {
@@ -56,7 +50,9 @@ function resolveTelemetry(g) {
56
50
  if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
57
51
  else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
58
52
  }
59
- return { enabled, dir, buckets, warning: joinWarn(warnings) };
53
+ const remote = nonEmptyString(t?.remote) ? t.remote.trim() : void 0;
54
+ if (t?.remote !== void 0 && !remote) warnings.push("telemetry.remote must be a non-empty URL; using origin");
55
+ return { enabled, ...remote ? { remote } : {}, buckets, warning: joinWarn(warnings) };
60
56
  }
61
57
 
62
58
  // extensions/lib/telemetry-paths.ts
@@ -67,6 +63,9 @@ var STMT_START = "(?:^|[\\n;&|(])\\s*";
67
63
 
68
64
  // extensions/lib/telemetry-paths.ts
69
65
  var toPosix = (p) => p.split("\\").join("/");
66
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ var inDirs = (rel, dirs) => dirs.some((d) => new RegExp(`(^|/)${escapeRe(d)}/[^/]+\\.md$`).test(rel));
68
+ var isSpecPath = (rel, specDirs) => inDirs(rel, specDirs);
70
69
  var recordPathFor = (dir, specRel) => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
71
70
  function repoRelativeToolPath(toplevel, cwd, p) {
72
71
  const abs = isAbsolute(p) ? p : resolve(cwd, p);
@@ -102,15 +101,15 @@ function patchBlockPath(headers) {
102
101
  function parsePatchBlock(block) {
103
102
  const hunk = block.findIndex((l) => l.startsWith("@@"));
104
103
  const headers = hunk < 0 ? block : block.slice(0, hunk);
105
- const path2 = patchBlockPath(headers);
106
- if (path2 === void 0) return void 0;
104
+ const path = patchBlockPath(headers);
105
+ if (path === void 0) return void 0;
107
106
  let added = 0;
108
107
  let removed = 0;
109
108
  for (const line of hunk < 0 ? [] : block.slice(hunk)) {
110
109
  if (line.startsWith("+")) added++;
111
110
  else if (line.startsWith("-")) removed++;
112
111
  }
113
- return { added, removed, path: path2 };
112
+ return { added, removed, path };
114
113
  }
115
114
  function parsePatchNumstat(patch) {
116
115
  if (!patch.trim()) return [];
@@ -199,9 +198,9 @@ function aggregateNumstat(numstat, files, buckets) {
199
198
  for (const line of numstat.split("\n")) {
200
199
  if (!line.trim()) continue;
201
200
  const [ins, del, ...rest] = line.split(" ");
202
- const path2 = numstatPath(rest.join(" "));
203
- if (!files.has(path2)) continue;
204
- const bucket = classifyBucket(path2, buckets);
201
+ const path = numstatPath(rest.join(" "));
202
+ if (!files.has(path)) continue;
203
+ const bucket = classifyBucket(path, buckets);
205
204
  const stat = out[bucket] ??= { files: 0, insertions: 0, deletions: 0 };
206
205
  stat.files += 1;
207
206
  stat.insertions += ins === "-" ? 0 : Number(ins) || 0;
@@ -238,19 +237,20 @@ async function computeGitDiff(o) {
238
237
  const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
239
238
  const files = modifiedFilesFrom(names.stdout, o.spec, o.dir, o.planDirs);
240
239
  const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
241
- const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
240
+ const count = await o.git(["rev-list", "--count", `${base}..HEAD`], o.cwd);
242
241
  return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
243
242
  }
244
243
 
245
244
  // extensions/lib/vcs.ts
246
245
  var NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
247
- var runBinary = (bin) => (args, cwd, timeoutMs = 1e4) => {
248
- const r = spawnSync(bin, args, { cwd, encoding: "utf8", env: NO_PROMPT_ENV, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
246
+ var runBinary = (bin) => (args, cwd, timeoutMs = 1e4, options = {}) => {
247
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", input: options.input, env: { ...NO_PROMPT_ENV, ...options.env }, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
249
248
  const err = r.error;
250
249
  const timedOut = err?.code === "ETIMEDOUT";
251
250
  const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
252
- return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: (r.stdout ?? "").trim(), stderr };
251
+ return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: options.raw ? r.stdout ?? "" : (r.stdout ?? "").trim(), stderr };
253
252
  };
253
+ var gitRun = runBinary("git");
254
254
  function vcsFor(absPath) {
255
255
  const co = checkoutOfSync(absPath);
256
256
  return co && { kind: co.via, root: co.toplevel, run: runBinary(co.via) };
@@ -270,16 +270,108 @@ function changedNames(vcs, base) {
270
270
  if (!fork) return { ok: false, code: 1, stdout: "", stderr: "jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
271
271
  return vcs.run(["--color=never", "diff", "--name-only", "--from", fork, "--to", "@"], vcs.root);
272
272
  }
273
- function recordSealed(vcs, rel) {
274
- if (vcs.kind === "jj") return true;
275
- return vcs.run(["ls-files", "--error-unmatch", "--", rel], vcs.root).ok && vcs.run(["diff", "--quiet", "HEAD", "--", rel], vcs.root).ok;
276
- }
277
- function commitRecordFile(vcs, rel, message) {
278
- if (vcs.kind === "jj") return vcs.run(["file", "track", "--include-ignored", "--", rel], vcs.root);
279
- const add = vcs.run(["add", "-f", "--", rel], vcs.root);
280
- const commit = add.ok ? vcs.run(["commit", "-q", "-m", message, "--", rel], vcs.root, 3e4) : add;
281
- if (!commit.ok) vcs.run(["reset", "-q", "--", rel], vcs.root);
282
- return commit;
273
+
274
+ // extensions/lib/telemetry-branch.ts
275
+ import { existsSync as existsSync2, mkdtempSync, readFileSync, rmSync } from "node:fs";
276
+ import { tmpdir } from "node:os";
277
+ import { basename, join as join2 } from "node:path";
278
+ var TELEMETRY_REF = "refs/heads/gauntlet/telemetry";
279
+ function trackerRefs(text) {
280
+ const tokens = text.match(/https?:\/\/[^\s<>\])]+|[\w.-]+\/[\w.-]+#\d+|#\d+|\b[A-Z][A-Z0-9]*-\d+\b|\bgh-\d+\b/g) ?? [];
281
+ return [...new Set(tokens.map((ref) => ref.replace(/[.,;]+$/, "")).filter((ref) => !ref.startsWith("http") || /\/(?:issues\/\d+|pull\/\d+|issue\/[A-Z][A-Z0-9]*-\d+)(?:\/[^\s]*)?$/.test(ref)).map((ref) => ref.startsWith("gh-") ? `#${ref.slice(3)}` : ref))];
282
+ }
283
+ function telemetryDir(root) {
284
+ const result = gitRun(["rev-parse", "--path-format=absolute", "--git-common-dir"], root);
285
+ return result.ok ? join2(result.stdout, "gauntlet", "telemetry") : void 0;
286
+ }
287
+ function telemetryRepo(root) {
288
+ const result = gitRun(["config", "--get", "remote.origin.url"], root);
289
+ const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
290
+ const path = match?.[1].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
291
+ if (result.ok && path) return path;
292
+ const top = checkoutOfSync(root)?.toplevel ?? root;
293
+ for (const file of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
294
+ const path2 = join2(top, file);
295
+ if (!existsSync2(path2)) continue;
296
+ const section = readFileSync(path2, "utf8").split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
297
+ const repo = /^-[\t ]*repo:[\t ]*(\S[^\r\n]*)$/m.exec(section)?.[1].trim();
298
+ if (repo) return repo;
299
+ break;
300
+ }
301
+ const worktrees = gitRun(["worktree", "list", "--porcelain", "-z"], root);
302
+ const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : void 0;
303
+ return `local/${basename(primary ?? top)}`;
304
+ }
305
+ var trackingRef = (remote) => `refs/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
306
+ var fetchBranch = (root, remote) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
307
+ function syncTelemetryBranch(root, records, remote = "origin") {
308
+ const scratch = mkdtempSync(join2(tmpdir(), "gauntlet-telemetry-index-"));
309
+ const env = { GIT_INDEX_FILE: join2(scratch, "index") };
310
+ const git = (args, input) => {
311
+ const result = gitRun(args, root, void 0, { env, input });
312
+ if (!result.ok) throw new Error(result.stderr);
313
+ return result.stdout;
314
+ };
315
+ const tracking = trackingRef(remote);
316
+ let fetchWarning;
317
+ const fetch = () => {
318
+ const result = fetchBranch(root, remote);
319
+ fetchWarning = result.ok ? void 0 : `fetch failed: ${result.stderr}`;
320
+ return result;
321
+ };
322
+ const resolve3 = (ref) => {
323
+ const result = gitRun(["rev-parse", "--verify", ref], root);
324
+ return result.ok ? result.stdout : void 0;
325
+ };
326
+ const commit = (tree, parent, message) => git(["-c", "commit.gpgsign=false", "commit-tree", tree, ...parent ? ["-p", parent] : [], "-m", message]);
327
+ let committed = false;
328
+ try {
329
+ let tip = resolve3(TELEMETRY_REF);
330
+ committed = records.size === 0 && !!tip;
331
+ fetch();
332
+ let upstream = resolve3(tracking);
333
+ if (records.size) {
334
+ const parent = tip ?? upstream;
335
+ git(parent ? ["read-tree", parent] : ["read-tree", "--empty"]);
336
+ for (const [path, content] of records) {
337
+ const blob = git(["hash-object", "-w", "--stdin"], content);
338
+ git(["update-index", "--add", "--cacheinfo", "100644", blob, path]);
339
+ }
340
+ const tree = git(["write-tree"]);
341
+ const next = parent && git(["rev-parse", `${parent}^{tree}`]) === tree ? parent : commit(tree, parent, "telemetry: update records");
342
+ git(["update-ref", TELEMETRY_REF, next, tip ?? ""]);
343
+ tip = next;
344
+ committed = true;
345
+ }
346
+ const pending = () => tip ? git(["rev-list", "--reverse", "--topo-order", tip, ...upstream ? [`^${upstream}`] : []]).split("\n").filter(Boolean) : [];
347
+ if (!pending().length) return { ok: true, committed, pushed: false, warning: fetchWarning };
348
+ const push = () => gitRun(["push", remote, `${TELEMETRY_REF}:${TELEMETRY_REF}`], root);
349
+ const first = push();
350
+ if (first.ok) return { ok: true, committed, pushed: true, warning: fetchWarning };
351
+ const fetched = fetch();
352
+ if (!fetched.ok) throw new Error(`${first.stderr}; refresh failed: ${fetched.stderr}`);
353
+ upstream = resolve3(tracking);
354
+ let replayed = upstream;
355
+ for (const sha of pending()) {
356
+ git(replayed ? ["read-tree", replayed] : ["read-tree", "--empty"]);
357
+ const changes = git(["diff-tree", "--root", "--no-commit-id", "--raw", "--no-abbrev", "--no-renames", "-r", "-z", sha]).split("\0");
358
+ for (let i = 0; i + 1 < changes.length; i += 2) {
359
+ const [, mode, , blob] = changes[i].split(" ");
360
+ const path = changes[i + 1];
361
+ git(mode === "000000" ? ["update-index", "--force-remove", "--", path] : ["update-index", "--add", "--cacheinfo", mode, blob, path]);
362
+ }
363
+ replayed = commit(git(["write-tree"]), replayed, git(["log", "-1", "--format=%B", sha]));
364
+ }
365
+ if (replayed) git(["update-ref", TELEMETRY_REF, replayed, tip]);
366
+ const second = push();
367
+ if (!second.ok) throw new Error(second.stderr);
368
+ return { ok: true, committed, pushed: true };
369
+ } catch (error) {
370
+ const reason = error.message;
371
+ return { ok: false, committed, pushed: false, reason, warning: [fetchWarning, `telemetry: not pushed (${reason})`].filter(Boolean).join("; ") };
372
+ } finally {
373
+ rmSync(scratch, { recursive: true, force: true });
374
+ }
283
375
  }
284
376
 
285
377
  // extensions/lib/telemetry-record.ts
@@ -417,9 +509,9 @@ var fail = (code, message) => {
417
509
  process2.stderr.write(message + "\n");
418
510
  process2.exit(code);
419
511
  };
420
- var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>] [--dir <telemetry dir>]");
512
+ var usage = () => fail(1, "usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr|squash> --base <ref> [--spec <spec path inside the checkout>]\n gauntlet-telemetry-seal [--worktree <abs path>] --migrate [<telemetry dir>]");
421
513
  function parseArgs(argv) {
422
- const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, dir: void 0, legs: [] };
514
+ const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, legs: [] };
423
515
  for (let i = 0; i < argv.length; i++) {
424
516
  const a = argv[i];
425
517
  const value = argv[i + 1];
@@ -427,7 +519,8 @@ function parseArgs(argv) {
427
519
  if (value === void 0 || value.startsWith("--")) usage();
428
520
  opts[key] = argv[++i];
429
521
  };
430
- if (a === "--worktree") takes("worktree");
522
+ if (a === "--migrate") opts.migrate = value !== void 0 && !value.startsWith("--") ? argv[++i] : ".pi/gauntlet/telemetry";
523
+ else if (a === "--worktree") takes("worktree");
431
524
  else if (a === "--option") takes("option");
432
525
  else if (a === "--base") takes("base");
433
526
  else if (a === "--spec") takes("spec");
@@ -439,16 +532,19 @@ function parseArgs(argv) {
439
532
  const leg = /^https?:\/\//.test(result) ? { repo, pr: result } : { repo, sha: result };
440
533
  if (split < 1 || !validLegs([leg])) fail(1, "invalid --leg " + value);
441
534
  opts.legs.push(leg);
442
- } else if (a === "--dir") takes("dir");
443
- else usage();
535
+ } else usage();
444
536
  }
445
- if (!opts.worktree || !isAbsolute2(opts.worktree) || !opts.base || !OPTIONS.has(opts.option)) usage();
537
+ if (opts.migrate !== void 0) {
538
+ opts.worktree ??= process2.cwd();
539
+ if (opts.option || opts.base || opts.spec || opts.legs.length) usage();
540
+ } else if (!opts.base || !OPTIONS.has(opts.option)) usage();
541
+ if (!opts.worktree || !isAbsolute2(opts.worktree)) usage();
446
542
  return opts;
447
543
  }
448
544
  function readLayer(file) {
449
- if (!existsSync2(file)) return {};
545
+ if (!existsSync3(file)) return {};
450
546
  try {
451
- return JSON.parse(readFileSync(file, "utf8"));
547
+ return JSON.parse(readFileSync2(file, "utf8"));
452
548
  } catch (e) {
453
549
  process2.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
454
550
  `);
@@ -456,37 +552,78 @@ function readLayer(file) {
456
552
  }
457
553
  }
458
554
  function settings(root) {
459
- const agentDir = process2.env.PI_CODING_AGENT_DIR || join2(homedir(), ".pi", "agent");
460
- const preset = readLayer(join2(agentDir, "settings.json"));
461
- const repo = readLayer(join2(root, ".pi", "settings.json"));
555
+ const agentDir = process2.env.PI_CODING_AGENT_DIR || join3(homedir(), ".pi", "agent");
556
+ const preset = readLayer(join3(agentDir, "settings.json"));
557
+ const repo = readLayer(join3(root, ".pi", "settings.json"));
462
558
  const merged = mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet);
463
- return { telemetry: resolveTelemetry(merged), planDirs: planDirsFor(resolveFlowGuards(merged).specDirs) };
559
+ const { specDirs } = resolveFlowGuards(merged);
560
+ return { telemetry: resolveTelemetry(merged), planDirs: planDirsFor(specDirs), specDirs };
464
561
  }
465
562
  function walkRecords(absDir) {
466
- if (!existsSync2(absDir)) return [];
563
+ if (!existsSync3(absDir)) return [];
467
564
  const out = [];
468
565
  for (const e of readdirSync(absDir, { withFileTypes: true })) {
469
- const p = join2(absDir, e.name);
566
+ const p = join3(absDir, e.name);
470
567
  if (e.isDirectory()) out.push(...walkRecords(p));
471
568
  else if (e.isFile() && e.name.endsWith(".yaml")) out.push(p);
472
569
  }
473
570
  return out;
474
571
  }
572
+ function migrate(root, dir, repo, remote) {
573
+ const writes = /* @__PURE__ */ new Map();
574
+ for (const path of walkRecords(dir).sort()) {
575
+ const record = parseRecord(readFileSync2(path, "utf8"));
576
+ if (!record) {
577
+ process2.stderr.write(`warning: skipped unparseable schema-1 record ${path}
578
+ `);
579
+ continue;
580
+ }
581
+ record.repo ??= repo;
582
+ writes.set(`${repo}/brainstorming/${toPosix(relative2(dir, path))}`, serializeRecord(record));
583
+ }
584
+ const result = syncTelemetryBranch(root, writes, remote);
585
+ if (!result.ok) return console.log(`telemetry: not pushed (${result.reason})`);
586
+ console.log(`migrated ${writes.size} records from ${dir}`);
587
+ }
475
588
  var isoNow = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
589
+ function runTickets(vcs, record, base) {
590
+ const log = gitRun(["log", "--format=%B", `${base}..HEAD`], vcs.root);
591
+ return trackerRefs([
592
+ record.spec,
593
+ record.branch ?? gitRun(["branch", "--show-current"], vcs.root).stdout,
594
+ log.ok ? log.stdout : ""
595
+ ].join("\n"));
596
+ }
597
+ var normalized = (legs) => [...new Set(legs.map((leg) => JSON.stringify([leg.repo.toLowerCase(), leg.sha ?? leg.pr])))].sort();
598
+ var sameLegs = (record, legs) => JSON.stringify(normalized(record.legs ?? [])) === JSON.stringify(normalized(legs));
599
+ function branchBlob(root, path, remote) {
600
+ const local = gitRun(["rev-parse", "--verify", TELEMETRY_REF], root);
601
+ const tracking = `refs/remotes/${!remote || remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
602
+ const fetched = local.ok && gitRun(["merge-base", "--is-ancestor", TELEMETRY_REF, tracking], root).ok;
603
+ const ref = !local.ok || fetched ? tracking : TELEMETRY_REF;
604
+ const blob = gitRun(["show", `${ref}:${path}`], root, void 0, { raw: true });
605
+ return blob.ok ? blob.stdout : void 0;
606
+ }
476
607
  async function seal(vcs, rec, o) {
477
- const abs = join2(vcs.root, rec);
478
- if (!existsSync2(abs)) fail(2, `no record at ${rec}`);
479
- const prior = readFileSync(abs);
480
- const parsed = parseRecord(prior.toString("utf8"));
481
- if (!parsed) fail(2, `unparseable record ${rec}: schema 1 with spec and run_id required`);
482
- const normalized = (legs) => [...new Set(legs.map((leg) => JSON.stringify([leg.repo.toLowerCase(), leg.sha ?? leg.pr])))].sort();
483
- if (parsed.status !== "in_progress" && JSON.stringify(normalized(parsed.legs ?? [])) !== JSON.stringify(normalized(o.legs))) {
608
+ const abs = rec;
609
+ const path = `${o.repo}/brainstorming/${toPosix(relative2(o.store, rec))}`;
610
+ if (!existsSync3(abs)) {
611
+ const sync2 = syncTelemetryBranch(vcs.root, /* @__PURE__ */ new Map(), o.remote);
612
+ const stored2 = parseRecord(branchBlob(vcs.root, path, o.remote) ?? "");
613
+ if (stored2?.status !== "shipped") return console.log("no telemetry run");
614
+ if (!sameLegs(stored2, o.legs)) fail(1, `seal legs differ: stored ${JSON.stringify(stored2.legs ?? [])}; supplied ${JSON.stringify(o.legs)}`);
615
+ return console.log(sync2.ok ? `already sealed ${rec}` : `telemetry: not pushed (${sync2.reason}); record kept at ${path}`);
616
+ }
617
+ const prior = readFileSync2(abs, "utf8");
618
+ const parsed = parseRecord(prior);
619
+ if (!parsed) return console.log(`telemetry: skipped (unparseable record ${rec})`);
620
+ if (parsed.status !== "in_progress" && !sameLegs(parsed, o.legs)) {
484
621
  fail(1, `seal legs differ: stored ${JSON.stringify(parsed.legs ?? [])}; supplied ${JSON.stringify(o.legs)}`);
485
622
  }
486
- if (parsed.status !== "in_progress") {
487
- if (parsed.status !== "shipped" || recordSealed(vcs, rec)) return console.log(`already sealed ${rec}`);
488
- } else {
623
+ if (parsed.status === "in_progress") {
489
624
  const now = isoNow();
625
+ parsed.repo = o.repo;
626
+ parsed.tickets = runTickets(vcs, parsed, o.base);
490
627
  if (o.legs.length) parsed.legs = o.legs;
491
628
  parsed.status = "shipped";
492
629
  parsed.shipped_at = now;
@@ -498,44 +635,50 @@ async function seal(vcs, rec, o) {
498
635
  parsed.derived = derive(parsed, now);
499
636
  writeFileSync(abs, serializeRecord(parsed));
500
637
  }
501
- const commit = commitRecordFile(vcs, rec, `telemetry: ${parsed.spec}`);
502
- if (!commit.ok) {
503
- writeFileSync(abs, prior);
504
- fail(1, `seal failed ${rec}: ${commit.stderr}`);
505
- }
506
- console.log(`sealed ${rec}`);
638
+ const bytes = readFileSync2(abs, "utf8");
639
+ const storedBytes = branchBlob(vcs.root, path, o.remote);
640
+ const stored = parseRecord(storedBytes ?? "");
641
+ const already = stored?.status === "shipped" && sameLegs(stored, o.legs) && storedBytes === bytes;
642
+ const sync = syncTelemetryBranch(vcs.root, /* @__PURE__ */ new Map([[path, bytes]]), o.remote);
643
+ if (sync.committed) unlinkSync(abs);
644
+ if (!sync.ok) return console.log(`telemetry: not pushed (${sync.reason}); record kept at ${sync.committed ? path : rec}`);
645
+ console.log(`${already ? "already sealed" : "sealed"} ${rec}`);
507
646
  }
508
647
  async function main() {
509
648
  const opts = parseArgs(process2.argv.slice(2));
510
- const override = opts.dir === void 0 ? void 0 : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
511
- if (override?.warning) usage();
512
- const vcs = existsSync2(opts.worktree) ? vcsFor(opts.worktree) : void 0;
649
+ const vcs = existsSync3(opts.worktree) ? vcsFor(opts.worktree) : void 0;
513
650
  if (!vcs) fail(1, "not a git or jj checkout");
514
651
  const root = vcs.root;
515
652
  let rel;
516
653
  if (opts.spec) {
517
- const specAbs = isAbsolute2(opts.spec) && existsSync2(opts.spec) ? realpathSync(opts.spec) : opts.spec;
654
+ const specAbs = isAbsolute2(opts.spec) && existsSync3(opts.spec) ? realpathSync(opts.spec) : opts.spec;
518
655
  rel = repoRelativeToolPath(root, root, specAbs);
519
656
  if (!rel) usage();
520
657
  }
521
- const { telemetry, planDirs } = settings(root);
658
+ const { telemetry, planDirs, specDirs } = settings(root);
522
659
  if (telemetry.warning) process2.stderr.write(`warning: ${telemetry.warning}
523
660
  `);
524
661
  if (!telemetry.enabled) return console.log("telemetry disabled");
525
- const dir = override?.dir ?? telemetry.dir;
526
- if (!validateBase(vcs, opts.base)) fail(1, `no base ref ${opts.base}`);
527
- const o = { legs: opts.legs, option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs };
528
- if (opts.spec) return seal(vcs, recordPathFor(dir, rel), o);
662
+ if (vcs.kind === "jj") return console.log("telemetry: not pushed (jj workspace: no git remote)");
663
+ const dir = DEFAULT_TELEMETRY_DIR;
664
+ if (opts.migrate === void 0 && !validateBase(vcs, opts.base)) fail(1, `no base ref ${opts.base}`);
665
+ const store = telemetryDir(root);
666
+ const repo = telemetryRepo(root);
667
+ if (!store) {
668
+ process2.stderr.write("warning: telemetry needs a git store\n");
669
+ return;
670
+ }
671
+ if (opts.migrate !== void 0) return migrate(root, resolve2(opts.worktree, opts.migrate), repo, telemetry.remote);
672
+ const o = { legs: opts.legs, option: opts.option, base: opts.base, dir, buckets: telemetry.buckets, planDirs, repo, remote: telemetry.remote, store };
673
+ if (opts.spec) return seal(vcs, recordPathFor(store, rel), o);
529
674
  const diff = changedNames(vcs, opts.base);
530
675
  if (!diff.ok) fail(1, `${vcs.kind} diff failed: ${diff.stderr}`);
531
676
  const changed = new Set(diff.stdout.split("\n").filter(Boolean));
532
- const records = walkRecords(join2(root, dir)).filter((abs) => {
533
- const parsed = parseRecord(readFileSync(abs, "utf8"));
534
- if (!parsed) process2.stderr.write(`warning: unparseable record ${toPosix(relative2(root, abs))}
535
- `);
536
- return parsed && changed.has(parsed.spec);
537
- }).map((abs) => toPosix(relative2(root, abs))).sort();
677
+ const records = [...changed].filter((path) => isSpecPath(path, specDirs)).map((path) => recordPathFor(store, path)).sort();
538
678
  if (records.length === 0) return console.log("no telemetry run");
539
679
  for (const rec of records) await seal(vcs, rec, o);
540
680
  }
541
- await main();
681
+ await main().catch((error) => {
682
+ process2.stderr.write(`warning: telemetry: not pushed (${error.message})
683
+ `);
684
+ });