pi-gauntlet 5.19.0 → 5.20.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 +6 -0
- package/README.md +1 -1
- package/bin/gauntlet-telemetry-seal.mjs +346 -0
- package/bin/gauntlet-telemetry-seal.test.mjs +271 -0
- package/extensions/lib/telemetry-diff.test.ts +108 -0
- package/extensions/lib/telemetry-diff.ts +61 -0
- package/extensions/lib/telemetry-paths.test.ts +11 -35
- package/extensions/lib/telemetry-paths.ts +13 -42
- package/extensions/lib/telemetry-record.test.ts +0 -6
- package/extensions/lib/telemetry-ship.test.ts +1 -5
- package/extensions/lib/telemetry-ship.ts +1 -4
- package/extensions/telemetry.test.ts +243 -135
- package/extensions/telemetry.ts +148 -201
- package/package.json +2 -2
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +19 -16
- package/skills/gatekeep-pr/reference/assessment.md +0 -13
- package/skills/gatekeep-pr/reference/decision-menu.md +1 -2
- package/skills/gatekeep-pr/reference/post-selection-loop.md +3 -3
- package/skills/gauntlet-performance/SKILL.md +1 -1
- package/skills/gauntlet-resume/reference/brief-contract.md +10 -6
- package/bin/gauntlet-telemetry-salvage.mjs +0 -273
- package/bin/gauntlet-telemetry-salvage.test.mjs +0 -466
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.20.0 - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Telemetry is gauntlet-bound: the recorder binds only after `phase_tracker start brainstorm` armed the flow (a spec written outside the gauntlet gets no record), never stages or commits the record, and hides it through `<git-common-dir>/info/exclude` until `finishing-a-development-branch` seals it. The new `gauntlet-telemetry-seal` bin replaces `gauntlet-telemetry-salvage`: it stamps `shipped`, computes the diff, re-derives, and commits the record once before the PR push or the squash; a nonzero exit stops the landing. `gatekeep-pr` no longer mentions telemetry. Records are no longer marked `abandoned` on discard, and the recorder does not bind to already sealed records on disk; `spec_edits_after_ship` is no longer produced (the field remains declared and old records parse). Plain jj workspaces keep sealing on `phase complete ship`. In-flight branches with a committed record show it as modified until the seal.
|
|
8
|
+
|
|
3
9
|
## v5.19.0 - 2026-09-26
|
|
4
10
|
|
|
5
11
|
### Added
|
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
71
71
|
|
|
72
72
|
- **20 skills** - the workflow logic. Thirteen activate automatically when pi sees the matching kind of task, and each one gates the next: `brainstorming`, `writing-plans`, `roasting-the-spec`, `test-driven-development`, `subagent-driven-development`, `dispatching-parallel-agents`, `verification-before-completion`, `requesting-code-review`, `receiving-code-review`, `using-git-worktrees`, `finishing-a-development-branch`, `writing-skills`, `linear` (reads/searches/comments on/manages Linear tickets via the `linearis` CLI; owns all linearis mechanics and the `## Issue tracker` overrides schema; tracker-facing skills route to it). Seven more are explicit-invocation-only (`disable-model-invocation: true`): `shape-ticket` creates or repairs one tracker issue per run against a Context/Problem/Idea/Acceptance-Criteria template, gated by an AC integrity check, a cheap council roast, and a single human-confirmed write - run it with `/skill:shape-ticket`. `gatekeep-pr` is consent-gated pre-merge verification of a PR against its issue - read-only gathering, verification evidence resolved CI-first (green checks on the exact assessed head count as evidence; the project's verification command runs only as fallback), a rubric-based review, then a deterministic authorship-aware menu with stable finding IDs (P#/L#/C#/F#) and numbered pre-composed courses (fixes execute as a single parallel-safe wave: one gate run, one re-review, one push); nothing mutates (fixes, pushes, reviews, merges) until you pick a row - run it with `/skill:gatekeep-pr <pr>`. `check-delivery` is a post-merge detective control: proves an issue actually shipped (default-branch landing, delivery target, per-AC evidence) before its tracker status advances; it never writes a terminal status - run it with `/skill:check-delivery <ref>`. `chase-bug` is human-only bug triage: read-only root-cause discovery to an evidenced verdict menu (real bug -> ticket/brainstorm/hotfix/respond; five negative verdicts), then a gated response to the reporter for addressable origins (GitHub issue / tracker ticket) and a rendered verdict summary otherwise - it never fixes during triage; the hotfix row hands off to `skills/chase-bug/hotfix.md` after the menu - run it with `/skill:chase-bug`. `gauntlet-performance` reads the committed run telemetry (current repo by default, `--dir <path>` adds others) through the parse-only `gauntlet-performance` CLI and answers with one example-led recommendation, 3-5 cornerstone numbers, and a menu of at most three actions (render a report file, open the recommendation as a ticket via `shape-ticket`, drill into one run); it writes nothing unless you pick render - run it with `/skill:gauntlet-performance [--dir <path>]... [--since <version>]`. `gauntlet-handoff` ends a session whose flow a fresh session will continue: it invokes pi-cohort's `handoff` skill (`--out <path>` or `--key <name>`, default key = the run worktree's branch with `/` flattened to `-`, default file `<tmpdir>/pi-handoff/<key>.md`), then appends the gauntlet process-state section (phase/plan tracker status) per the shared contract `skills/gauntlet-resume/reference/brief-contract.md` - run it with `/skill:gauntlet-handoff [--out <path> | --key <name>]`. `gauntlet-resume` is the only way back into an interrupted flow from a fresh session: it takes a `gauntlet-handoff` brief (a file path, pasted text, or - with no arguments - a pick from the briefs under `<tmpdir>/pi-handoff/`), a bare worktree that already holds a spec, or a spec tracked on main under a configured spec dir (a spec seed: it creates or reuses the worktree through `using-git-worktrees` and pins the spec), restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief-file> | <spec>.md | <worktree-name-or-path>]`.
|
|
73
73
|
- **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
|
|
74
|
-
- **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one
|
|
74
|
+
- **4 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you push or open a PR without a passing test run since your last edit; a phase-tracker flow guard reminds on implement-phase commits missing spec/code review. In a brainstorming-entered flow, phase-tracker rejects `implement` or `verify` completion while tracker tasks remain pending or in progress; see [its configuration reference](./doc/configuration.md#phase-tracker). phase-tracker also registers `plan_check`, which verifies a plan against its spec and against the grammar in [skills/writing-plans/reference/plan-contract.md](./skills/writing-plans/reference/plan-contract.md), including that each task's `Tests:` commands are selective and never the full suite; a pass stamps the plan for implementation. `telemetry` records one YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) that ships in the squash beside the spec - it is written only inside a brainstorming-entered flow, kept untracked and git-excluded until `finishing-a-development-branch` seals and commits it once with `gauntlet-telemetry-seal` - and blocks a brainstorm `write` into an already-shipped spec; see [its configuration reference](./doc/configuration.md#telemetry). See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
|
|
75
75
|
|
|
76
76
|
pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs, and warns once if the main loop writes code during implement (subagents own implement-phase edits). A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
|
|
77
77
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bins/gauntlet-telemetry-seal.mjs
|
|
4
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { isAbsolute as isAbsolute2, join } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
|
|
10
|
+
// extensions/lib/gauntlet-settings.ts
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
function mergeGauntlet(preset, repo) {
|
|
13
|
+
return { ...preset ?? {}, ...repo ?? {} };
|
|
14
|
+
}
|
|
15
|
+
var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
16
|
+
var joinWarn = (ws) => ws.length ? ws.join("; ") : void 0;
|
|
17
|
+
var DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
|
|
18
|
+
var DEFAULT_TELEMETRY_BUCKETS = [
|
|
19
|
+
["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
|
|
20
|
+
["docs", ["**/*.md"]],
|
|
21
|
+
["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]]
|
|
22
|
+
];
|
|
23
|
+
function resolveTelemetry(g) {
|
|
24
|
+
const t = g.telemetry;
|
|
25
|
+
const warnings = [];
|
|
26
|
+
const enabled = t?.enabled !== false;
|
|
27
|
+
let dir = DEFAULT_TELEMETRY_DIR;
|
|
28
|
+
if (t?.dir !== void 0) {
|
|
29
|
+
const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
|
|
30
|
+
const canonical = value.replace(/\\/g, "/");
|
|
31
|
+
const normalized = path.posix.normalize(canonical);
|
|
32
|
+
if (value && path.win32.parse(canonical).root === "" && normalized !== ".." && !normalized.startsWith("../")) dir = normalized;
|
|
33
|
+
else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
|
|
34
|
+
}
|
|
35
|
+
let buckets = DEFAULT_TELEMETRY_BUCKETS;
|
|
36
|
+
if (t?.buckets !== void 0) {
|
|
37
|
+
const b = t.buckets;
|
|
38
|
+
const valid = b !== null && typeof b === "object" && !Array.isArray(b) && Object.keys(b).length > 0 && Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
|
|
39
|
+
if (valid) buckets = Object.entries(b).map(([name, globs]) => [name, [...globs]]);
|
|
40
|
+
else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
|
|
41
|
+
}
|
|
42
|
+
return { enabled, dir, buckets, warning: joinWarn(warnings) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// extensions/lib/telemetry-paths.ts
|
|
46
|
+
import { isAbsolute, matchesGlob, relative, resolve } from "node:path";
|
|
47
|
+
|
|
48
|
+
// extensions/lib/phase-tracker-helpers.ts
|
|
49
|
+
var STMT_START = "(?:^|[\\n;&|(])\\s*";
|
|
50
|
+
|
|
51
|
+
// extensions/lib/telemetry-paths.ts
|
|
52
|
+
var toPosix = (p) => p.split("\\").join("/");
|
|
53
|
+
var isSpecPath = (rel) => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
|
|
54
|
+
var recordPathFor = (dir, specRel) => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
|
|
55
|
+
function repoRelativeToolPath(toplevel, cwd, p) {
|
|
56
|
+
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
57
|
+
const rel = toPosix(relative(toplevel, abs));
|
|
58
|
+
if (rel === "" || rel.startsWith("../") || rel === ".." || isAbsolute(rel)) return void 0;
|
|
59
|
+
return rel;
|
|
60
|
+
}
|
|
61
|
+
var GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
|
|
62
|
+
var SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
|
|
63
|
+
var SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
|
|
64
|
+
function classifyBucket(rel, buckets) {
|
|
65
|
+
for (const [name, globs] of buckets) if (globs.some((g) => matchesGlob(rel, g))) return name;
|
|
66
|
+
return "code";
|
|
67
|
+
}
|
|
68
|
+
function numstatPath(raw) {
|
|
69
|
+
const braced = raw.replace(/\{([^{}]*) => ([^{}]*)\}/g, (_m, _a, b) => b).replace(/\/\//g, "/");
|
|
70
|
+
const arrow = braced.indexOf(" => ");
|
|
71
|
+
return arrow >= 0 ? braced.slice(arrow + 4) : braced;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// extensions/lib/telemetry-ship.ts
|
|
75
|
+
function modifiedFilesFrom(nameOnly, spec, dir) {
|
|
76
|
+
const dirPrefix = dir.replace(/\/+$/, "") + "/";
|
|
77
|
+
return nameOnly.split("\n").filter((f) => f && f !== spec && !/(^|\/)doc\/plans\//.test(f) && !f.startsWith(dirPrefix)).sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// extensions/lib/telemetry-diff.ts
|
|
81
|
+
function aggregateNumstat(numstat, files, buckets) {
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const line of numstat.split("\n")) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
const [ins, del, ...rest] = line.split(" ");
|
|
86
|
+
const path2 = numstatPath(rest.join(" "));
|
|
87
|
+
if (!files.has(path2)) continue;
|
|
88
|
+
const bucket = classifyBucket(path2, buckets);
|
|
89
|
+
const stat = out[bucket] ??= { files: 0, insertions: 0, deletions: 0 };
|
|
90
|
+
stat.files += 1;
|
|
91
|
+
stat.insertions += ins === "-" ? 0 : Number(ins) || 0;
|
|
92
|
+
stat.deletions += del === "-" ? 0 : Number(del) || 0;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
var firstLine = (s) => s.trim().split("\n")[0];
|
|
97
|
+
async function computeGitDiff(o) {
|
|
98
|
+
const mb = await o.git(["merge-base", "HEAD", o.base], o.cwd);
|
|
99
|
+
if (mb.code !== 0 || !mb.stdout.trim()) return { warning: `diff omitted: merge-base failed: ${firstLine(mb.stderr)}` };
|
|
100
|
+
const base = mb.stdout.trim();
|
|
101
|
+
const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
|
|
102
|
+
const files = modifiedFilesFrom(names.stdout, o.spec, o.dir);
|
|
103
|
+
const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
|
|
104
|
+
const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
|
|
105
|
+
return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// extensions/lib/telemetry-record.ts
|
|
109
|
+
import { Document, isCollection, isMap, isSeq, parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
110
|
+
var PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
|
|
111
|
+
var emptyAccumulators = () => ({ phases: {}, personas: {}, reviews: {}, spec_writes: {}, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, conformance_loops: 0, amendments: 0, spec_edits_after_ship: 0, events_dropped: 0 });
|
|
112
|
+
var addTokens = (a, b) => ({ input: (a?.input ?? 0) + b.input, output: (a?.output ?? 0) + b.output, cache_read: (a?.cache_read ?? 0) + b.cache_read, cache_write: (a?.cache_write ?? 0) + b.cache_write, cost: (a?.cost ?? 0) + b.cost });
|
|
113
|
+
var optSum = (a, b) => a === void 0 && b === void 0 ? void 0 : (a ?? 0) + (b ?? 0);
|
|
114
|
+
var optMax = (a, b) => a === void 0 ? b : b === void 0 ? a : Math.max(a, b);
|
|
115
|
+
var compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
116
|
+
function foldAccumulators(a, b) {
|
|
117
|
+
const out = emptyAccumulators();
|
|
118
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(a.phases), ...Object.keys(b.phases)])) {
|
|
119
|
+
const x = a.phases[k] ?? {}, y = b.phases[k] ?? {};
|
|
120
|
+
out.phases[k] = compact({ tokens: x.tokens && y.tokens ? addTokens(x.tokens, y.tokens) : x.tokens ?? y.tokens, compactions: optSum(x.compactions, y.compactions), user_messages: optSum(x.user_messages, y.user_messages), peak_context: optMax(x.peak_context, y.peak_context) });
|
|
121
|
+
}
|
|
122
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(a.personas), ...Object.keys(b.personas)])) {
|
|
123
|
+
const x = a.personas[k], y = b.personas[k];
|
|
124
|
+
out.personas[k] = compact({ dispatches: (x?.dispatches ?? 0) + (y?.dispatches ?? 0), models: [.../* @__PURE__ */ new Set([...x?.models ?? [], ...y?.models ?? []])], tokens: x?.tokens && y?.tokens ? addTokens(x.tokens, y.tokens) : x?.tokens ?? y?.tokens });
|
|
125
|
+
}
|
|
126
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(a.reviews), ...Object.keys(b.reviews)])) {
|
|
127
|
+
const x = a.reviews[k], y = b.reviews[k];
|
|
128
|
+
const findings = x?.findings || y?.findings ? { blocker: (x?.findings?.blocker ?? 0) + (y?.findings?.blocker ?? 0), major: (x?.findings?.major ?? 0) + (y?.findings?.major ?? 0), minor: (x?.findings?.minor ?? 0) + (y?.findings?.minor ?? 0) } : void 0;
|
|
129
|
+
out.reviews[k] = compact({ dispatches: (x?.dispatches ?? 0) + (y?.dispatches ?? 0), nonzero_exit: (x?.nonzero_exit ?? 0) + (y?.nonzero_exit ?? 0), findings });
|
|
130
|
+
}
|
|
131
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(a.spec_writes), ...Object.keys(b.spec_writes)])) {
|
|
132
|
+
const x = a.spec_writes[k], y = b.spec_writes[k];
|
|
133
|
+
out.spec_writes[k] = { count: (x?.count ?? 0) + (y?.count ?? 0), last_sha256: y?.last_sha256 ?? x?.last_sha256 ?? "" };
|
|
134
|
+
}
|
|
135
|
+
for (const g of Object.keys(out.gates)) out.gates[g] = a.gates[g] + b.gates[g];
|
|
136
|
+
out.conformance_loops = a.conformance_loops + b.conformance_loops;
|
|
137
|
+
out.conformance_open_gaps = b.conformance_open_gaps ?? a.conformance_open_gaps;
|
|
138
|
+
out.amendments = a.amendments + b.amendments;
|
|
139
|
+
out.spec_edits_after_ship = a.spec_edits_after_ship + b.spec_edits_after_ship;
|
|
140
|
+
out.events_dropped = a.events_dropped + b.events_dropped;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function newRecord(o) {
|
|
144
|
+
return { schema: 1, spec: o.spec, run_id: o.runId, branch: o.branch, status: "in_progress", created_at: o.now, sessions: [o.session], derived: { duration_s: 0, phases: {}, personas: {}, conformance_loops: 0, gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }, amendments: 0, spec_edits_after_ship: 0, spec_writes: {}, events_dropped: 0 }, accumulators: {}, events: [] };
|
|
145
|
+
}
|
|
146
|
+
var totalAccumulators = (rec) => Object.values(rec.accumulators).reduce((acc, block) => foldAccumulators(acc, block), emptyAccumulators());
|
|
147
|
+
var seconds = (a, b) => Math.max(0, Math.round((Date.parse(b) - Date.parse(a)) / 1e3));
|
|
148
|
+
function liveShipEvent(events) {
|
|
149
|
+
let live;
|
|
150
|
+
for (const e of events) {
|
|
151
|
+
if (e.kind === "ship") live = e;
|
|
152
|
+
else if (e.kind === "ship_failed") live = void 0;
|
|
153
|
+
}
|
|
154
|
+
return live;
|
|
155
|
+
}
|
|
156
|
+
function derive(rec, now) {
|
|
157
|
+
const acc = totalAccumulators(rec), phases = {};
|
|
158
|
+
for (const e of rec.events) {
|
|
159
|
+
if (e.kind !== "phase") continue;
|
|
160
|
+
if (e.action === "reset") {
|
|
161
|
+
for (const p of PHASES) if (phases[p]) phases[p] = compact({ ...phases[p], started_at: void 0, completed_at: void 0, duration_s: void 0, model: void 0, thinking: void 0 });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!e.name) continue;
|
|
165
|
+
const cur = phases[e.name] ?? {};
|
|
166
|
+
if (e.action === "start") phases[e.name] = compact({ ...cur, started_at: e.ts, completed_at: void 0, duration_s: void 0, model: e.model, thinking: e.thinking });
|
|
167
|
+
else if (cur.started_at) phases[e.name] = { ...cur, completed_at: e.ts, duration_s: seconds(cur.started_at, e.ts) };
|
|
168
|
+
}
|
|
169
|
+
for (const k of Object.keys(acc.phases)) phases[k] = compact({ ...phases[k] ?? {}, ...acc.phases[k] });
|
|
170
|
+
const live = liveShipEvent(rec.events);
|
|
171
|
+
return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : void 0, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped });
|
|
172
|
+
}
|
|
173
|
+
var stripUndefined = (v) => {
|
|
174
|
+
if (Array.isArray(v)) return v.map(stripUndefined);
|
|
175
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== void 0).map(([k, x]) => [k, stripUndefined(x)]));
|
|
176
|
+
return v;
|
|
177
|
+
};
|
|
178
|
+
var flowLeafChildren = (map, blockLists = /* @__PURE__ */ new Set()) => {
|
|
179
|
+
for (const pair of map.items) {
|
|
180
|
+
const key = String(pair.key);
|
|
181
|
+
const child = pair.value;
|
|
182
|
+
if (!isCollection(child) || blockLists.has(key)) continue;
|
|
183
|
+
if (isMap(child) && child.items.length > 0 && child.items.every((item) => isMap(item.value))) {
|
|
184
|
+
for (const item of child.items) if (isCollection(item.value)) item.value.flow = true;
|
|
185
|
+
} else {
|
|
186
|
+
child.flow = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
var flowModelLists = (node) => {
|
|
191
|
+
if (isMap(node)) {
|
|
192
|
+
for (const pair of node.items) {
|
|
193
|
+
if (String(pair.key) === "models" && isSeq(pair.value)) pair.value.flow = true;
|
|
194
|
+
flowModelLists(pair.value);
|
|
195
|
+
}
|
|
196
|
+
} else if (isSeq(node)) {
|
|
197
|
+
for (const item of node.items) flowModelLists(item);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
function serializeRecord(rec) {
|
|
201
|
+
const { derived, accumulators, events, ...head } = rec;
|
|
202
|
+
const body = stripUndefined({ ...head, derived, accumulators });
|
|
203
|
+
const doc = new Document(body);
|
|
204
|
+
const derivedNode = doc.get("derived", true);
|
|
205
|
+
if (isMap(derivedNode)) flowLeafChildren(derivedNode, /* @__PURE__ */ new Set(["modified_files"]));
|
|
206
|
+
const accumulatorNode = doc.get("accumulators", true);
|
|
207
|
+
if (isMap(accumulatorNode)) {
|
|
208
|
+
for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
|
|
209
|
+
}
|
|
210
|
+
flowModelLists(doc.contents);
|
|
211
|
+
const bodyText = doc.toString({ lineWidth: 0 });
|
|
212
|
+
const eventLines = events.map((e) => " - " + stringifyYaml(stripUndefined(e), { collectionStyle: "flow", lineWidth: 0 }).trim());
|
|
213
|
+
return bodyText + "events:\n" + (eventLines.length ? eventLines.join("\n") + "\n" : "");
|
|
214
|
+
}
|
|
215
|
+
function parseRecord(text) {
|
|
216
|
+
let doc;
|
|
217
|
+
try {
|
|
218
|
+
doc = parseYaml(text);
|
|
219
|
+
} catch {
|
|
220
|
+
return void 0;
|
|
221
|
+
}
|
|
222
|
+
if (!doc || typeof doc !== "object") return void 0;
|
|
223
|
+
const d = doc;
|
|
224
|
+
if (d.schema !== 1 || typeof d.spec !== "string" || typeof d.run_id !== "string") return void 0;
|
|
225
|
+
const accumulators = Object.fromEntries(Object.entries(d.accumulators ?? {}).flatMap(
|
|
226
|
+
([session, block]) => block && typeof block === "object" && !Array.isArray(block) ? [[session, { ...emptyAccumulators(), ...block }]] : []
|
|
227
|
+
));
|
|
228
|
+
return { ...d, sessions: Array.isArray(d.sessions) ? d.sessions : [], derived: d.derived ?? newRecord({ spec: d.spec, session: "", now: d.created_at ?? "", runId: d.run_id }).derived, accumulators, events: Array.isArray(d.events) ? d.events : [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/bins/gauntlet-telemetry-seal.mjs
|
|
232
|
+
var GIT_TIMEOUT_MS = 1e4;
|
|
233
|
+
var COMMIT_TIMEOUT_MS = 3e4;
|
|
234
|
+
var GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
|
|
235
|
+
var OPTIONS = /* @__PURE__ */ new Set(["pr", "squash"]);
|
|
236
|
+
var fail = (code, message) => {
|
|
237
|
+
process.stderr.write(message + "\n");
|
|
238
|
+
process.exit(code);
|
|
239
|
+
};
|
|
240
|
+
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>]");
|
|
241
|
+
function parseArgs(argv) {
|
|
242
|
+
const opts = { worktree: void 0, option: void 0, base: void 0, spec: void 0, dir: void 0 };
|
|
243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
244
|
+
const a = argv[i];
|
|
245
|
+
const value = argv[i + 1];
|
|
246
|
+
const takes = (key) => {
|
|
247
|
+
if (value === void 0 || value.startsWith("--")) usage();
|
|
248
|
+
opts[key] = argv[++i];
|
|
249
|
+
};
|
|
250
|
+
if (a === "--worktree") takes("worktree");
|
|
251
|
+
else if (a === "--option") takes("option");
|
|
252
|
+
else if (a === "--base") takes("base");
|
|
253
|
+
else if (a === "--spec") takes("spec");
|
|
254
|
+
else if (a === "--dir") takes("dir");
|
|
255
|
+
else usage();
|
|
256
|
+
}
|
|
257
|
+
if (!opts.worktree || !isAbsolute2(opts.worktree) || !opts.base || !OPTIONS.has(opts.option)) usage();
|
|
258
|
+
return opts;
|
|
259
|
+
}
|
|
260
|
+
function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
|
|
261
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
|
|
262
|
+
const timedOut = r.error?.code === "ETIMEDOUT";
|
|
263
|
+
const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
|
|
264
|
+
return { ok: !timedOut && r.status === 0, code: timedOut ? 1 : r.status ?? 1, stdout: (r.stdout ?? "").trim(), stderr };
|
|
265
|
+
}
|
|
266
|
+
var gitRunner = (args, cwd) => {
|
|
267
|
+
const r = git(cwd, args);
|
|
268
|
+
return { code: r.code, stdout: r.stdout, stderr: r.stderr };
|
|
269
|
+
};
|
|
270
|
+
function readLayer(file) {
|
|
271
|
+
if (!existsSync(file)) return {};
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
274
|
+
} catch (e) {
|
|
275
|
+
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
276
|
+
`);
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function telemetrySettings(root) {
|
|
281
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
282
|
+
const preset = readLayer(join(agentDir, "settings.json"));
|
|
283
|
+
const repo = readLayer(join(root, ".pi", "settings.json"));
|
|
284
|
+
return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
|
|
285
|
+
}
|
|
286
|
+
var isoNow = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
287
|
+
var trackedAndClean = (root, rec) => git(root, ["ls-files", "--error-unmatch", "--", rec]).ok && git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok;
|
|
288
|
+
async function seal(root, rec, o) {
|
|
289
|
+
const abs = join(root, rec);
|
|
290
|
+
if (!existsSync(abs)) fail(2, `no record at ${rec}`);
|
|
291
|
+
const prior = readFileSync(abs);
|
|
292
|
+
const parsed = parseRecord(prior.toString("utf8"));
|
|
293
|
+
if (!parsed) fail(2, `unparseable record ${rec}: schema 1 with spec and run_id required`);
|
|
294
|
+
if (parsed.status !== "in_progress") {
|
|
295
|
+
if (parsed.status !== "shipped" || trackedAndClean(root, rec)) return console.log(`already sealed ${rec}`);
|
|
296
|
+
} else {
|
|
297
|
+
const now = isoNow();
|
|
298
|
+
parsed.status = "shipped";
|
|
299
|
+
parsed.shipped_at = now;
|
|
300
|
+
parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "ship", option: o.option });
|
|
301
|
+
const out = await computeGitDiff({ git: gitRunner, cwd: root, spec: parsed.spec, dir: o.dir, buckets: o.buckets, base: o.base });
|
|
302
|
+
if (out.warning) parsed.events.push({ ts: now, session: parsed.sessions.at(-1) ?? "seal", phase: "ship", kind: "warning", message: out.warning });
|
|
303
|
+
parsed.derived.diff = out.diff;
|
|
304
|
+
parsed.derived.modified_files = out.modified_files;
|
|
305
|
+
parsed.derived = derive(parsed, now);
|
|
306
|
+
writeFileSync(abs, serializeRecord(parsed));
|
|
307
|
+
}
|
|
308
|
+
const rollback = (reason) => {
|
|
309
|
+
writeFileSync(abs, prior);
|
|
310
|
+
git(root, ["reset", "-q", "--", rec]);
|
|
311
|
+
fail(1, `seal failed ${rec}: ${reason}`);
|
|
312
|
+
};
|
|
313
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
314
|
+
if (!add.ok) rollback(add.stderr);
|
|
315
|
+
const commit = git(root, ["commit", "-q", "-m", `telemetry: ${parsed.spec}`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
316
|
+
if (!commit.ok) rollback(commit.stderr);
|
|
317
|
+
console.log(`sealed ${rec}`);
|
|
318
|
+
}
|
|
319
|
+
async function main() {
|
|
320
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
321
|
+
const override = opts.dir === void 0 ? void 0 : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
|
|
322
|
+
if (override?.warning) usage();
|
|
323
|
+
const top = existsSync(opts.worktree) ? git(opts.worktree, ["rev-parse", "--show-toplevel"]) : { ok: false };
|
|
324
|
+
if (!top.ok) fail(1, "not a git checkout");
|
|
325
|
+
const root = top.stdout;
|
|
326
|
+
let rel;
|
|
327
|
+
if (opts.spec) {
|
|
328
|
+
const specAbs = isAbsolute2(opts.spec) && existsSync(opts.spec) ? realpathSync(opts.spec) : opts.spec;
|
|
329
|
+
rel = repoRelativeToolPath(root, root, specAbs);
|
|
330
|
+
if (!rel || !isSpecPath(rel)) usage();
|
|
331
|
+
}
|
|
332
|
+
const telemetry = telemetrySettings(root);
|
|
333
|
+
if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}
|
|
334
|
+
`);
|
|
335
|
+
if (!telemetry.enabled) return console.log("telemetry disabled");
|
|
336
|
+
const dir = override?.dir ?? telemetry.dir;
|
|
337
|
+
if (!git(root, ["rev-parse", "--verify", "-q", `${opts.base}^{commit}`]).ok) fail(1, `no base ref ${opts.base}`);
|
|
338
|
+
const o = { option: opts.option, base: opts.base, dir, buckets: telemetry.buckets };
|
|
339
|
+
if (opts.spec) return seal(root, recordPathFor(dir, rel), o);
|
|
340
|
+
const diff = git(root, ["diff", "--name-only", `${opts.base}...HEAD`]);
|
|
341
|
+
if (!diff.ok) fail(1, `git diff failed: ${diff.stderr}`);
|
|
342
|
+
const records = diff.stdout.split("\n").filter((f) => f && isSpecPath(f)).map((f) => recordPathFor(dir, f)).filter((rec) => existsSync(join(root, rec)));
|
|
343
|
+
if (records.length === 0) return console.log("no telemetry run");
|
|
344
|
+
for (const rec of records) await seal(root, rec, o);
|
|
345
|
+
}
|
|
346
|
+
await main();
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, realpathSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
|
|
10
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-seal.mjs");
|
|
11
|
+
const SPEC = "doc/specs/x.md";
|
|
12
|
+
const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
|
|
13
|
+
const RECORD = [
|
|
14
|
+
"schema: 1",
|
|
15
|
+
"spec: doc/specs/x.md",
|
|
16
|
+
"run_id: r-1",
|
|
17
|
+
"status: in_progress",
|
|
18
|
+
"created_at: 2026-09-17T16:00:00Z",
|
|
19
|
+
"sessions: [s1]",
|
|
20
|
+
"derived:",
|
|
21
|
+
" duration_s: 60",
|
|
22
|
+
" phases: {}",
|
|
23
|
+
" personas: {}",
|
|
24
|
+
" conformance_loops: 0",
|
|
25
|
+
" gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }",
|
|
26
|
+
" amendments: 0",
|
|
27
|
+
" spec_edits_after_ship: 0",
|
|
28
|
+
" spec_writes: {}",
|
|
29
|
+
" events_dropped: 0",
|
|
30
|
+
"accumulators: {}",
|
|
31
|
+
"events:",
|
|
32
|
+
" - { ts: 2026-09-17T16:00:00Z, session: s1, phase: brainstorm, kind: phase, action: start, name: brainstorm }",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
const SEALED = RECORD.replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:00:00Z");
|
|
36
|
+
|
|
37
|
+
const git = (cwd, args, env = {}) =>
|
|
38
|
+
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env } });
|
|
39
|
+
const out = (cwd, args) => git(cwd, args).stdout.trim();
|
|
40
|
+
const write = (root, rel, text) => {
|
|
41
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, rel), text);
|
|
43
|
+
};
|
|
44
|
+
const commit = (root, msg, ...paths) => {
|
|
45
|
+
git(root, ["add", "-f", "--", ...paths]);
|
|
46
|
+
git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
|
|
47
|
+
return out(root, ["rev-parse", "HEAD"]);
|
|
48
|
+
};
|
|
49
|
+
// main: README only. feat: spec + src/a.ts; the record is untracked unless a test commits it.
|
|
50
|
+
const repo = (options = {}) => {
|
|
51
|
+
const record = Object.hasOwn(options, "record") ? options.record : RECORD;
|
|
52
|
+
const root = mkdtempSync(join(tmpdir(), "gts-"));
|
|
53
|
+
git(root, ["init", "-q", "-b", "main"]);
|
|
54
|
+
write(root, "README.md", "# fixture\n");
|
|
55
|
+
commit(root, "init", "README.md");
|
|
56
|
+
git(root, ["checkout", "-q", "-b", "feat"]);
|
|
57
|
+
write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
|
|
58
|
+
write(root, "src/a.ts", "export const a = 1;\n");
|
|
59
|
+
commit(root, "Add spec and code", SPEC, "src/a.ts");
|
|
60
|
+
if (record !== undefined) write(root, REC, record);
|
|
61
|
+
return root;
|
|
62
|
+
};
|
|
63
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
|
|
64
|
+
process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
|
|
65
|
+
const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
66
|
+
const invoke = (args, env = {}) => {
|
|
67
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, ...IDENTITY, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env } });
|
|
68
|
+
return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
|
|
69
|
+
};
|
|
70
|
+
const run = (root, args = [], env = {}) => invoke(["--worktree", root, "--option", "squash", "--base", "main", ...args], env);
|
|
71
|
+
const head = (root) => out(root, ["rev-parse", "HEAD"]);
|
|
72
|
+
const commitCount = (root) => Number(out(root, ["rev-list", "--count", "HEAD"]));
|
|
73
|
+
const headSubject = (root) => out(root, ["log", "-1", "--format=%s"]);
|
|
74
|
+
const headFiles = (root) => out(root, ["show", "--name-only", "--format=", "HEAD"]).split("\n").filter(Boolean);
|
|
75
|
+
const porcelain = (root) => out(root, ["status", "--porcelain", "--untracked-files=all"]);
|
|
76
|
+
const record = (root) => parseYaml(readFileSync(join(root, REC), "utf8"));
|
|
77
|
+
const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
|
|
78
|
+
|
|
79
|
+
test("seals an untracked in_progress record: stamp, ship event, diff, re-derived duration, one telemetry: commit touching only the record", (t) => {
|
|
80
|
+
const root = repo(); cleanup(t, root);
|
|
81
|
+
const n = commitCount(root);
|
|
82
|
+
const r = run(root, ["--spec", SPEC]);
|
|
83
|
+
assert.equal(r.status, 0, r.stderr);
|
|
84
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
85
|
+
const rec = record(root);
|
|
86
|
+
assert.equal(rec.status, "shipped");
|
|
87
|
+
assert.match(rec.shipped_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
|
88
|
+
assert.equal(rec.derived.gates.ship_option, "squash");
|
|
89
|
+
assert.equal(typeof rec.derived.gates.ship_option, "string");
|
|
90
|
+
assert.equal(rec.events.at(-1).kind, "ship");
|
|
91
|
+
assert.equal(rec.events.at(-1).option, "squash");
|
|
92
|
+
assert.deepEqual(rec.derived.modified_files, ["src/a.ts"]);
|
|
93
|
+
assert.equal(rec.derived.diff.commits, 1, "the seal commit is excluded from the count");
|
|
94
|
+
assert.equal(rec.derived.diff.buckets.code.files, 1);
|
|
95
|
+
// created_at is 2026-09-17; sealing now re-derives duration_s from created_at to shipped_at, far above the recorded 60.
|
|
96
|
+
assert.ok(rec.derived.duration_s > 60, `duration re-derived at seal time, got ${rec.derived.duration_s}`);
|
|
97
|
+
assert.equal(commitCount(root), n + 1);
|
|
98
|
+
assert.equal(headSubject(root), `telemetry: ${SPEC}`);
|
|
99
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
100
|
+
assert.equal(porcelain(root), "");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("--spec accepts an absolute path within the checkout", (t) => {
|
|
104
|
+
const root = repo(); cleanup(t, root);
|
|
105
|
+
const r = run(root, ["--spec", join(realpathSync(root), SPEC)]);
|
|
106
|
+
assert.equal(r.status, 0, r.stderr);
|
|
107
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("--spec absolute through a symlinked prefix still resolves to the repo-relative record", (t) => {
|
|
111
|
+
const root = repo();
|
|
112
|
+
const linkDir = mkdtempSync(join(tmpdir(), "gts-link-"));
|
|
113
|
+
cleanup(t, root, linkDir);
|
|
114
|
+
const link = join(linkDir, "checkout");
|
|
115
|
+
symlinkSync(root, link);
|
|
116
|
+
const r = run(root, ["--spec", join(link, SPEC)]);
|
|
117
|
+
assert.equal(r.status, 0, r.stderr);
|
|
118
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("--spec accepts a dot-relative path", (t) => {
|
|
122
|
+
const root = repo(); cleanup(t, root);
|
|
123
|
+
const r = run(root, ["--spec", `./${SPEC}`]);
|
|
124
|
+
assert.equal(r.status, 0, r.stderr);
|
|
125
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("--option pr is recorded; without --spec the candidates are the existing records of changed specs", (t) => {
|
|
129
|
+
const root = repo(); cleanup(t, root);
|
|
130
|
+
const r = invoke(["--worktree", root, "--option", "pr", "--base", "main"]);
|
|
131
|
+
assert.equal(r.status, 0, r.stderr);
|
|
132
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
133
|
+
assert.equal(record(root).derived.gates.ship_option, "pr");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("already sealed: a shipped, tracked, clean record is left alone", (t) => {
|
|
137
|
+
const root = repo({ record: SEALED }); cleanup(t, root);
|
|
138
|
+
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
139
|
+
const before = head(root);
|
|
140
|
+
const r = run(root, ["--spec", SPEC]);
|
|
141
|
+
assert.equal(r.status, 0, r.stderr);
|
|
142
|
+
assert.deepEqual(r.lines, [`already sealed ${REC}`]);
|
|
143
|
+
assert.equal(head(root), before);
|
|
144
|
+
assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an abandoned record is already sealed even when untracked", (t) => {
|
|
148
|
+
const root = repo({ record: RECORD.replace("status: in_progress", "status: abandoned") }); cleanup(t, root);
|
|
149
|
+
const before = head(root);
|
|
150
|
+
const r = run(root, ["--spec", SPEC]);
|
|
151
|
+
assert.equal(r.status, 0, r.stderr);
|
|
152
|
+
assert.deepEqual(r.lines, [`already sealed ${REC}`]);
|
|
153
|
+
assert.equal(head(root), before);
|
|
154
|
+
assert.equal(record(root).status, "abandoned");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a shipped but untracked record (previous commit failed) is committed as is on retry", (t) => {
|
|
158
|
+
const root = repo({ record: SEALED }); cleanup(t, root);
|
|
159
|
+
const n = commitCount(root);
|
|
160
|
+
const r = run(root, ["--spec", SPEC]);
|
|
161
|
+
assert.equal(r.status, 0, r.stderr);
|
|
162
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
163
|
+
assert.equal(commitCount(root), n + 1);
|
|
164
|
+
assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z", "bytes committed as they were");
|
|
165
|
+
assert.equal(porcelain(root), "");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("--spec with no record -> no record at <path>, exit 2, no commit", (t) => {
|
|
169
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
170
|
+
const n = commitCount(root);
|
|
171
|
+
const r = run(root, ["--spec", SPEC]);
|
|
172
|
+
assert.equal(r.status, 2);
|
|
173
|
+
assert.equal(r.stderr, `no record at ${REC}`);
|
|
174
|
+
assert.equal(commitCount(root), n);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("unparseable record -> parser message, exit 2, file untouched", (t) => {
|
|
178
|
+
const root = repo({ record: "schema: 1\nspec: doc/specs/x.md\n" }); cleanup(t, root);
|
|
179
|
+
const r = run(root, ["--spec", SPEC]);
|
|
180
|
+
assert.equal(r.status, 2);
|
|
181
|
+
assert.match(r.stderr, new RegExp(`^unparseable record ${REC.replace(/\./g, "\\.")}`));
|
|
182
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), "schema: 1\nspec: doc/specs/x.md\n");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("no telemetry run: a changed spec without a record is not a candidate (supersession edit)", (t) => {
|
|
186
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
187
|
+
const r = run(root);
|
|
188
|
+
assert.equal(r.status, 0, r.stderr);
|
|
189
|
+
assert.deepEqual(r.lines, ["no telemetry run"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("telemetry disabled -> exit 0, nothing touched", (t) => {
|
|
193
|
+
const root = repo(); cleanup(t, root);
|
|
194
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
|
|
195
|
+
const r = run(root, ["--spec", SPEC]);
|
|
196
|
+
assert.equal(r.status, 0, r.stderr);
|
|
197
|
+
assert.deepEqual(r.lines, ["telemetry disabled"]);
|
|
198
|
+
assert.equal(record(root).status, "in_progress");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("gitignored telemetry dir is staged with -f", (t) => {
|
|
202
|
+
const root = repo(); cleanup(t, root);
|
|
203
|
+
write(root, ".gitignore", ".pi/gauntlet/telemetry/\n");
|
|
204
|
+
commit(root, "ignore telemetry", ".gitignore");
|
|
205
|
+
const r = run(root, ["--spec", SPEC]);
|
|
206
|
+
assert.equal(r.status, 0, r.stderr);
|
|
207
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("commit failure (rejecting pre-commit hook) restores the pre-seal bytes, unstages, exits 1", (t) => {
|
|
211
|
+
const root = repo(); cleanup(t, root);
|
|
212
|
+
const hook = join(root, ".git/hooks/pre-commit");
|
|
213
|
+
writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
|
|
214
|
+
chmodSync(hook, 0o755);
|
|
215
|
+
const n = commitCount(root);
|
|
216
|
+
const r = run(root, ["--spec", SPEC]);
|
|
217
|
+
assert.equal(r.status, 1);
|
|
218
|
+
assert.match(r.stderr, /rejected/);
|
|
219
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD, "pre-seal bytes restored");
|
|
220
|
+
assert.equal(out(root, ["diff", "--cached", "--name-only"]), "", "nothing left staged");
|
|
221
|
+
assert.equal(commitCount(root), n);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("commit failure restores a previously tracked record and clears the index", (t) => {
|
|
225
|
+
const root = repo(); cleanup(t, root);
|
|
226
|
+
commit(root, "Track ongoing telemetry", REC);
|
|
227
|
+
const hook = join(root, ".git/hooks/pre-commit");
|
|
228
|
+
writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
|
|
229
|
+
chmodSync(hook, 0o755);
|
|
230
|
+
const n = commitCount(root);
|
|
231
|
+
const r = run(root, ["--spec", SPEC]);
|
|
232
|
+
assert.equal(r.status, 1);
|
|
233
|
+
assert.match(r.stderr, /rejected/);
|
|
234
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD);
|
|
235
|
+
assert.equal(out(root, ["diff", "--cached", "--name-only"]), "");
|
|
236
|
+
assert.equal(commitCount(root), n);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("usage and environment errors exit 1", (t) => {
|
|
240
|
+
const root = repo(); cleanup(t, root);
|
|
241
|
+
const noArgs = invoke([]);
|
|
242
|
+
assert.equal(noArgs.status, 1);
|
|
243
|
+
assert.match(noArgs.stderr, /^usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr\|squash> --base <ref> \[--spec <spec path inside the checkout>\] \[--dir <telemetry dir>\]/);
|
|
244
|
+
assert.equal(invoke(["--worktree", root, "--option", "rebase", "--base", "main"]).status, 1);
|
|
245
|
+
assert.equal(invoke(["--worktree", root, "--option", "squash"]).status, 1, "--base is required");
|
|
246
|
+
const noBase = invoke(["--worktree", root, "--option", "squash", "--base", "nope"]);
|
|
247
|
+
assert.equal(noBase.status, 1);
|
|
248
|
+
assert.equal(noBase.stderr, "no base ref nope");
|
|
249
|
+
for (const spec of ["/tmp/elsewhere/doc/specs/x.md", "README.md"]) {
|
|
250
|
+
const invalid = run(root, ["--spec", spec]);
|
|
251
|
+
assert.equal(invalid.status, 1);
|
|
252
|
+
assert.match(invalid.stderr, /^usage:/);
|
|
253
|
+
}
|
|
254
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
|
|
255
|
+
const disabledInvalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
|
|
256
|
+
assert.equal(disabledInvalid.status, 1);
|
|
257
|
+
assert.match(disabledInvalid.stderr, /^usage:/);
|
|
258
|
+
const notGit = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, notGit);
|
|
259
|
+
const ng = invoke(["--worktree", notGit, "--option", "squash", "--base", "main"]);
|
|
260
|
+
assert.equal(ng.status, 1);
|
|
261
|
+
assert.equal(ng.stderr, "not a git checkout");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("--dir overrides the telemetry dir", (t) => {
|
|
265
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
266
|
+
write(root, "tele/doc/specs/x.yaml", RECORD);
|
|
267
|
+
const r = run(root, ["--spec", SPEC, "--dir", "tele"]);
|
|
268
|
+
assert.equal(r.status, 0, r.stderr);
|
|
269
|
+
assert.deepEqual(r.lines, ["sealed tele/doc/specs/x.yaml"]);
|
|
270
|
+
assert.deepEqual(headFiles(root), ["tele/doc/specs/x.yaml"]);
|
|
271
|
+
});
|