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.
- package/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/bin/gauntlet-performance.mjs +477 -64
- package/bin/gauntlet-performance.test.mjs +27 -27
- package/bin/gauntlet-spec-index.mjs +290 -47
- package/bin/gauntlet-spec-index.test.mjs +42 -18
- package/bin/gauntlet-telemetry-seal.mjs +220 -77
- package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
- package/extensions/lib/gauntlet-settings.test.ts +12 -14
- package/extensions/lib/gauntlet-settings.ts +7 -16
- package/extensions/lib/telemetry-branch.test.ts +102 -0
- package/extensions/lib/telemetry-branch.ts +145 -0
- package/extensions/lib/telemetry-diff.test.ts +2 -2
- package/extensions/lib/telemetry-diff.ts +1 -1
- package/extensions/lib/telemetry-leg.ts +163 -0
- package/extensions/lib/telemetry-record.ts +2 -0
- package/extensions/lib/vcs.ts +6 -23
- package/extensions/telemetry-leg-failures.test.ts +138 -0
- package/extensions/telemetry-leg-fields.test.ts +130 -0
- package/extensions/telemetry-leg-no-write.test.ts +90 -0
- package/extensions/telemetry-leg-rounds.test.ts +157 -0
- package/extensions/telemetry-leg-trackers.test.ts +114 -0
- package/extensions/telemetry-leg.test.ts +151 -0
- package/extensions/telemetry-settlement-contract.test.ts +67 -0
- package/extensions/telemetry.test.ts +44 -27
- package/extensions/telemetry.ts +68 -48
- package/extensions/test-support/git-env.mjs +13 -0
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/check-delivery/SKILL.md +2 -0
- package/skills/finishing-a-development-branch/SKILL.md +13 -13
- package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
- package/skills/gatekeep-pr/reference/batch.md +3 -1
- package/skills/gatekeep-pr/reference/cleanup.md +1 -1
- package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
- package/skills/gauntlet-resume/reference/seed.md +1 -6
- package/skills/shape-ticket/SKILL.md +1 -1
- 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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
+
telemetry(root, yaml, "status: shipped\nderived:\n modified_files:\n - nope1\n - nope2\n");
|
|
244
268
|
assert.equal(files(), "");
|
|
245
|
-
|
|
269
|
+
telemetry(root, yaml, 'status: shipped\nderived:\n modified_files: "x"\n');
|
|
246
270
|
assert.equal(files(), "missing");
|
|
247
|
-
|
|
271
|
+
telemetry(root, yaml, "");
|
|
248
272
|
assert.equal(files(), "");
|
|
249
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
if (
|
|
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
|
|
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
|
|
203
|
-
if (!files.has(
|
|
204
|
-
const bucket = classifyBucket(
|
|
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",
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
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>] [--
|
|
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,
|
|
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 === "--
|
|
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
|
|
443
|
-
else usage();
|
|
535
|
+
} else usage();
|
|
444
536
|
}
|
|
445
|
-
if (
|
|
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 (!
|
|
545
|
+
if (!existsSync3(file)) return {};
|
|
450
546
|
try {
|
|
451
|
-
return JSON.parse(
|
|
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 ||
|
|
460
|
-
const preset = readLayer(
|
|
461
|
-
const repo = readLayer(
|
|
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
|
-
|
|
559
|
+
const { specDirs } = resolveFlowGuards(merged);
|
|
560
|
+
return { telemetry: resolveTelemetry(merged), planDirs: planDirsFor(specDirs), specDirs };
|
|
464
561
|
}
|
|
465
562
|
function walkRecords(absDir) {
|
|
466
|
-
if (!
|
|
563
|
+
if (!existsSync3(absDir)) return [];
|
|
467
564
|
const out = [];
|
|
468
565
|
for (const e of readdirSync(absDir, { withFileTypes: true })) {
|
|
469
|
-
const p =
|
|
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 =
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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) &&
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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 =
|
|
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
|
+
});
|