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
@@ -12,7 +12,6 @@ import {
12
12
  planDirsFor,
13
13
  resolveVerifyBeforeShip,
14
14
  resolveTelemetry,
15
- DEFAULT_TELEMETRY_DIR,
16
15
  DEFAULT_TELEMETRY_BUCKETS,
17
16
  DEFAULT_TEST_COMMANDS,
18
17
  buildTestCmdRegex,
@@ -250,11 +249,10 @@ test("verifyBeforeShip: default vs override", () => {
250
249
  assert.equal(o.warningReference, "doc/t.md");
251
250
  });
252
251
 
253
- test("telemetry: absent block -> enabled, default dir, default buckets, no warning", () => {
252
+ test("telemetry: absent block -> enabled, default buckets, no directory key or warning", () => {
254
253
  const r = resolveTelemetry({});
255
254
  assert.equal(r.enabled, true);
256
- assert.equal(r.dir, DEFAULT_TELEMETRY_DIR);
257
- assert.equal(r.dir, ".pi/gauntlet/telemetry");
255
+ assert.equal("dir" in r, false);
258
256
  assert.deepEqual(r.buckets, DEFAULT_TELEMETRY_BUCKETS);
259
257
  assert.equal(r.warning, undefined);
260
258
  });
@@ -265,10 +263,10 @@ test("telemetry: enabled false is the only way to disable", () => {
265
263
  assert.equal(resolveTelemetry({ telemetry: { enabled: 0 } }).enabled, true);
266
264
  });
267
265
 
268
- test("telemetry: dir accepts a non-empty relative path, otherwise default + warning", () => {
269
- assert.equal(resolveTelemetry({ telemetry: { dir: "telemetry/" } }).dir, "telemetry");
270
- assert.equal(resolveTelemetry({ telemetry: { dir: "a/../b" } }).dir, "b");
271
- for (const bad of [
266
+ test("telemetry: retired dir setting is ignored with one migration warning", () => {
267
+ for (const dir of [
268
+ "telemetry/",
269
+ "a/../b",
272
270
  "",
273
271
  " ",
274
272
  5,
@@ -284,9 +282,9 @@ test("telemetry: dir accepts a non-empty relative path, otherwise default + warn
284
282
  "a/../../outside",
285
283
  "..",
286
284
  ]) {
287
- const r = resolveTelemetry({ telemetry: { dir: bad } });
288
- assert.equal(r.dir, DEFAULT_TELEMETRY_DIR);
289
- assert.match(r.warning ?? "", /telemetry\.dir/);
285
+ const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir } }));
286
+ assert.equal("dir" in r, false);
287
+ assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
290
288
  }
291
289
  });
292
290
 
@@ -307,9 +305,9 @@ test("telemetry: malformed buckets -> default + warning", () => {
307
305
  }
308
306
  });
309
307
 
310
- test("telemetry: dir and buckets warnings join with '; '", () => {
311
- const r = resolveTelemetry({ telemetry: { dir: 1, buckets: 2 } });
312
- assert.match(r.warning ?? "", /telemetry\.dir.*; .*telemetry\.buckets/);
308
+ test("telemetry: retired dir and invalid buckets each warn once", () => {
309
+ const r = resolveTelemetry(mergeGauntlet(undefined, { telemetry: { dir: 1, buckets: 2 } }));
310
+ assert.equal(r.warning, "piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them; telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
313
311
  });
314
312
 
315
313
  test("DEFAULT_TEST_COMMANDS + buildTestCmdRegex match the documented entrypoints", () => {
@@ -13,7 +13,7 @@ export interface PiGauntlet {
13
13
  escalationLoop?: { implModel?: unknown };
14
14
  loopEngine?: { maxIterationsPerRow?: unknown; escalateAfter?: unknown; maxIterations?: unknown };
15
15
  phaseModels?: { plan?: unknown; implement?: unknown; verify?: unknown; ship?: unknown };
16
- telemetry?: { enabled?: unknown; dir?: unknown; buckets?: unknown };
16
+ telemetry?: { enabled?: unknown; remote?: unknown; buckets?: unknown };
17
17
  }
18
18
 
19
19
  // Whole-object second-level merge: each piGauntlet key present in the repo layer
@@ -230,7 +230,7 @@ export const DEFAULT_TELEMETRY_BUCKETS: [string, string[]][] = [
230
230
 
231
231
  export interface TelemetryResolved {
232
232
  enabled: boolean;
233
- dir: string;
233
+ remote?: string;
234
234
  buckets: [string, string[]][];
235
235
  warning: string | undefined;
236
236
  }
@@ -239,19 +239,8 @@ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
239
239
  const t = g.telemetry;
240
240
  const warnings: string[] = [];
241
241
  const enabled = t?.enabled !== false;
242
-
243
- let dir = DEFAULT_TELEMETRY_DIR;
244
- if (t?.dir !== undefined) {
245
- const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
246
- const canonical = value.replace(/\\/g, "/");
247
- const normalized = path.posix.normalize(canonical);
248
- if (
249
- value &&
250
- path.win32.parse(canonical).root === "" &&
251
- normalized !== ".." &&
252
- !normalized.startsWith("../")
253
- ) dir = normalized;
254
- else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
242
+ if (t && Object.hasOwn(t, "dir")) {
243
+ warnings.push("piGauntlet.telemetry.dir is retired; records live on the gauntlet/telemetry branch - set piGauntlet.telemetry.remote to redirect them");
255
244
  }
256
245
 
257
246
  let buckets = DEFAULT_TELEMETRY_BUCKETS;
@@ -267,7 +256,9 @@ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
267
256
  else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
268
257
  }
269
258
 
270
- return { enabled, dir, buckets, warning: joinWarn(warnings) };
259
+ const remote = nonEmptyString(t?.remote) ? t.remote.trim() : undefined;
260
+ if (t?.remote !== undefined && !remote) warnings.push("telemetry.remote must be a non-empty URL; using origin");
261
+ return { enabled, ...(remote ? { remote } : {}), buckets, warning: joinWarn(warnings) };
271
262
  }
272
263
 
273
264
  export function settingsErrorWarning(errors: string[]): string {
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
4
+ import { isolatedGitEnv } from "../test-support/git-env.mjs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { test } from "node:test";
8
+
9
+ const library = new URL("./telemetry-branch.ts", import.meta.url).href;
10
+
11
+ function fixture() {
12
+ const scratch = mkdtempSync(join(tmpdir(), "telemetry-identity-"));
13
+ const root = join(scratch, "primary product");
14
+ const home = join(scratch, "home");
15
+ mkdirSync(root); mkdirSync(home);
16
+ const env = { ...process.env, HOME: home, ...isolatedGitEnv(scratch) };
17
+ const run = (binary: string, args: string[], cwd = root) => {
18
+ const result = spawnSync(binary, args, { cwd, env, encoding: "utf8", timeout: 10_000 });
19
+ assert.equal(result.status, 0, `${binary} ${args.join(" ")}: ${result.stderr}`);
20
+ return result.stdout.trim();
21
+ };
22
+ const git = (...args: string[]) => run("git", ["-c", "commit.gpgsign=false", ...args]);
23
+ git("init", "-b", "main");
24
+ const identity = (cwd = root) => JSON.parse(run(process.execPath, ["--input-type=module", "--eval",
25
+ `import { telemetryRepo } from ${JSON.stringify(library)}; console.log(JSON.stringify(telemetryRepo(process.cwd()) ?? null));`,
26
+ ], cwd));
27
+ return { scratch, root, git, identity, run };
28
+ }
29
+
30
+ test("repository identity normalizes origin URL forms and keeps every namespace segment", async (t) => {
31
+ const f = fixture();
32
+ for (const [name, url, expected] of [
33
+ ["scp", "git@host.test:team/product.git", "team/product"],
34
+ ["ssh", "ssh://git@host.test/team/product.git", "team/product"],
35
+ ["ssh port and nested namespace", "ssh://git@host.test:2222/group/sub/product.git/", "group/sub/product"],
36
+ ["https", "https://host.test/team/product.git/", "team/product"],
37
+ ["nested scp", "git@host.test:group/sub/product.git", "group/sub/product"],
38
+ ["nested https", "https://host.test/group/other/product/", "group/other/product"],
39
+ ["case preserved", "https://host.test/Team/Product.git", "Team/Product"],
40
+ ]) {
41
+ await t.test(name, () => {
42
+ f.git("config", "remote.origin.url", url);
43
+ assert.equal(f.identity(), expected);
44
+ });
45
+ }
46
+ mkdirSync(join(f.root, ".pi"));
47
+ writeFileSync(join(f.root, ".pi/settings.json"), JSON.stringify({ piGauntlet: { telemetry: { remote: "https://elsewhere.test/central/telemetry.git" } } }));
48
+ assert.equal(f.identity(), "Team/Product", "telemetry.remote never changes product identity");
49
+ });
50
+
51
+ test("failed telemetry ref commit reports committed: false and preserves the feature index", (t) => {
52
+ const f = fixture();
53
+ t.after(() => rmSync(f.scratch, { recursive: true, force: true }));
54
+ writeFileSync(join(f.root, "file"), "feature\n");
55
+ f.git("add", "file");
56
+ f.git("commit", "-m", "initial");
57
+ const before = f.git("rev-parse", "HEAD");
58
+ const index = readFileSync(join(f.root, ".git/index"));
59
+ const hook = join(f.root, ".git/hooks/reference-transaction");
60
+ writeFileSync(hook, '#!/bin/sh\nif [ "$1" = prepared ]; then echo rejected >&2; exit 1; fi\n');
61
+ chmodSync(hook, 0o755);
62
+ const result = JSON.parse(f.run(process.execPath, ["--input-type=module", "--eval",
63
+ `import { syncTelemetryBranch } from ${JSON.stringify(library)}; console.log(JSON.stringify(syncTelemetryBranch(process.cwd(), new Map([["team/product/brainstorming/x.yaml", "schema: 1\\n"]]))));`,
64
+ ]));
65
+ assert.equal(result.ok, false);
66
+ assert.equal(result.committed, false);
67
+ assert.equal(result.pushed, false);
68
+ assert.match(result.reason, /rejected|reference transaction/);
69
+ assert.equal(f.git("rev-parse", "HEAD"), before);
70
+ assert.deepEqual(readFileSync(join(f.root, ".git/index")), index);
71
+ assert.equal(f.git("for-each-ref", "refs/heads/gauntlet/telemetry"), "");
72
+ });
73
+
74
+ test("origin-less identity uses repo from the first-found overrides Issue tracker section", () => {
75
+ const f = fixture();
76
+ mkdirSync(join(f.root, ".pi"));
77
+ mkdirSync(join(f.root, "doc"));
78
+ writeFileSync(join(f.root, "doc/gauntlet-overrides.md"), "## Issue tracker\n- repo: doc/product\n");
79
+ assert.equal(f.identity(), "doc/product");
80
+ writeFileSync(join(f.root, "gauntlet-overrides.md"), "## Issue tracker\n- repo: root/product\n");
81
+ assert.equal(f.identity(), "root/product");
82
+ writeFileSync(join(f.root, ".pi/gauntlet-overrides.md"), "## Other\n- repo: wrong/product\n## Issue tracker\n- tracker: none\n- repo: group/sub/product\n## Later\n- repo: also/wrong\n");
83
+ assert.equal(f.identity(), "group/sub/product");
84
+ f.git("config", "remote.origin.url", "git@host.test:origin/product.git");
85
+ assert.equal(f.identity(), "origin/product", "origin takes precedence over overrides");
86
+ });
87
+
88
+ test("local fallback uses the primary directory from linked worktrees and ignores later overrides", () => {
89
+ const f = fixture();
90
+ assert.equal(f.identity(), "local/primary product");
91
+ mkdirSync(join(f.root, ".pi"));
92
+ writeFileSync(join(f.root, ".pi/gauntlet-overrides.md"), "## Issue tracker\n- repo:\n- tracker: none\n## Other\n- repo: wrong/product\n");
93
+ writeFileSync(join(f.root, "gauntlet-overrides.md"), "## Issue tracker\n- repo: ignored/product\n");
94
+ assert.equal(f.identity(), "local/primary product");
95
+ f.git("commit", "--allow-empty", "-m", "initial");
96
+ const linked = join(f.scratch, "different worktree");
97
+ f.git("worktree", "add", "-b", "feature", linked);
98
+ const nested = join(linked, "nested");
99
+ mkdirSync(nested);
100
+ assert.equal(f.identity(linked), "local/primary product");
101
+ assert.equal(f.identity(nested), "local/primary product");
102
+ });
@@ -0,0 +1,145 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, join } from "node:path";
4
+ import { checkoutOfSync } from "./checkout.ts";
5
+ import { gitRun } from "./vcs.ts";
6
+
7
+ export const TELEMETRY_REF = "refs/heads/gauntlet/telemetry";
8
+
9
+ export function trackerRefs(text: string): string[] {
10
+ const tokens = text.match(/https?:\/\/[^\s<>\])]+|[\w.-]+\/[\w.-]+#\d+|#\d+|\b[A-Z][A-Z0-9]*-\d+\b|\bgh-\d+\b/g) ?? [];
11
+ return [...new Set(tokens.map(ref => ref.replace(/[.,;]+$/, ""))
12
+ .filter(ref => !ref.startsWith("http") || /\/(?:issues\/\d+|pull\/\d+|issue\/[A-Z][A-Z0-9]*-\d+)(?:\/[^\s]*)?$/.test(ref))
13
+ .map(ref => ref.startsWith("gh-") ? `#${ref.slice(3)}` : ref))];
14
+ }
15
+
16
+ export function telemetryDir(root: string): string | undefined {
17
+ const result = gitRun(["rev-parse", "--path-format=absolute", "--git-common-dir"], root);
18
+ return result.ok ? join(result.stdout, "gauntlet", "telemetry") : undefined;
19
+ }
20
+
21
+ export function telemetryRepo(root: string): string {
22
+ // Read the configured URL, not get-url's transport rewrite (which may be local).
23
+ const result = gitRun(["config", "--get", "remote.origin.url"], root);
24
+ const match = result.stdout.match(/^(?:https?:\/\/[^/]+\/|ssh:\/\/(?:[^@/]+@)?[^/]+\/|(?:[^@\s]+@)?[^:\s/]+:)(.+)$/);
25
+ const path = match?.[1].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
26
+ if (result.ok && path) return path;
27
+
28
+ const top = checkoutOfSync(root)?.toplevel ?? root;
29
+ for (const file of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
30
+ const path = join(top, file);
31
+ if (!existsSync(path)) continue;
32
+ const section = readFileSync(path, "utf8").split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
33
+ const repo = /^-[\t ]*repo:[\t ]*(\S[^\r\n]*)$/m.exec(section)?.[1].trim();
34
+ if (repo) return repo;
35
+ break;
36
+ }
37
+ const worktrees = gitRun(["worktree", "list", "--porcelain", "-z"], root);
38
+ const primary = worktrees.ok ? /^worktree ([^\0]+)/.exec(worktrees.stdout)?.[1] : undefined;
39
+ return `local/${basename(primary ?? top)}`;
40
+ }
41
+
42
+ const trackingRef = (remote: string) => `refs/remotes/${remote === "origin" ? "origin" : "telemetry"}/gauntlet/telemetry`;
43
+ const fetchBranch = (root: string, remote: string) => gitRun(["fetch", "--no-tags", remote, `+${TELEMETRY_REF}:${trackingRef(remote)}`], root);
44
+
45
+ export function syncTelemetryBranch(root: string, records: ReadonlyMap<string, string>, remote = "origin"): { ok: boolean; committed: boolean; pushed: boolean; reason?: string; warning?: string } {
46
+ const scratch = mkdtempSync(join(tmpdir(), "gauntlet-telemetry-index-"));
47
+ const env = { GIT_INDEX_FILE: join(scratch, "index") };
48
+ const git = (args: string[], input?: string) => {
49
+ const result = gitRun(args, root, undefined, { env, input });
50
+ if (!result.ok) throw new Error(result.stderr);
51
+ return result.stdout;
52
+ };
53
+ const tracking = trackingRef(remote);
54
+ let fetchWarning: string | undefined;
55
+ const fetch = () => {
56
+ const result = fetchBranch(root, remote);
57
+ fetchWarning = result.ok ? undefined : `fetch failed: ${result.stderr}`;
58
+ return result;
59
+ };
60
+ const resolve = (ref: string) => {
61
+ const result = gitRun(["rev-parse", "--verify", ref], root);
62
+ return result.ok ? result.stdout : undefined;
63
+ };
64
+ const commit = (tree: string, parent: string | undefined, message: string) =>
65
+ git(["-c", "commit.gpgsign=false", "commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", message]);
66
+ let committed = false;
67
+ try {
68
+ let tip = resolve(TELEMETRY_REF);
69
+ committed = records.size === 0 && !!tip;
70
+ fetch();
71
+ let upstream = resolve(tracking);
72
+ if (records.size) {
73
+ const parent = tip ?? upstream;
74
+ git(parent ? ["read-tree", parent] : ["read-tree", "--empty"]);
75
+ for (const [path, content] of records) {
76
+ const blob = git(["hash-object", "-w", "--stdin"], content);
77
+ git(["update-index", "--add", "--cacheinfo", "100644", blob, path]);
78
+ }
79
+ const tree = git(["write-tree"]);
80
+ const next = parent && git(["rev-parse", `${parent}^{tree}`]) === tree
81
+ ? parent : commit(tree, parent, "telemetry: update records");
82
+ git(["update-ref", TELEMETRY_REF, next, tip ?? ""]);
83
+ tip = next;
84
+ committed = true;
85
+ }
86
+ const pending = () => tip ? git(["rev-list", "--reverse", "--topo-order", tip, ...(upstream ? [`^${upstream}`] : [])]).split("\n").filter(Boolean) : [];
87
+ if (!pending().length) return { ok: true, committed, pushed: false, warning: fetchWarning };
88
+ const push = () => gitRun(["push", remote, `${TELEMETRY_REF}:${TELEMETRY_REF}`], root);
89
+ const first = push();
90
+ if (first.ok) return { ok: true, committed, pushed: true, warning: fetchWarning };
91
+ const fetched = fetch();
92
+ if (!fetched.ok) throw new Error(`${first.stderr}; refresh failed: ${fetched.stderr}`);
93
+ upstream = resolve(tracking);
94
+ let replayed = upstream;
95
+ for (const sha of pending()) {
96
+ git(replayed ? ["read-tree", replayed] : ["read-tree", "--empty"]);
97
+ // Apply only this commit's changes, not its whole tree: remote-only records survive.
98
+ const changes = git(["diff-tree", "--root", "--no-commit-id", "--raw", "--no-abbrev", "--no-renames", "-r", "-z", sha]).split("\0");
99
+ for (let i = 0; i + 1 < changes.length; i += 2) {
100
+ const [, mode, , blob] = changes[i].split(" ");
101
+ const path = changes[i + 1];
102
+ git(mode === "000000" ? ["update-index", "--force-remove", "--", path] : ["update-index", "--add", "--cacheinfo", mode, blob, path]);
103
+ }
104
+ replayed = commit(git(["write-tree"]), replayed, git(["log", "-1", "--format=%B", sha]));
105
+ }
106
+ // Publish the replay atomically only after every pending commit has survived it.
107
+ if (replayed) git(["update-ref", TELEMETRY_REF, replayed, tip!]);
108
+ const second = push();
109
+ if (!second.ok) throw new Error(second.stderr);
110
+ return { ok: true, committed, pushed: true };
111
+ } catch (error) {
112
+ const reason = (error as Error).message;
113
+ return { ok: false, committed, pushed: false, reason, warning: [fetchWarning, `telemetry: not pushed (${reason})`].filter(Boolean).join("; ") };
114
+ } finally {
115
+ rmSync(scratch, { recursive: true, force: true });
116
+ }
117
+ }
118
+
119
+ export function readTelemetryBranch(root: string, { remote = "origin", prefix = "" }: { remote?: string; prefix?: string } = {}): { records: { path: string; bytes: string }[]; warning?: string } {
120
+ const records: { path: string; bytes: string }[] = [];
121
+ let warning: string | undefined;
122
+ try {
123
+ const synced = syncTelemetryBranch(root, new Map(), remote);
124
+ warning = synced.warning;
125
+ if (synced.pushed) {
126
+ const fetched = fetchBranch(root, remote);
127
+ warning = fetched.ok ? undefined : `fetch failed: ${fetched.stderr}`;
128
+ }
129
+ const tip = gitRun(["rev-parse", "--verify", trackingRef(remote)], root);
130
+ if (!tip.ok) return { records, warning: [warning, "no telemetry ref fetched yet"].filter(Boolean).join("; ") };
131
+ const tree = gitRun(["ls-tree", "-r", "-z", tip.stdout, ...(prefix ? ["--", prefix] : [])], root);
132
+ if (!tree.ok) throw new Error(tree.stderr);
133
+ for (const entry of tree.stdout.split("\0").filter(Boolean)) {
134
+ const [meta, path] = entry.split("\t");
135
+ const [, type, sha] = meta.split(" ");
136
+ if (type !== "blob" || !path.startsWith(prefix) || !path.endsWith(".yaml")) continue;
137
+ const blob = gitRun(["cat-file", "blob", sha], root, undefined, { raw: true });
138
+ if (!blob.ok) throw new Error(blob.stderr);
139
+ records.push({ path, bytes: blob.stdout });
140
+ }
141
+ return { records, warning };
142
+ } catch (error) {
143
+ return { records, warning: [warning, `read failed: ${(error as Error).message}`].filter(Boolean).join("; ") };
144
+ }
145
+ }
@@ -16,7 +16,7 @@ test("aggregateNumstat sums per bucket over the given file set; binary rows coun
16
16
  });
17
17
  });
18
18
 
19
- test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets, telemetry-free commit count", async () => {
19
+ test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets, all product commits counted", async () => {
20
20
  const calls: string[][] = [];
21
21
  const git = (args: string[]) => {
22
22
  calls.push(args);
@@ -31,7 +31,7 @@ test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets
31
31
  assert.deepEqual(out.diff, { base: "abc123", commits: 9, buckets: { code: { files: 1, insertions: 100, deletions: 5 }, test: { files: 1, insertions: 40, deletions: 0 }, docs: { files: 1, insertions: 2, deletions: 1 } } });
32
32
  assert.equal(out.warning, undefined);
33
33
  assert.deepEqual(calls[0], ["merge-base", "HEAD", "main"]);
34
- assert.deepEqual(calls.at(-1), ["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", "abc123..HEAD"]);
34
+ assert.deepEqual(calls.at(-1), ["rev-list", "--count", "abc123..HEAD"]);
35
35
  });
36
36
 
37
37
  test("computeGitDiff: custom plan dir excludes only configured plans", async () => {
@@ -57,6 +57,6 @@ export async function computeGitDiff(o: { git: Runner; cwd: string; spec: string
57
57
  const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
58
58
  const files = modifiedFilesFrom(names.stdout, o.spec, o.dir, o.planDirs);
59
59
  const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
60
- const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
60
+ const count = await o.git(["rev-list", "--count", `${base}..HEAD`], o.cwd);
61
61
  return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
62
62
  }
@@ -0,0 +1,163 @@
1
+ import { join } from "node:path";
2
+ import { stringify } from "yaml";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Deps } from "../telemetry.ts";
5
+ import { checkoutOf } from "./checkout.ts";
6
+ import { syncTelemetryBranch, telemetryRepo, trackerRefs } from "./telemetry-branch.ts";
7
+ import { textOf } from "./telemetry-collect.ts";
8
+ import { addTokens, compact, emptyAccumulators, zeroTokens, type Accumulators, type Tokens } from "./telemetry-record.ts";
9
+
10
+ const skills = ["shape-ticket", "gatekeep-pr", "check-delivery"] as const;
11
+ type LegSkill = (typeof skills)[number];
12
+ export interface LegItem {
13
+ kind: "ticket" | "pr";
14
+ key?: string;
15
+ ticket?: string;
16
+ outcome: string;
17
+ }
18
+ export interface LegRecord {
19
+ skill: LegSkill;
20
+ session: string;
21
+ author: { name?: string; email?: string };
22
+ gauntlet_version?: string;
23
+ models: string[];
24
+ tokens: Omit<Tokens, "cost">;
25
+ cost: number;
26
+ repo: string;
27
+ ts: string;
28
+ items: LegItem[];
29
+ }
30
+
31
+ const legSkill = (name: string | undefined): LegSkill | undefined => skills.find((skill) => skill === name);
32
+
33
+ const ticketRef = (ref: string) => trackerRefs(ref).includes(ref);
34
+ const outcomes: Record<LegSkill, string> = {
35
+ "shape-ticket": "written|created|closed|close failed \\(.*\\)|close by hand|dropped|declined|stopped|no-op|unfetched",
36
+ "gatekeep-pr": "approved|merged|closed-pr|final|merge-queued|stopped|stuck - .+",
37
+ "check-delivery": "delivered|manual|declined|already recorded|skipped|failed - .+",
38
+ };
39
+
40
+ export function settlementItems(skill: LegSkill, text: string, trackerless = false): LegItem[] | undefined {
41
+ const [marker, ...lines] = text.split("\n");
42
+ if (!/^gauntlet: (?:done|stuck)\b/.test(marker)) return undefined;
43
+ const row = new RegExp(`^(.+?): (${outcomes[skill]})$`);
44
+ const items = lines.flatMap((line): LegItem[] => {
45
+ let ticket: string | undefined;
46
+ const annotation = / \(([^()]+)\)$/.exec(line);
47
+ if (skill === "gatekeep-pr" && annotation && (trackerless || ticketRef(annotation[1]))) {
48
+ ticket = annotation[1];
49
+ line = line.slice(0, annotation.index);
50
+ }
51
+ const match = row.exec(line);
52
+ if (!match || match[1].startsWith("# ") || match[1].startsWith("#### ")) return [];
53
+ const [, ref, outcome] = match;
54
+ const pr = /^#\d+@[a-f\d]+$/i.test(ref) || /^https?:\/\/\S+\/pull\/\d+(?:@[a-f\d]+)?$/i.test(ref);
55
+ if (trackerless || pr || ticketRef(ref)) return [compact({ kind: pr || skill === "gatekeep-pr" ? "pr" : "ticket", key: ref, ticket, outcome }) as LegItem];
56
+ if (skill === "shape-ticket" && (outcome === "dropped" || outcome === "declined")) return [{ kind: "ticket", outcome }];
57
+ return [];
58
+ });
59
+ return items;
60
+ }
61
+
62
+ export function registerLegTelemetry(pi: ExtensionAPI, deps: Deps, runBound: () => boolean): () => Accumulators | undefined {
63
+ let active: { skill: LegSkill; provisional: boolean; session: string; ts: string; acc: Accumulators; models: Set<string> } | undefined;
64
+ let terminal: string | undefined;
65
+ let lastSkill: LegSkill | undefined;
66
+ const trackerless = (root: string): boolean => {
67
+ for (const path of [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"]) {
68
+ const text = deps.fs.readFile(join(root, path));
69
+ if (text === undefined) continue;
70
+ const section = text.split(/^##\s+Issue tracker\s*$/m)[1]?.split(/^##\s/m)[0] ?? "";
71
+ return /^-\s*tracker:\s*none\s*$/m.test(section);
72
+ }
73
+ return false;
74
+ };
75
+ const enabled = (ctx: ExtensionContext) => Number(process.env.PI_SUBAGENT_DEPTH ?? "0") === 0 && deps.settings(ctx.cwd).telemetry.enabled;
76
+ const reset = () => { active = undefined; terminal = undefined; };
77
+ const start = (skill: LegSkill | undefined, ctx: ExtensionContext, provisional = false) => {
78
+ if (!skill || !enabled(ctx) || runBound()) return;
79
+ if (active) {
80
+ if (active.provisional && !provisional) {
81
+ active.skill = skill;
82
+ active.provisional = false;
83
+ lastSkill = skill;
84
+ }
85
+ return;
86
+ }
87
+ lastSkill = skill;
88
+ active = { skill, provisional, session: ctx.sessionManager.getSessionId(), ts: deps.now(), acc: emptyAccumulators(), models: new Set() };
89
+ if (ctx.model) active.models.add(`${ctx.model.provider}/${ctx.model.id}`);
90
+ terminal = undefined;
91
+ };
92
+ const notify = (reason: string) => pi.sendMessage({
93
+ customType: "telemetry-warning", content: `telemetry: not pushed (${reason.split("\n")[0]})`, display: true,
94
+ });
95
+
96
+ for (const event of ["session_start", "session_switch", "session_fork", "session_tree", "session_shutdown"] as const) {
97
+ pi.on(event, async () => { reset(); lastSkill = undefined; });
98
+ }
99
+ pi.on("input", async (event, ctx) => {
100
+ terminal = undefined;
101
+ if (event.source !== "extension") start(legSkill(/^\/skill:([^\s]+)/.exec(event.text)?.[1]), ctx);
102
+ });
103
+ pi.on("before_agent_start", async (event, ctx) => {
104
+ terminal = undefined;
105
+ if (event.messageType === "user") {
106
+ const skill = legSkill(/^<skill name="([^"]+)" location="/.exec(event.prompt)?.[1]);
107
+ start(skill ?? lastSkill, ctx, !skill);
108
+ }
109
+ });
110
+ pi.on("tool_call", async (event, ctx) => {
111
+ terminal = undefined;
112
+ if (event.toolName !== "read") return;
113
+ const path = (event.input as { path?: unknown }).path;
114
+ if (typeof path === "string") start(legSkill(/(?:^|\/)skills\/([^/]+)\/SKILL\.md$/.exec(path)?.[1]), ctx);
115
+ });
116
+ pi.on("model_select", async (event, ctx) => {
117
+ if (active && enabled(ctx) && !runBound()) active.models.add(`${event.model.provider}/${event.model.id}`);
118
+ });
119
+ pi.on("message_end", async (event, ctx) => {
120
+ if (event.message.role !== "assistant") return;
121
+ if (active && enabled(ctx) && !runBound()) {
122
+ if (event.message.provider && event.message.model) active.models.add(`${event.message.provider}/${event.message.model}`);
123
+ const text = textOf(event.message.content);
124
+ terminal = /^gauntlet: (?:done|stuck)\b/.test(text) ? text : undefined;
125
+ } else terminal = undefined;
126
+ });
127
+ // agent_end can precede queued follow-on work; only the final live message at settlement seals.
128
+ pi.on("agent_settled", async (event, ctx) => {
129
+ if (!active || !terminal || event.aborted || !enabled(ctx) || runBound()) return;
130
+ const run = active;
131
+ const text = terminal;
132
+ try {
133
+ const checkout = await checkoutOf(ctx.cwd, deps.git, deps.jj);
134
+ if (!checkout || checkout.via !== "git") {
135
+ reset();
136
+ return notify(checkout ? "jj workspace: no git remote" : "no repository");
137
+ }
138
+ const items = settlementItems(run.skill, text, trackerless(checkout.toplevel));
139
+ if (!items) return;
140
+ reset();
141
+ const repo = (deps.telemetryRepo ?? telemetryRepo)(checkout.toplevel);
142
+ const [name, email] = await Promise.all([
143
+ deps.git(["config", "user.name"], checkout.toplevel),
144
+ deps.git(["config", "user.email"], checkout.toplevel),
145
+ ]);
146
+ const { cost, ...tokens } = Object.values(run.acc.phases).reduce((total, phase) => addTokens(total, phase.tokens ?? zeroTokens()), zeroTokens());
147
+ for (const persona of Object.values(run.acc.personas)) for (const model of persona.models) run.models.add(model);
148
+ const record: LegRecord = {
149
+ skill: run.skill, session: run.session, ts: run.ts, repo, items,
150
+ author: compact({ name: name.stdout.trim() || undefined, email: email.stdout.trim() || undefined }),
151
+ gauntlet_version: deps.settings(ctx.cwd).versions["pi-gauntlet"], models: [...run.models], tokens, cost,
152
+ };
153
+ const stamp = run.ts.replace(/[-:]/g, "");
154
+ const path = `${repo}/${run.skill}/${stamp}-${run.session}.yaml`;
155
+ const result = (deps.syncTelemetryBranch ?? syncTelemetryBranch)(checkout.toplevel, new Map([[path, stringify(record)]]), deps.settings(ctx.cwd).telemetry.remote);
156
+ if (!result.ok) notify(result.reason ?? "unknown failure");
157
+ } catch (error) {
158
+ reset();
159
+ notify(String((error as Error).message ?? error));
160
+ }
161
+ });
162
+ return () => runBound() ? undefined : active?.acc;
163
+ }
@@ -103,6 +103,8 @@ export const validLegs = (legs: unknown): legs is TelemetryLeg[] => Array.isArra
103
103
  ((typeof leg.sha === "string" && leg.sha.trim().length > 0 && leg.pr === undefined) ||
104
104
  (typeof leg.pr === "string" && /^https?:\/\/\S+$/.test(leg.pr) && leg.sha === undefined)));
105
105
  export interface TelemetryRecord {
106
+ repo?: string;
107
+ tickets?: string[];
106
108
  legs?: TelemetryLeg[];
107
109
  schema: 1; spec: string; run_id: string; branch?: string; status: RecordStatus; created_at: string; approved_at?: string; shipped_at?: string; abandoned_at?: string;
108
110
  supersedes?: string[]; fixes?: string[]; versions?: Record<string, string>; author?: { name?: string; email?: string }; agent_overrides?: Record<string, unknown>;
@@ -6,21 +6,23 @@ import { checkoutOfSync } from "./checkout.ts";
6
6
  import { computeGitDiff, computeJjDiff, JJ_FORK_POINT, type Buckets, type DiffOutcome, type RunResult } from "./telemetry-diff.ts";
7
7
 
8
8
  export interface VcsResult extends RunResult { ok: boolean }
9
- export type VcsRun = (args: string[], cwd: string, timeoutMs?: number) => VcsResult;
9
+ export type VcsRun = (args: string[], cwd: string, timeoutMs?: number, options?: { input?: string; env?: NodeJS.ProcessEnv; raw?: boolean }) => VcsResult;
10
10
  export interface Vcs { kind: "git" | "jj"; root: string; run: VcsRun }
11
11
 
12
12
  // Prompts off so a credential or editor request cannot hang a non-interactive caller.
13
13
  const NO_PROMPT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
14
14
 
15
- const runBinary = (bin: "git" | "jj"): VcsRun => (args, cwd, timeoutMs = 10_000) => {
15
+ const runBinary = (bin: "git" | "jj"): VcsRun => (args, cwd, timeoutMs = 10_000, options = {}) => {
16
16
  // jj diff --git returns full patches; spawnSync's 1 MiB default buffer truncates them (ENOBUFS).
17
- const r = spawnSync(bin, args, { cwd, encoding: "utf8", env: NO_PROMPT_ENV, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
17
+ const r = spawnSync(bin, args, { cwd, encoding: "utf8", input: options.input, env: { ...NO_PROMPT_ENV, ...options.env }, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
18
18
  const err = r.error as NodeJS.ErrnoException | undefined;
19
19
  const timedOut = err?.code === "ETIMEDOUT";
20
20
  const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || err?.message || `${bin} exited ${r.status}`;
21
- return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : (r.status ?? 1), stdout: (r.stdout ?? "").trim(), stderr };
21
+ return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : (r.status ?? 1), stdout: options.raw ? (r.stdout ?? "") : (r.stdout ?? "").trim(), stderr };
22
22
  };
23
23
 
24
+ export const gitRun: VcsRun = runBinary("git");
25
+
24
26
  export function vcsFor(absPath: string): Vcs | undefined {
25
27
  const co = checkoutOfSync(absPath);
26
28
  return co && { kind: co.via, root: co.toplevel, run: runBinary(co.via) };
@@ -53,22 +55,3 @@ export function changedNames(vcs: Vcs, base: string): VcsResult {
53
55
  if (!fork) return { ok: false, code: 1, stdout: "", stderr: "jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
54
56
  return vcs.run(["--color=never", "diff", "--name-only", "--from", fork, "--to", "@"], vcs.root);
55
57
  }
56
-
57
- // git: sealed == tracked and clean (ls-files --error-unmatch, diff --quiet HEAD).
58
- // jj: no commit step, so a shipped record is always sealed.
59
- export function recordSealed(vcs: Vcs, rel: string): boolean {
60
- if (vcs.kind === "jj") return true;
61
- return vcs.run(["ls-files", "--error-unmatch", "--", rel], vcs.root).ok
62
- && vcs.run(["diff", "--quiet", "HEAD", "--", rel], vcs.root).ok;
63
- }
64
-
65
- // jj has no commit step: `file track --include-ignored` is the `git add -f` equivalent - it
66
- // snapshots the stamped record into the working-copy change even under a gitignored dir.
67
- export function commitRecordFile(vcs: Vcs, rel: string, message: string): VcsResult {
68
- if (vcs.kind === "jj") return vcs.run(["file", "track", "--include-ignored", "--", rel], vcs.root);
69
- const add = vcs.run(["add", "-f", "--", rel], vcs.root);
70
- const commit = add.ok ? vcs.run(["commit", "-q", "-m", message, "--", rel], vcs.root, 30_000) : add;
71
- // Unstage the -f add so a retry sees the same state.
72
- if (!commit.ok) vcs.run(["reset", "-q", "--", rel], vcs.root);
73
- return commit;
74
- }