pi-gauntlet 5.10.2 → 5.12.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.
@@ -1,17 +1,23 @@
1
1
  // One checkout resolver for every extension that needs "which git checkout owns this
2
2
  // path" (#37): plan_check, the settings loader, Guard 2, telemetry. `--path-format=absolute`
3
3
  // (git >= 2.31) makes git-dir and common-dir comparable as strings from any subdirectory.
4
+ // A plain (non-colocated) jj checkout has no .git for rev-parse, so when git fails the
5
+ // resolver falls back to `jj root`; colocated jj repos still resolve through git first.
6
+ // checkoutOfSync is the same resolution for sync callers (the settings loader).
4
7
  import { execFileSync } from "node:child_process";
5
8
  import { existsSync, statSync } from "node:fs";
6
- import { dirname } from "node:path";
9
+ import { dirname, join } from "node:path";
7
10
 
8
11
  export type GitResult = { code: number; stdout: string };
9
- export type Checkout = { toplevel: string; isPrimary: boolean };
12
+ // `via` records which binary resolved the checkout, so callers with git-only follow-ups
13
+ // (telemetry's checkpoint commit) can skip them in a plain jj workspace.
14
+ export type Checkout = { toplevel: string; isPrimary: boolean; via: "git" | "jj" };
15
+ type ResolvedCheckout = { toplevel: string; isPrimary: boolean };
10
16
 
11
17
  export const CHECKOUT_ARGS = ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"];
12
18
 
13
19
  // isPrimary = gitDir === commonDir (both absolute thanks to --path-format).
14
- export function parseCheckout(r: GitResult): Checkout | undefined {
20
+ export function parseCheckout(r: GitResult): ResolvedCheckout | undefined {
15
21
  if (r.code !== 0) return undefined;
16
22
  const lines = r.stdout.trim().split("\n").map((l) => l.trim());
17
23
  if (lines.length < 3 || !lines[0]) return undefined;
@@ -38,20 +44,54 @@ export function nearestExistingDir(absPath: string): string {
38
44
  return p;
39
45
  }
40
46
 
47
+ export const JJ_ROOT_ARGS = ["root"];
48
+
49
+ // Added jj workspaces keep .jj/repo as a pointer file into the main repo; the primary
50
+ // checkout keeps it as the real directory.
51
+ function parseJjRoot(r: GitResult): ResolvedCheckout | undefined {
52
+ if (r.code !== 0) return undefined;
53
+ const toplevel = r.stdout.trim();
54
+ if (!toplevel) return undefined;
55
+ return { toplevel, isPrimary: isDir(join(toplevel, ".jj", "repo")) };
56
+ }
57
+
41
58
  export async function checkoutOf(
42
59
  absPath: string,
43
60
  git: (args: string[], cwd: string) => GitResult | Promise<GitResult>,
61
+ jj: (args: string[], cwd: string) => GitResult | Promise<GitResult> = jjSync,
44
62
  ): Promise<Checkout | undefined> {
45
- return parseCheckout(await git(CHECKOUT_ARGS, nearestExistingDir(absPath)));
63
+ const cwd = nearestExistingDir(absPath);
64
+ const g = parseCheckout(await git(CHECKOUT_ARGS, cwd));
65
+ if (g) return { ...g, via: "git" };
66
+ const j = parseJjRoot(await jj(JJ_ROOT_ARGS, cwd));
67
+ return j ? { ...j, via: "jj" } : undefined;
46
68
  }
47
69
 
48
- // Never throws: any failure (not a repo, missing dir, old git) is a nonzero code.
49
- export const gitSync = (args: string[], cwd: string): GitResult => {
50
- try {
51
- const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
52
- return { code: 0, stdout };
53
- } catch (e) {
54
- const status = (e as { status?: unknown }).status;
55
- return { code: typeof status === "number" ? status : 1, stdout: "" };
56
- }
57
- };
70
+ // Same git-then-jj resolution for sync callers; either side injectable for tests.
71
+ export function checkoutOfSync(
72
+ absPath: string,
73
+ git: (args: string[], cwd: string) => GitResult = gitSync,
74
+ jj: (args: string[], cwd: string) => GitResult = jjSync,
75
+ ): Checkout | undefined {
76
+ const cwd = nearestExistingDir(absPath);
77
+ const g = parseCheckout(git(CHECKOUT_ARGS, cwd));
78
+ if (g) return { ...g, via: "git" };
79
+ const j = parseJjRoot(jj(JJ_ROOT_ARGS, cwd));
80
+ return j ? { ...j, via: "jj" } : undefined;
81
+ }
82
+
83
+ // Never throws: any failure (not a repo, missing dir, missing binary) is a nonzero code.
84
+ const commandSync =
85
+ (bin: string) =>
86
+ (args: string[], cwd: string): GitResult => {
87
+ try {
88
+ const stdout = execFileSync(bin, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 });
89
+ return { code: 0, stdout };
90
+ } catch (e) {
91
+ const status = (e as { status?: unknown }).status;
92
+ return { code: typeof status === "number" ? status : 1, stdout: "" };
93
+ }
94
+ };
95
+
96
+ export const gitSync = commandSync("git");
97
+ export const jjSync = commandSync("jj");
@@ -30,3 +30,16 @@ test("outside any checkout the loader falls back to cwd", () => {
30
30
  assert.equal(loaded.root, dir);
31
31
  assert.deepEqual(loaded.gauntlet.flowGuards, { enforce: false });
32
32
  });
33
+
34
+ test("settings resolve through the jj fallback in a plain jj workspace (no .git)", () => {
35
+ const dir = tmp();
36
+ mkdirSync(join(dir, ".jj", "repo"), { recursive: true }); // primary jj checkout marker
37
+ mkdirSync(join(dir, ".pi"));
38
+ mkdirSync(join(dir, "doc"));
39
+ writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ piGauntlet: { specCouncil: { members: ["p/jj"] } } }));
40
+ const jjCheckout = () => ({ toplevel: dir, isPrimary: true, via: "jj" as const });
41
+ const loaded = loadGauntletSettings(join(dir, "doc"), "/tmp/pi-gauntlet-test-agent", jjCheckout);
42
+ assert.equal(loaded.root, dir);
43
+ assert.deepEqual(loaded.gauntlet.specCouncil, { members: ["p/jj"] });
44
+ assert.deepEqual(loaded.errors, []);
45
+ });
@@ -2,7 +2,7 @@
2
2
  // ci.mjs unit tests. Only pi-loaded extensions import this file.
3
3
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { mergeGauntlet, type PiGauntlet } from "./gauntlet-settings.ts";
5
- import { gitSync, parseCheckout } from "./checkout.ts";
5
+ import { checkoutOfSync, type Checkout } from "./checkout.ts";
6
6
 
7
7
  export interface LoadedGauntlet {
8
8
  gauntlet: PiGauntlet;
@@ -12,12 +12,13 @@ export interface LoadedGauntlet {
12
12
  }
13
13
 
14
14
  // Reads the preset (agentDir/settings.json) and repo (<root>/.pi/settings.json) layers
15
- // via pi's own SettingsManager, where <root> is the git toplevel of cwd - pi launched in a
16
- // subdirectory or a linked worktree still finds that checkout's file (#37). SettingsManager
15
+ // via pi's own SettingsManager, where <root> is the checkout toplevel of cwd (git, falling
16
+ // back to `jj root` in a plain jj workspace) - pi launched in a subdirectory or a linked
17
+ // worktree still finds that checkout's file (#37). SettingsManager
17
18
  // never throws on a bad file - it substitutes {} for that layer and records the error,
18
19
  // surfaced here via errors[] so callers can report a degraded read instead of failing silent.
19
- export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir()): LoadedGauntlet {
20
- const root = parseCheckout(gitSync(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"], cwd))?.toplevel ?? cwd;
20
+ export function loadGauntletSettings(cwd: string, agentDir: string = getAgentDir(), checkout: (path: string) => Checkout | undefined = checkoutOfSync): LoadedGauntlet {
21
+ const root = checkout(cwd)?.toplevel ?? cwd;
21
22
  const sm = SettingsManager.create(root, agentDir);
22
23
  const preset = sm.getGlobalSettings() as { piGauntlet?: Record<string, unknown> };
23
24
  const repo = sm.getProjectSettings() as { piGauntlet?: Record<string, unknown> };
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Handler = (event: any, ctx: any) => unknown;
22
22
 
23
- function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
23
+ function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
@@ -52,6 +52,8 @@
52
52
  return { code: 0, stdout: "", stderr: "" };
53
53
  },
54
54
  settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
55
+ // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
56
+ ...(o.jjWorkspace ? { jj: async () => ({ code: 0, stdout: root + "\n", stderr: "" }) } : {}),
55
57
  };
56
58
  let branch = o.branch ?? [];
57
59
  const ctx = {
@@ -281,6 +283,25 @@
281
283
  assert.deepEqual(nogit.readRecord().events.filter((e) => e.kind === "warning").map((e: any) => e.message), [`spec ${outsideSpec} is outside a git checkout; telemetry not recorded`]);
282
284
  });
283
285
 
286
+ test("jj-bound checkout: record is written, never committed, and the warning fires once", async () => {
287
+ const h = harness({
288
+ jjWorkspace: true,
289
+ gitFail: (args) =>
290
+ args[0] === "rev-parse" && args.includes("--path-format=absolute")
291
+ ? { code: 128, stdout: "", stderr: "fatal: not a git repository" }
292
+ : undefined,
293
+ });
294
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
295
+ await h.writeSpec("doc/specs/a.md");
296
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
297
+ const rec = h.readRecord();
298
+ assert.deepEqual(
299
+ rec.events.filter((e) => e.kind === "warning").map((e: any) => e.message),
300
+ ["record written, not committed: not a git checkout"],
301
+ );
302
+ assert.equal(h.commits().length, 0);
303
+ });
304
+
284
305
  test("worktree spec from a primary cwd: record keyed under the worktree, committed there (AC 8)", async () => {
285
306
  const h = harness();
286
307
  await h.emit("session_start", { type: "session_start", reason: "startup" });
@@ -48,6 +48,8 @@
48
48
  export interface Deps {
49
49
  fs: FsPort;
50
50
  git: (args: string[], cwd: string) => Promise<GitResult>;
51
+ // Optional jj override for tests; production resolves plain jj workspaces via jjSync.
52
+ jj?: (args: string[], cwd: string) => GitResult | Promise<GitResult>;
51
53
  now: () => string;
52
54
  settings: (cwd: string) => SettingsSnapshot;
53
55
  }
@@ -160,6 +162,7 @@
160
162
  export default function (pi: ExtensionAPI, deps: Deps = realDeps) {
161
163
  let phases: PhaseMap = emptyPhases();
162
164
  let toplevel: string | undefined; // toplevel of the bound record's checkout; undefined until bind
165
+ let checkoutVia: "git" | "jj" | undefined; // which binary resolved toplevel; undefined until bind
163
166
  let boundSpec: string | undefined; // repo-relative
164
167
  let record: TelemetryRecord | undefined;
165
168
  const predecessorLinks = new Set<string>();
@@ -170,6 +173,7 @@
170
173
  let lastCommitted = "";
171
174
  let frozen = false;
172
175
  let settingsWarned = false;
176
+ let notGitWarned = false;
173
177
  let sessionId = "";
174
178
 
175
179
  // Per-event settings read; undefined => this event is a no-op.
@@ -182,11 +186,11 @@
182
186
 
183
187
  // Resolves a tool path (relative to ctx.cwd or absolute) to its owning checkout and
184
188
  // checkout-relative key. undefined outside any checkout.
185
- const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string } | undefined> => {
189
+ const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string; via: "git" | "jj" } | undefined> => {
186
190
  const candidateAbs = isAbsolute(p) ? p : resolve(ctx.cwd, p);
187
- const co = await checkoutOf(candidateAbs, deps.git);
191
+ const co = await checkoutOf(candidateAbs, deps.git, deps.jj);
188
192
  const rel = co ? repoRelativeToolPath(co.toplevel, ctx.cwd, p) : undefined;
189
- return co && rel ? { toplevel: co.toplevel, rel } : undefined;
193
+ return co && rel ? { toplevel: co.toplevel, rel, via: co.via } : undefined;
190
194
  };
191
195
 
192
196
  const live = (): Accumulators => record
@@ -230,6 +234,15 @@
230
234
 
231
235
  const commitRecord = async () => {
232
236
  if (!record || !recordRel || frozen || !toplevel) return;
237
+ if (checkoutVia === "jj") {
238
+ // A jj-bound checkout has no .git to commit into; say so once per session instead
239
+ // of failing `git commit` at every checkpoint.
240
+ if (!notGitWarned) {
241
+ notGitWarned = true;
242
+ warn("record written, not committed: not a git checkout");
243
+ }
244
+ return;
245
+ }
233
246
  const text = deps.fs.readFile(abs(recordRel)) ?? "";
234
247
  if (text === lastCommitted) return;
235
248
  const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
@@ -274,8 +287,9 @@
274
287
  return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
275
288
  };
276
289
 
277
- const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string) => {
290
+ const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string, via: "git" | "jj") => {
278
291
  toplevel = specToplevel;
292
+ checkoutVia = via;
279
293
  currentDir = snap.telemetry.dir;
280
294
  boundSpec = specRel;
281
295
  recordRel = recordPathFor(currentDir, specRel);
@@ -314,6 +328,7 @@
314
328
 
315
329
  const unbind = () => {
316
330
  toplevel = undefined;
331
+ checkoutVia = undefined;
317
332
  boundSpec = undefined;
318
333
  record = undefined;
319
334
  recordRel = undefined;
@@ -416,7 +431,7 @@
416
431
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
417
432
  const loc = candidate ? await locate(ctx, candidate) : undefined;
418
433
  if (loc && isSpecPath(loc.rel)) {
419
- await bind(loc.rel, snap, loc.toplevel);
434
+ await bind(loc.rel, snap, loc.toplevel, loc.via);
420
435
  await flush(false);
421
436
  }
422
437
  });
@@ -450,7 +465,7 @@
450
465
  const pass = d?.status === "pass";
451
466
  if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
452
467
  if (record) unbind();
453
- await bind(specLoc.rel, snap, specLoc.toplevel);
468
+ await bind(specLoc.rel, snap, specLoc.toplevel, specLoc.via);
454
469
  }
455
470
  live().gates.plan_rounds += 1;
456
471
  pushEvent({ kind: "plan_check", pass, spec, plan });
@@ -498,11 +513,11 @@
498
513
  });
499
514
 
500
515
  // Binding + spec-write bookkeeping; the bound-spec hooks are defined in block 2.
501
- const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
516
+ const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
502
517
  const rel = loc.rel;
503
518
  if (!boundSpec) {
504
519
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
505
- await bind(rel, snap, loc.toplevel);
520
+ await bind(rel, snap, loc.toplevel, loc.via);
506
521
  const patch = onBoundSpecWrite(tool, rel, event);
507
522
  await flush(true);
508
523
  return patch;
@@ -510,7 +525,7 @@
510
525
  const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
511
526
  const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
512
527
  if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
513
- await bind(specRel, snap, loc.toplevel);
528
+ await bind(specRel, snap, loc.toplevel, loc.via);
514
529
  await flush(true);
515
530
  }
516
531
  }
@@ -520,7 +535,7 @@
520
535
  // Another checkout: a fresh run, never a rename of this record.
521
536
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
522
537
  unbind();
523
- await bind(rel, snap, loc.toplevel);
538
+ await bind(rel, snap, loc.toplevel, loc.via);
524
539
  const patch = onBoundSpecWrite(tool, rel, event);
525
540
  await flush(true);
526
541
  return patch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.10.2",
3
+ "version": "5.12.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  ],
34
34
  "bin": {
35
35
  "gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
36
- "gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs"
36
+ "gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs",
37
+ "gauntlet-performance": "bin/gauntlet-performance.mjs"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=24.15.0"
@@ -177,7 +177,7 @@ fi
177
177
  node <bin>/gauntlet-telemetry-salvage.mjs --worktree "$WORKTREE" --base <base-branch>
178
178
  ```
179
179
 
180
- The salvage prints one line per spec on the branch (`present`, `restored <path> from <sha>`, `no telemetry run`, `never written`, `restore failed <path>: <reason>`) and always exits 0. Print its stdout verbatim in the ship completion message. A `restore failed` line is reported, never retried, and never blocks the ship - the record stays recoverable from the branch ref.
180
+ The salvage prints one line per spec on the branch (`present`, `present <path> (marked shipped)`, `restored <path> from <sha>`, `restored <path> from <sha> (marked shipped)`, `no telemetry run`, `never written`, `restore failed <path>: <reason>`) and always exits 0. `(marked shipped)` means the record was still `in_progress` with no ship phase (the recorder lost its binding) and the salvage committed `status: shipped` + `shipped_at` as one `telemetry:` commit; it rides the squash or push like any branch commit. Print its stdout verbatim in the ship completion message. A `restore failed` line is reported, never retried, and never blocks the ship - the record stays recoverable from the branch ref.
181
181
 
182
182
  #### Option 1: Squash-merge to base
183
183
 
@@ -174,6 +174,7 @@ verification command may write to the tree while the Reviewer reads it):
174
174
  squash. On a cell with no push row (fork overlay, report-only states) the same
175
175
  finding is a non-blocking follow-up instead: the record stays recoverable from the
176
176
  PR head ref after merge, and blocking would stop a ship the gate cannot repair.
177
+ `unfinished <path>` (record still `in_progress` with no ship phase) also lands in `## Evidence` as one line and is non-blocking: pre-landing `in_progress` is normal, and the merge course's salvage run stamps it.
177
178
  - **Evidence:** On the CI path, list each satisfying check's name, conclusion, assessed SHA, and run URL - there is no command or raw_tail to paste. On the local path, paste each run's `command` and `raw_tail` verbatim, fenced - never
178
179
  paraphrased. Any authored summary is labeled as a summary and never substitutes for
179
180
  `raw_tail`.
@@ -256,7 +257,7 @@ are never bundled into one selection, with one scoped exception: the selected me
256
257
  course first runs `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned
257
258
  path> --base origin/<baseRefName>` (no `--check`). `present` -> merge as-is. `restored
258
259
  <path> from <sha>` -> push that single `telemetry: restore` commit as part of this
259
- course, re-fetch `headRefOid`, and pass the new SHA to `--match-head-commit`. `restore
260
+ course, re-fetch `headRefOid`, and pass the new SHA to `--match-head-commit`. A line ending `(marked shipped)` (`present` or `restored`) is handled the same way: push that single `telemetry:` commit, re-fetch `headRefOid`, pass the new SHA. `restore
260
261
  failed` -> merge proceeds, the reason is printed, and the follow-up names recovery
261
262
  from the PR head ref.
262
263
 
@@ -508,7 +509,7 @@ The menu is a state machine, not a one-shot report:
508
509
  reviewed doc edits selected alongside them (one commit, or one per batch
509
510
  sequentially; subjects name the fixes) - then re-resolves the evidence for the new head **once** (the brief's stale-head row: prior evidence is stale; the local command executes only on a fallback/opt-out resolution). Before the push, run
510
511
  `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned path> --base
511
- origin/<baseRefName>` (no `--check`); a `restored` commit rides the wave's single
512
+ origin/<baseRefName>` (no `--check`); a `restored` or `(marked shipped)` commit rides the wave's single
512
513
  push and the pushed SHA becomes the assessed head under the course's-own-push rule
513
514
  in step 1. Print its stdout in the re-rendered report's `## Evidence`. **On
514
515
  green**, push **once**; gate and push are per-wave invariants, never per-fix or
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: gauntlet-performance
3
+ description: Use when a human asks how gauntlet runs perform across the recorded telemetry - explicit invocation only (/skill:gauntlet-performance [--dir <path>]... [--since <version>]).
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Gauntlet Performance
8
+
9
+ The CLI parses and aggregates; you reason. Never open a telemetry YAML record yourself.
10
+
11
+ ## Run the digest
12
+
13
+ 1. Resolve the CLI: `<directory of this SKILL.md>/../../bin/gauntlet-performance.mjs`.
14
+ 2. Run it from the repo root, passing the user's `--dir` and `--since` arguments verbatim:
15
+ `node <bin>/gauntlet-performance.mjs [--dir <path>]... [--since <version>]`.
16
+ Default corpus is the current repo; each `--dir` adds a repo root or a telemetry dir.
17
+ 3. If the command is missing or the output contains `no records found`, relay that line and stop - no menu.
18
+
19
+ ## Read the digest
20
+
21
+ `runs` has one row per record; `shipped*` marks a truncated run (no ship phase recorded - salvage stamped it at landing; its `wall` is `-` and it feeds no aggregate). `by version` groups by pi-gauntlet version: `n` counts every row, `shipped` counts the rows behind the p50/max columns. `grants` is `fix_round_grants` - the fix-round proxy (human-granted extra review rounds); schema 1 has no code-review round count.
22
+
23
+ ## Reply - exactly this, in this order
24
+
25
+ 1. **Recommendation** (2-4 sentences). One claim, led by the run that exemplifies it: quote its `spec` slug, `run_id`, and the 1-3 numbers that carry the claim. When `grants` is the evidence, call it the fix-round proxy. If no version group has `shipped >= 2`, the recommendation is "sample too small" with the `n`/`shipped` counts per version.
26
+ 2. **Cornerstones**: 3-5 bullets of aggregate facts from `by version` - corpus size, truncated count, the p50s and model tallies that moved between versions.
27
+ 3. **Menu**, numbered, at most 3 items, rendered exactly as:
28
+ - `1. render report` - ask for a target path; write markdown there: the digest verbatim, then the recommendation and cornerstones above. If the file exists, ask before overwriting. Write nothing unless this item is chosen.
29
+ - `2. open recommendation as ticket` - hand the claim and its numbers to `/skill:shape-ticket`; never create a ticket directly.
30
+ - `3. drill into <slug>` - re-run the CLI with `--json` and show that run's fields.
31
+
32
+ Nothing else: no preamble, no restated digest, no file written before item 1 is chosen.
33
+
34
+ ## Project overrides
35
+
36
+ If a gauntlet overrides file exists - checked in order:
37
+ `.pi/gauntlet-overrides.md`, `<repo root>/gauntlet-overrides.md`,
38
+ `<repo root>/doc/gauntlet-overrides.md`; first found wins - read it. Any
39
+ sections relevant to this skill - by name match, by topic (routing,
40
+ verification, worktrees, etc.), or by workflow convention - override or
41
+ extend the instructions above. Project-local `AGENTS.md` is already in
42
+ context - check it for project-specific routing tables, service paths, and
43
+ verification commands.