pi-gauntlet 5.7.0 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.8.1 - 2026-09-17
4
+
5
+ - chase-bug hotfix: the implementer proves its worktree binding first, addresses every mutating git command with `git -C`, runs with a fresh context, and the parent aborts non-destructively on any primary-checkout drift after each implementer return; `ci.mjs` asserts the guard text ([#36](https://github.com/jjuraszek/pi-gauntlet/issues/36))
6
+ - `gauntlet-resume`: same-repository worktrees resume from a pi session launched in the primary checkout; cross-repository targets still stop, and reconstruction addresses the resolved worktree by path.
7
+
8
+ ## v5.8.0 - 2026-09-17
9
+
10
+ - New extension `telemetry`: records one committed YAML record per gauntlet run at `.pi/gauntlet/telemetry/<spec path>.yaml` (phase timing, model/thinking snapshots, per-persona dispatches and tokens, reviewer findings, gate and fix-round counters, plan totals, last test result, diff buckets and modified files at ship), keyed by spec path and continued across sessions; pathspec-commits the record at checkpoints; reconciles a failed ship command; freezes after squash/PR/discard. During brainstorm a `write` into a spec whose record is shipped is blocked (`edit` passes). Settings `piGauntlet.telemetry.{enabled,dir,buckets}`. (#33)
11
+ - `yaml` is the package's first runtime dependency; CI and local checkouts run `npm install` before the test suite. `verify-before-ship` shares its default test-command list with the resolver module (no behaviour change).
12
+
3
13
  ## v5.7.0 - 2026-09-17
4
14
 
5
15
  - `gauntlet-resume` (new, human-only, `disable-model-invocation: true`): the sole re-entry point into an interrupted gauntlet flow from a fresh session. Input is a pi-cohort `/handoff` brief (file or pasted; grammar in `skills/gauntlet-resume/reference/brief-contract.md`) or a bare worktree that already holds a spec (`reference/reconstruction.md`). Restores `phase_tracker` / `plan_tracker` state via `start brainstorm` + `skip` with `resume:` reasons, re-runs `plan_check` before implement-or-later, stops a ship-stage brief at verify, never creates a worktree, never infers approval from artifacts. Free-form prompts redirect to `/skill:brainstorming`.
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
  - **18 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). Five 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-resume` is the only way back into an interrupted flow from a fresh session: it takes a pi-cohort `/handoff` brief (file or pasted) or a bare worktree that already holds a spec, restores phase/plan tracker state through the legal arming sequence (`start brainstorm`, `skip` with `resume:` reasons, `plan_check` before implement-or-later), never creates a worktree, and never infers approval from artifacts - run it with `/skill:gauntlet-resume [<brief>] [<worktree>]`.
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
- - **3 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. See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
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 committed YAML record per gauntlet run (phase timing, models, personas, gate/fix rounds, diff at ship) 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
 
@@ -120,8 +120,9 @@ For local development against a checkout instead of npm:
120
120
 
121
121
  ```bash
122
122
  git clone git@github.com:jjuraszek/pi-gauntlet.git ~/repos/pi-gauntlet
123
- cd ~/path/to/your/repo && pi install -l ~/repos/pi-gauntlet
124
- cd ~/repos/pi-gauntlet && npm run link-agents # local-path installs skip npm install; run this once
123
+ cd ~/repos/pi-gauntlet && npm install # installs the yaml dependency and links the agents
124
+ cd ~/path/to/your/repo
125
+ pi install -l ~/repos/pi-gauntlet
125
126
  ```
126
127
 
127
128
  ## Use from Claude Code
@@ -8,11 +8,16 @@ import {
8
8
  mainLoopModel,
9
9
  resolveFlowGuards,
10
10
  resolveVerifyBeforeShip,
11
+ resolveTelemetry,
12
+ DEFAULT_TELEMETRY_DIR,
13
+ DEFAULT_TELEMETRY_BUCKETS,
14
+ DEFAULT_TEST_COMMANDS,
15
+ buildTestCmdRegex,
11
16
  settingsErrorWarning,
12
17
  type PiGauntlet,
13
18
  } from "./gauntlet-settings.ts";
14
19
 
15
- const DEFAULT_TEST_COMMANDS = ["make ci", "pytest"];
20
+ const CUSTOM_TEST_COMMANDS = ["make ci", "pytest"];
16
21
 
17
22
  test("mergeGauntlet: repo key replaces preset key whole-object", () => {
18
23
  const preset = { specCouncil: { members: ["a"], chair: "c" }, closureReview: { model: "m" } };
@@ -144,13 +149,83 @@ test("settingsErrorWarning: includes prefix and joined errors", () => {
144
149
  });
145
150
 
146
151
  test("verifyBeforeShip: default vs override", () => {
147
- const d = resolveVerifyBeforeShip({}, DEFAULT_TEST_COMMANDS);
148
- assert.deepEqual(d.testCommands, DEFAULT_TEST_COMMANDS);
152
+ const d = resolveVerifyBeforeShip({}, CUSTOM_TEST_COMMANDS);
153
+ assert.deepEqual(d.testCommands, CUSTOM_TEST_COMMANDS);
149
154
  assert.equal(d.warningReference, undefined);
150
155
  const o = resolveVerifyBeforeShip(
151
156
  { verifyBeforeShip: { testCommands: ["x"], warningReference: "doc/t.md" } },
152
- DEFAULT_TEST_COMMANDS,
157
+ CUSTOM_TEST_COMMANDS,
153
158
  );
154
159
  assert.deepEqual(o.testCommands, ["x"]);
155
160
  assert.equal(o.warningReference, "doc/t.md");
156
161
  });
162
+
163
+ test("telemetry: absent block -> enabled, default dir, default buckets, no warning", () => {
164
+ const r = resolveTelemetry({});
165
+ assert.equal(r.enabled, true);
166
+ assert.equal(r.dir, DEFAULT_TELEMETRY_DIR);
167
+ assert.equal(r.dir, ".pi/gauntlet/telemetry");
168
+ assert.deepEqual(r.buckets, DEFAULT_TELEMETRY_BUCKETS);
169
+ assert.equal(r.warning, undefined);
170
+ });
171
+
172
+ test("telemetry: enabled false is the only way to disable", () => {
173
+ assert.equal(resolveTelemetry({ telemetry: { enabled: false } }).enabled, false);
174
+ assert.equal(resolveTelemetry({ telemetry: { enabled: "no" } }).enabled, true);
175
+ assert.equal(resolveTelemetry({ telemetry: { enabled: 0 } }).enabled, true);
176
+ });
177
+
178
+ test("telemetry: dir accepts a non-empty relative path, otherwise default + warning", () => {
179
+ assert.equal(resolveTelemetry({ telemetry: { dir: "telemetry/" } }).dir, "telemetry");
180
+ assert.equal(resolveTelemetry({ telemetry: { dir: "a/../b" } }).dir, "b");
181
+ for (const bad of [
182
+ "",
183
+ " ",
184
+ 5,
185
+ null,
186
+ ["x"],
187
+ "/abs/dir",
188
+ "C:\\outside",
189
+ "C:/outside",
190
+ "C:outside",
191
+ "C:",
192
+ "\\\\server\\share",
193
+ "../outside",
194
+ "a/../../outside",
195
+ "..",
196
+ ]) {
197
+ const r = resolveTelemetry({ telemetry: { dir: bad } });
198
+ assert.equal(r.dir, DEFAULT_TELEMETRY_DIR);
199
+ assert.match(r.warning ?? "", /telemetry\.dir/);
200
+ }
201
+ });
202
+
203
+ test("telemetry: buckets replaces the default list wholesale, preserving key order", () => {
204
+ const r = resolveTelemetry({ telemetry: { buckets: { spec: ["doc/specs/**"], test: ["**/*.test.ts"] } } });
205
+ assert.deepEqual(r.buckets, [
206
+ ["spec", ["doc/specs/**"]],
207
+ ["test", ["**/*.test.ts"]],
208
+ ]);
209
+ assert.equal(r.warning, undefined);
210
+ });
211
+
212
+ test("telemetry: malformed buckets -> default + warning", () => {
213
+ for (const bad of [[], "x", { test: "**/*.ts" }, { test: [""] }, { test: [1] }, {}]) {
214
+ const r = resolveTelemetry({ telemetry: { buckets: bad } });
215
+ assert.deepEqual(r.buckets, DEFAULT_TELEMETRY_BUCKETS);
216
+ assert.match(r.warning ?? "", /telemetry\.buckets/);
217
+ }
218
+ });
219
+
220
+ test("telemetry: dir and buckets warnings join with '; '", () => {
221
+ const r = resolveTelemetry({ telemetry: { dir: 1, buckets: 2 } });
222
+ assert.match(r.warning ?? "", /telemetry\.dir.*; .*telemetry\.buckets/);
223
+ });
224
+
225
+ test("DEFAULT_TEST_COMMANDS + buildTestCmdRegex match the documented entrypoints", () => {
226
+ const re = buildTestCmdRegex(DEFAULT_TEST_COMMANDS);
227
+ for (const cmd of ["make ci", "make test", "npm test", "npm run test", "pnpm test", "yarn test", "pytest -q", "rspec", "cargo test", "go test ./..."])
228
+ assert.ok(re.test(cmd), cmd);
229
+ assert.equal(re.test("make test-smoke"), false);
230
+ assert.equal(re.test("ls"), false);
231
+ });
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+
1
3
  // Pure gauntlet-settings resolvers. NO pi runtime import: this module is imported
2
4
  // by ci.mjs unit tests (node --test) which run outside pi, where
3
5
  // @earendil-works/pi-coding-agent is unresolvable. The loader
@@ -9,6 +11,7 @@ export interface PiGauntlet {
9
11
  flowGuards?: { enforce?: unknown; specDirs?: unknown };
10
12
  verifyBeforeShip?: { testCommands?: unknown; warningReference?: unknown };
11
13
  escalationLoop?: { implModel?: unknown };
14
+ telemetry?: { enabled?: unknown; dir?: unknown; buckets?: unknown };
12
15
  }
13
16
 
14
17
  // Whole-object second-level merge: each piGauntlet key present in the repo layer
@@ -143,6 +146,72 @@ export function resolveVerifyBeforeShip(g: PiGauntlet, defaultTestCommands: stri
143
146
  return { testCommands, warningReference };
144
147
  }
145
148
 
149
+ // Default verification entrypoints shared by verify-before-ship (advisory) and
150
+ // telemetry (derived.tests). Regex fragments; buildTestCmdRegex anchors them with \b.
151
+ export const DEFAULT_TEST_COMMANDS = [
152
+ "make\\s+(?:ci|test)(?![-\\w])", // rejects make test-smoke and make test-corpus
153
+ "npm\\s+(?:test|run\\s+test)",
154
+ "pnpm\\s+test",
155
+ "yarn\\s+test",
156
+ "pytest",
157
+ "rspec",
158
+ "cargo\\s+test",
159
+ "go\\s+test",
160
+ ];
161
+
162
+ export const buildTestCmdRegex = (commands: string[]): RegExp => new RegExp(`\\b(${commands.join("|")})\\b`);
163
+
164
+ export const DEFAULT_TELEMETRY_DIR = ".pi/gauntlet/telemetry";
165
+
166
+ // Ordered: first matching bucket wins; anything unmatched is "code".
167
+ export const DEFAULT_TELEMETRY_BUCKETS: [string, string[]][] = [
168
+ ["test", ["**/test/**", "**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/*_test.*"]],
169
+ ["docs", ["**/*.md"]],
170
+ ["config", ["**/*.json", "**/*.yaml", "**/*.yml", "**/*.toml", "**/*.lock", "**/*-lock.*"]],
171
+ ];
172
+
173
+ export interface TelemetryResolved {
174
+ enabled: boolean;
175
+ dir: string;
176
+ buckets: [string, string[]][];
177
+ warning: string | undefined;
178
+ }
179
+
180
+ export function resolveTelemetry(g: PiGauntlet): TelemetryResolved {
181
+ const t = g.telemetry;
182
+ const warnings: string[] = [];
183
+ const enabled = t?.enabled !== false;
184
+
185
+ let dir = DEFAULT_TELEMETRY_DIR;
186
+ if (t?.dir !== undefined) {
187
+ const value = nonEmptyString(t.dir) ? t.dir.trim().replace(/\/+$/, "") : "";
188
+ const canonical = value.replace(/\\/g, "/");
189
+ const normalized = path.posix.normalize(canonical);
190
+ if (
191
+ value &&
192
+ path.win32.parse(canonical).root === "" &&
193
+ normalized !== ".." &&
194
+ !normalized.startsWith("../")
195
+ ) dir = normalized;
196
+ else warnings.push("telemetry.dir must be a non-empty path relative to the git toplevel; using the default");
197
+ }
198
+
199
+ let buckets = DEFAULT_TELEMETRY_BUCKETS;
200
+ if (t?.buckets !== undefined) {
201
+ const b = t.buckets;
202
+ const valid =
203
+ b !== null &&
204
+ typeof b === "object" &&
205
+ !Array.isArray(b) &&
206
+ Object.keys(b).length > 0 &&
207
+ Object.values(b).every((v) => Array.isArray(v) && v.length > 0 && v.every(nonEmptyString));
208
+ if (valid) buckets = Object.entries(b as Record<string, string[]>).map(([name, globs]) => [name, [...globs]]);
209
+ else warnings.push("telemetry.buckets is not an object of non-empty glob arrays; using the defaults");
210
+ }
211
+
212
+ return { enabled, dir, buckets, warning: joinWarn(warnings) };
213
+ }
214
+
146
215
  export function settingsErrorWarning(errors: string[]): string {
147
216
  return `\u26a0\ufe0f gauntlet settings load error (using defaults): ${errors.join("; ")}`;
148
217
  }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./telemetry-collect.ts";
4
+
5
+ const usage = (input: number, output = 0, cost = 0) => ({ input, output, cacheRead: 0, cacheWrite: 0, totalTokens: input + output, cost: { input: cost, output: 0, cacheRead: 0, cacheWrite: 0, total: cost } });
6
+
7
+ test("usageToTokens maps pi Usage to snake_case tokens; all-zero -> undefined", () => {
8
+ assert.deepEqual(usageToTokens(usage(10, 5, 0.25)), { input: 10, output: 5, cache_read: 0, cache_write: 0, cost: 0.25 });
9
+ assert.equal(usageToTokens(usage(0)), undefined);
10
+ assert.equal(usageToTokens(undefined), undefined);
11
+ assert.deepEqual(usageToTokens({ input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: 0.5 }), { input: 1, output: 0, cache_read: 0, cache_write: 0, cost: 0.5 });
12
+ });
13
+
14
+ test("countFindings tallies [blocker]/[major]/[minor] tags; none -> undefined", () => {
15
+ assert.deepEqual(countFindings("- [major] x\n- [minor] y\n- [MINOR] z\n[blocker] w"), { blocker: 1, major: 1, minor: 2 });
16
+ assert.equal(countFindings("all good"), undefined);
17
+ });
18
+
19
+ test("countOpenGaps returns the latest conformance result's open gap count", () => {
20
+ assert.equal(countOpenGaps("Conformance verdict: CONFORMS"), 0);
21
+ assert.equal(countOpenGaps("Conformance verdict: GAPS\nG1:\n verdict: MISSING\nG2:\n verdict: PARTIAL\nG1:\n evidence: repeated"), 2);
22
+ assert.equal(countOpenGaps("unrelated reviewer text"), undefined);
23
+ });
24
+
25
+ test("planTotals counts tasks by terminal status", () => {
26
+ assert.deepEqual(planTotals([{ status: "complete" }, { status: "skipped" }, { status: "failed" }, { status: "complete" }]), { tasks: 4, complete: 2, failed: 1, skipped: 1 });
27
+ });
28
+
29
+ test("hasReopen detects complete -> in_progress at the same index only", () => {
30
+ assert.equal(hasReopen([{ status: "complete" }, { status: "pending" }], [{ status: "in_progress" }, { status: "pending" }]), true);
31
+ assert.equal(hasReopen([{ status: "complete" }], [{ status: "complete" }]), false);
32
+ assert.equal(hasReopen(undefined, [{ status: "in_progress" }]), false);
33
+ assert.equal(hasReopen([{ status: "pending" }], [{ status: "in_progress" }]), false);
34
+ });
35
+
36
+ test("insertedText joins edit newText values or returns write content", () => {
37
+ assert.equal(insertedText({ path: "x", edits: [{ oldText: "a", newText: "b" }, { oldText: "c", newText: "d" }] }), "b\nd");
38
+ assert.equal(insertedText({ path: "x", content: "body" }), "body");
39
+ assert.equal(insertedText({ path: "x" }), "");
40
+ });
41
+
42
+ test("textOf concatenates text content blocks", () => {
43
+ assert.equal(textOf([{ type: "text", text: "a" }, { type: "image" }, { type: "text", text: "b" }]), "a\n\nb");
44
+ assert.equal(textOf("nope"), "");
45
+ });
46
+
47
+ test("constant sets", () => {
48
+ assert.deepEqual([...REVIEWER_AGENTS], ["spec-reviewer", "code-reviewer", "conformance-reviewer"]);
49
+ assert.deepEqual([...COUNTED_USER_PHASES], ["plan", "implement", "verify", "ship"]);
50
+ });
@@ -0,0 +1,59 @@
1
+ // Pure reducers the telemetry collector applies to pi tool results (#33).
2
+ import type { PhaseKey, Tokens } from "./telemetry-record.ts";
3
+
4
+ // pi Usage -> snake_case tokens; cost is usage.cost.total (object) or a bare number.
5
+ export function usageToTokens(u: unknown): Tokens | undefined {
6
+ const x = u as { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown; cost?: unknown } | undefined;
7
+ if (!x || typeof x !== "object") return undefined;
8
+ const n = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
9
+ const cost = typeof x.cost === "number" ? x.cost : n((x.cost as { total?: unknown } | undefined)?.total);
10
+ const t: Tokens = { input: n(x.input), output: n(x.output), cache_read: n(x.cacheRead), cache_write: n(x.cacheWrite), cost };
11
+ return t.input || t.output || t.cache_read || t.cache_write || t.cost ? t : undefined;
12
+ }
13
+
14
+ const FINDING_TAG_RE = /\[(blocker|major|minor)\]/gi;
15
+ export function countFindings(text: string): { blocker: number; major: number; minor: number } | undefined {
16
+ const out = { blocker: 0, major: 0, minor: 0 };
17
+ let any = false;
18
+ for (const m of text.matchAll(FINDING_TAG_RE)) {
19
+ out[m[1].toLowerCase() as keyof typeof out] += 1;
20
+ any = true;
21
+ }
22
+ return any ? out : undefined;
23
+ }
24
+
25
+ export function countOpenGaps(text: string): number | undefined {
26
+ if (/Conformance verdict:\s*CONFORMS/.test(text)) return 0;
27
+ const gaps = new Set([...text.matchAll(/^\s*G(\d+):/gm)].map((match) => match[1]));
28
+ return gaps.size || undefined;
29
+ }
30
+
31
+ export const REVIEWER_AGENTS = new Set(["spec-reviewer", "code-reviewer", "conformance-reviewer"]);
32
+ export const COUNTED_USER_PHASES = new Set<PhaseKey>(["plan", "implement", "verify", "ship"]);
33
+
34
+ export const textOf = (content: unknown): string =>
35
+ Array.isArray(content) ? content.map((c) => (c && typeof c === "object" && (c as { type?: string }).type === "text" ? String((c as { text?: unknown }).text ?? "") : "")).join("\n") : "";
36
+
37
+ export interface PlanTotals {
38
+ tasks: number;
39
+ complete: number;
40
+ failed: number;
41
+ skipped: number;
42
+ }
43
+ export const planTotals = (tasks: { status: string }[]): PlanTotals => ({
44
+ tasks: tasks.length,
45
+ complete: tasks.filter((t) => t.status === "complete").length,
46
+ failed: tasks.filter((t) => t.status === "failed").length,
47
+ skipped: tasks.filter((t) => t.status === "skipped").length,
48
+ });
49
+
50
+ // A plan_tracker update that moves a task from complete back to in_progress.
51
+ export const hasReopen = (prev: { status: string }[] | undefined, next: { status: string }[]): boolean =>
52
+ !!prev && next.some((t, i) => prev[i]?.status === "complete" && t.status === "in_progress");
53
+
54
+ // The text an edit/write call inserts: joined newText values, or the write body.
55
+ export const insertedText = (input: unknown): string => {
56
+ const i = input as { edits?: { newText?: unknown }[]; content?: unknown };
57
+ if (Array.isArray(i.edits)) return i.edits.map((e) => String(e?.newText ?? "")).join("\n");
58
+ return typeof i.content === "string" ? i.content : "";
59
+ };
@@ -0,0 +1,98 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ aggregateNumstat,
5
+ classifyBucket,
6
+ isPlanPath,
7
+ isSpecPath,
8
+ isSupersededByBanner,
9
+ matchDiscardStatement,
10
+ matchShipStatement,
11
+ matchTestStatement,
12
+ parseSpecLinks,
13
+ planSpecHeader,
14
+ recordPathFor,
15
+ repoRelativeToolPath,
16
+ truncateCommand,
17
+ } from "./telemetry-paths.ts";
18
+ import { DEFAULT_TELEMETRY_BUCKETS, DEFAULT_TEST_COMMANDS } from "./gauntlet-settings.ts";
19
+
20
+ test("recordPathFor maps <dir>/<spec .md -> .yaml> preserving nesting", () => {
21
+ assert.equal(recordPathFor(".pi/gauntlet/telemetry", "doc/specs/x.md"), ".pi/gauntlet/telemetry/doc/specs/x.yaml");
22
+ assert.equal(recordPathFor("t", "svc/doc/specs/2026-01-01-a.md"), "t/svc/doc/specs/2026-01-01-a.yaml");
23
+ });
24
+
25
+ test("repoRelativeToolPath resolves like pi tools: relative to cwd, then repo-relative", () => {
26
+ assert.equal(repoRelativeToolPath("/repo", "/repo", "doc/specs/a.md"), "doc/specs/a.md");
27
+ assert.equal(repoRelativeToolPath("/repo", "/repo/svc", "doc/specs/a.md"), "svc/doc/specs/a.md");
28
+ assert.equal(repoRelativeToolPath("/repo", "/repo/svc", "/repo/doc/specs/a.md"), "doc/specs/a.md");
29
+ assert.equal(repoRelativeToolPath("/repo", "/repo", "/elsewhere/x.md"), undefined);
30
+ });
31
+
32
+ test("isSpecPath / isPlanPath match **/doc/specs/*.md and **/doc/plans/*.md only", () => {
33
+ assert.ok(isSpecPath("doc/specs/a.md"));
34
+ assert.ok(isSpecPath("svc/doc/specs/a.md"));
35
+ assert.equal(isSpecPath("doc/specs/sub/a.md"), false);
36
+ assert.equal(isSpecPath("doc/plans/a.md"), false);
37
+ assert.ok(isPlanPath("doc/plans/a.md"));
38
+ });
39
+
40
+ test("planSpecHeader extracts the **Spec:** path", () => {
41
+ assert.equal(planSpecHeader("# P\n\n**Spec:** `doc/specs/a.md`\n"), "doc/specs/a.md");
42
+ assert.equal(planSpecHeader("**Spec:** doc/specs/a.md"), "doc/specs/a.md");
43
+ assert.equal(planSpecHeader("no header"), undefined);
44
+ });
45
+
46
+ test("matchShipStatement needs a statement start (STMT_START)", () => {
47
+ assert.deepEqual(matchShipStatement("git merge --squash gh-33 && git commit"), { option: "squash", statement: "git merge --squash gh-33" });
48
+ assert.deepEqual(matchShipStatement("cd x; git push -u origin HEAD"), { option: "pr", statement: "git push -u origin HEAD" });
49
+ assert.equal(matchShipStatement('gh pr create --fill')?.option, "pr");
50
+ assert.equal(matchShipStatement('rg "git push" skills/'), undefined);
51
+ assert.equal(matchShipStatement("echo git push"), undefined);
52
+ });
53
+
54
+ test("matchDiscardStatement matches worktree remove and branch -D", () => {
55
+ assert.equal(matchDiscardStatement("git worktree remove .worktrees/x"), "git worktree remove .worktrees/x");
56
+ assert.equal(matchDiscardStatement("cd .. && git branch -D gh-33"), "git branch -D gh-33");
57
+ assert.equal(matchDiscardStatement("git branch -d gh-33"), undefined);
58
+ });
59
+
60
+ test("matchTestStatement returns the first statement matching a test fragment", () => {
61
+ assert.equal(matchTestStatement("cd repo && npm test -- --grep x", DEFAULT_TEST_COMMANDS), "npm test -- --grep x");
62
+ assert.equal(matchTestStatement("make test-smoke", DEFAULT_TEST_COMMANDS), undefined);
63
+ assert.equal(matchTestStatement("ls", DEFAULT_TEST_COMMANDS), undefined);
64
+ });
65
+
66
+ test("truncateCommand cuts at 120 chars", () => {
67
+ assert.equal(truncateCommand("a".repeat(200)).length, 120);
68
+ assert.equal(truncateCommand("short"), "short");
69
+ });
70
+
71
+ test("classifyBucket: first match wins, else code", () => {
72
+ assert.equal(classifyBucket("extensions/lib/telemetry-paths.test.ts", DEFAULT_TELEMETRY_BUCKETS), "test");
73
+ assert.equal(classifyBucket("doc/configuration.md", DEFAULT_TELEMETRY_BUCKETS), "docs");
74
+ assert.equal(classifyBucket("package.json", DEFAULT_TELEMETRY_BUCKETS), "config");
75
+ assert.equal(classifyBucket("extensions/telemetry.ts", DEFAULT_TELEMETRY_BUCKETS), "code");
76
+ assert.equal(classifyBucket("doc/specs/a.md", [["spec", ["doc/specs/**"]], ["docs", ["**/*.md"]]]), "spec");
77
+ });
78
+
79
+ test("aggregateNumstat sums per bucket over the given file set; binary rows count 0 lines", () => {
80
+ const numstat = ["10\t2\textensions/telemetry.ts", "5\t0\textensions/lib/telemetry-paths.test.ts", "-\t-\timg.png", "3\t3\tdoc/specs/a.md", "1\t1\t{old => new}/x.ts"].join("\n");
81
+ const files = new Set(["extensions/telemetry.ts", "extensions/lib/telemetry-paths.test.ts", "img.png", "new/x.ts"]);
82
+ assert.deepEqual(aggregateNumstat(numstat, files, DEFAULT_TELEMETRY_BUCKETS), {
83
+ code: { files: 3, insertions: 11, deletions: 3 },
84
+ test: { files: 1, insertions: 5, deletions: 0 },
85
+ });
86
+ });
87
+
88
+ test("parseSpecLinks reads Supersedes/Fixes banners as paths or markdown links", () => {
89
+ const body = "# T\n\n> **Supersedes:** [doc/specs/a.md](./a.md), doc/specs/b.md\n> **Fixes:** [doc/specs/c.md](./c.md)\n";
90
+ assert.deepEqual(parseSpecLinks(body), { supersedes: ["doc/specs/a.md", "doc/specs/b.md"], fixes: ["doc/specs/c.md"] });
91
+ assert.deepEqual(parseSpecLinks("# T\n"), { supersedes: [], fixes: [] });
92
+ });
93
+
94
+ test("isSupersededByBanner detects the predecessor banner and its successor label", () => {
95
+ assert.equal(isSupersededByBanner("> **Superseded by:** [doc/specs/new.md](./new.md) - fully", "doc/specs/new.md"), true);
96
+ assert.equal(isSupersededByBanner("> **Superseded by:** [doc/specs/other.md](./other.md)", "doc/specs/new.md"), false);
97
+ assert.equal(isSupersededByBanner("plain text", "doc/specs/new.md"), false);
98
+ });
@@ -0,0 +1,145 @@
1
+ // Pure helpers for the telemetry extension (#33): record path mapping, pi-cwd
2
+ // path resolution, ship/discard/test command matchers, diff buckets, spec banners.
3
+ //
4
+ // Ship detection runs on a `bash` `tool_call` while `ship` is in progress and matches
5
+ // the first statement against
6
+ // STMT_START + (git\s+merge\s+--squash|git\s+push|gh\s+pr\s+create)
7
+ // and discard against
8
+ // STMT_START + git\s+(worktree\s+remove|branch\s+-D)
9
+ // with STMT_START from phase-tracker-helpers.ts:39.
10
+ // Default bucket globs (DEFAULT_TELEMETRY_BUCKETS): test = **/test/**, **/tests/**,
11
+ // **/__tests__/**, **/*.test.*, **/*.spec.*, **/*_test.*; docs = **/*.md;
12
+ // config = **/*.json, **/*.yaml, **/*.yml, **/*.toml, **/*.lock, **/*-lock.*; else code.
13
+
14
+ import { isAbsolute, matchesGlob, relative, resolve } from "node:path";
15
+ import { STMT_START } from "./phase-tracker-helpers.ts";
16
+ import { buildTestCmdRegex } from "./gauntlet-settings.ts";
17
+
18
+ // ---- paths -------------------------------------------------------------------
19
+
20
+ export const toPosix = (p: string): string => p.split("\\").join("/");
21
+
22
+ export const isSpecPath = (rel: string): boolean => /(^|\/)doc\/specs\/[^/]+\.md$/.test(rel);
23
+ export const isPlanPath = (rel: string): boolean => /(^|\/)doc\/plans\/[^/]+\.md$/.test(rel);
24
+
25
+ // <dir>/<spec path with trailing .md replaced by .yaml>, nesting preserved.
26
+ export const recordPathFor = (dir: string, specRel: string): string => `${dir}/${specRel.replace(/\.md$/, ".yaml")}`;
27
+
28
+ // pi tools resolve a relative path against ctx.cwd; the record key is repo-relative
29
+ // to the owning toplevel so the same spec maps to one record from any cwd.
30
+ // Returns undefined for paths outside the toplevel.
31
+ export function repoRelativeToolPath(toplevel: string, cwd: string, p: string): string | undefined {
32
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
33
+ const rel = toPosix(relative(toplevel, abs));
34
+ if (rel === "" || rel.startsWith("../") || rel === ".." || isAbsolute(rel)) return undefined;
35
+ return rel;
36
+ }
37
+
38
+ const SPEC_HEADER_RE = /^\*\*Spec:\*\*\s*`?([^`\s]+)`?/m;
39
+ export const planSpecHeader = (planBody: string): string | undefined => SPEC_HEADER_RE.exec(planBody)?.[1];
40
+
41
+ // ---- command matchers ----------------------------------------------------------
42
+
43
+ const SHIP_RE = new RegExp(STMT_START + "(git\\s+merge\\s+--squash|git\\s+push|gh\\s+pr\\s+create)");
44
+ const DISCARD_RE = new RegExp(STMT_START + "(git\\s+(?:worktree\\s+remove|branch\\s+-D))");
45
+ const STATEMENT_END = /\n|;|&&|\|\||\|/;
46
+
47
+ export const truncateCommand = (s: string): string => (s.length > 120 ? s.slice(0, 120) : s);
48
+
49
+ // The statement that begins at the regex's group-1 match, up to the next separator.
50
+ function statementAt(command: string, re: RegExp): string | undefined {
51
+ const m = re.exec(command);
52
+ if (!m) return undefined;
53
+ const start = m.index + m[0].indexOf(m[1]);
54
+ return command.slice(start).split(STATEMENT_END)[0].trim();
55
+ }
56
+
57
+ export type ShipOption = "squash" | "pr";
58
+
59
+ export function matchShipStatement(command: string): { option: ShipOption; statement: string } | undefined {
60
+ const statement = statementAt(command, SHIP_RE);
61
+ if (!statement) return undefined;
62
+ return { option: /^git\s+merge\s+--squash/.test(statement) ? "squash" : "pr", statement };
63
+ }
64
+
65
+ export const matchDiscardStatement = (command: string): string | undefined => statementAt(command, DISCARD_RE);
66
+
67
+ // First statement matching one of the resolved verifyBeforeShip.testCommands
68
+ // fragments, using the same \b-anchored construction as verify-before-ship.ts.
69
+ export function matchTestStatement(command: string, testCommands: string[]): string | undefined {
70
+ const re = buildTestCmdRegex(testCommands);
71
+ for (const raw of command.split(STATEMENT_END)) {
72
+ const statement = raw.trim();
73
+ if (statement && re.test(statement)) return statement;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ // ---- diff buckets --------------------------------------------------------------
79
+
80
+ export function classifyBucket(rel: string, buckets: [string, string[]][]): string {
81
+ for (const [name, globs] of buckets) if (globs.some((g) => matchesGlob(rel, g))) return name;
82
+ return "code";
83
+ }
84
+
85
+ export interface BucketStat {
86
+ files: number;
87
+ insertions: number;
88
+ deletions: number;
89
+ }
90
+
91
+ // `git diff --numstat` rows: "<ins>\t<del>\t<path>"; binary rows use "-"; renames
92
+ // render as "a => b" or "{a => b}/rest" and count under the new path.
93
+ export function numstatPath(raw: string): string {
94
+ const braced = raw.replace(/\{([^{}]*) => ([^{}]*)\}/g, (_m, _a, b: string) => b).replace(/\/\//g, "/");
95
+ const arrow = braced.indexOf(" => ");
96
+ return arrow >= 0 ? braced.slice(arrow + 4) : braced;
97
+ }
98
+
99
+ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: [string, string[]][]): Record<string, BucketStat> {
100
+ const out: Record<string, BucketStat> = {};
101
+ for (const line of numstat.split("\n")) {
102
+ if (!line.trim()) continue;
103
+ const [ins, del, ...rest] = line.split("\t");
104
+ const path = numstatPath(rest.join("\t"));
105
+ if (!files.has(path)) continue;
106
+ const bucket = classifyBucket(path, buckets);
107
+ const stat = (out[bucket] ??= { files: 0, insertions: 0, deletions: 0 });
108
+ stat.files += 1;
109
+ stat.insertions += ins === "-" ? 0 : Number(ins) || 0;
110
+ stat.deletions += del === "-" ? 0 : Number(del) || 0;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // ---- spec banners --------------------------------------------------------------
116
+
117
+ const LINK_BANNER_RE = /^> \*\*(Supersedes|Fixes):\*\*\s*(.+)$/gm;
118
+ const MD_LINK_RE = /\[([^\]]+)\]\([^)]*\)/g;
119
+
120
+ function bannerTargets(rest: string): string[] {
121
+ const labels: string[] = [];
122
+ const remainder = rest.replace(MD_LINK_RE, (_m, label: string) => {
123
+ labels.push(label.trim());
124
+ return " ";
125
+ });
126
+ for (const tok of remainder.split(/[\s,]+/)) if (tok && tok !== "-") labels.push(tok);
127
+ return labels;
128
+ }
129
+
130
+ export function parseSpecLinks(body: string): { supersedes: string[]; fixes: string[] } {
131
+ const out = { supersedes: [] as string[], fixes: [] as string[] };
132
+ for (const m of body.matchAll(LINK_BANNER_RE)) {
133
+ const list = m[1] === "Supersedes" ? out.supersedes : out.fixes;
134
+ for (const t of bannerTargets(m[2])) if (!list.includes(t)) list.push(t);
135
+ }
136
+ return out;
137
+ }
138
+
139
+ export const SUPERSEDED_BY_RE = /^> \*\*Superseded by:\*\*/m;
140
+
141
+ // True when the inserted text is a predecessor banner naming `successor` as its label.
142
+ export function isSupersededByBanner(inserted: string, successor: string): boolean {
143
+ const m = /^> \*\*Superseded by:\*\*\s*\[([^\]]+)\]/m.exec(inserted);
144
+ return m?.[1]?.trim() === successor;
145
+ }