pi-gauntlet 5.10.0 → 5.10.2

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v5.10.2 - 2026-09-18
4
+
5
+ - The telemetry record is a deliverable: brainstorming, `finishing-a-development-branch`, and `gatekeep-pr` name it beside the spec, and a new shipped `gauntlet-telemetry-salvage` bin restores a record that a plan strip or a fix commit deleted (from the deleting commit's parent, as one `telemetry: ` commit, exit 0 for valid invocations, never pushes). Finishing strips the plan on the feature branch before the Option 1 squash; gatekeep-pr checks at assessment, restores after each fix wave, and restores as the first step of a merge course. CI pins the bin, its test, its tarball imports, and the rule's presence in the three skills.
6
+
7
+ ## v5.10.1 - 2026-09-18
8
+
9
+ - `skills/brainstorming/SKILL.md`: restore precision the 5.10.0 dedup dropped - first-feature round 1 lists the concrete decisions (public types/files/routes/identifiers; abstraction location/responsibility/boundary; schema entity names/field types/indexing), doc updates ship in the same commit as the code, the premise note is full sentences and an unverified claim is not a stop, a multi-concern request is decomposed rather than designed as one spec, 300-500 words is a target per round, and the gather-stop red flag links to `gatherer.md`.
10
+
3
11
  ## v5.10.0 - 2026-09-18
4
12
 
5
13
  - Council provenance: member findings (except `over-spec`) end with `probed: <source or check> - <result> | none`; the chair tags each suggested edit `grounded:` or `hypothesis:` (`external-ref:` and `over-spec:` clusters stay untagged); `roasting-the-spec` probes `hypothesis` data-shape claims once (bounded, read-only, artifact at hand) before applying, else lands them as one grouped Open Question per artifact; `Applied:` audit lines carry the probe.
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
- - **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.
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) that ships in the squash beside the spec - finishing and the PR gate restore a stripped record with `gauntlet-telemetry-salvage` - 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,141 @@
1
+ #!/usr/bin/env node
2
+ // Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
3
+ // from the branch. The recorder pathspec-commits the record at every checkpoint, so the
4
+ // newest copy is always in the deleting commit's parent. Exit 0 on every outcome: the
5
+ // callers are ship paths and this script never blocks one.
6
+ import { existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { isAbsolute, join } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import process from "node:process";
11
+ import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
12
+ import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
13
+ import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
14
+
15
+ const GIT_TIMEOUT_MS = 10_000;
16
+ const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
17
+ const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" };
18
+
19
+ const usage = () => {
20
+ process.stderr.write("usage: gauntlet-telemetry-salvage --worktree <abs path> [--base <ref>] [--dir <telemetry dir>] [--check]\n");
21
+ process.exit(1);
22
+ };
23
+
24
+ function parseArgs(argv) {
25
+ const opts = { worktree: undefined, base: undefined, dir: undefined, check: false };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === "--worktree" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.worktree = argv[++i];
29
+ else if (a === "--base" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.base = argv[++i];
30
+ else if (a === "--dir" && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) opts.dir = argv[++i];
31
+ else if (a === "--check") opts.check = true;
32
+ else usage();
33
+ }
34
+ if (!opts.worktree || !isAbsolute(opts.worktree)) usage();
35
+ return opts;
36
+ }
37
+
38
+ function git(cwd, args, timeout = GIT_TIMEOUT_MS) {
39
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", env: GIT_ENV, timeout });
40
+ const timedOut = r.error?.code === "ETIMEDOUT";
41
+ const stderr = timedOut ? "timed out" : (r.stderr ?? "").trim().split("\n")[0] || r.error?.message || `git exited ${r.status}`;
42
+ return { ok: !timedOut && r.status === 0, stdout: (r.stdout ?? "").trim(), stderr, timedOut };
43
+ }
44
+
45
+ function readLayer(file) {
46
+ if (!existsSync(file)) return {};
47
+ try {
48
+ return JSON.parse(readFileSync(file, "utf8"));
49
+ } catch (e) {
50
+ process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer\n`);
51
+ return {};
52
+ }
53
+ }
54
+
55
+ // Same two layers the recorder reads (gauntlet-settings-loader.ts), without the pi import.
56
+ function telemetrySettings(root) {
57
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
58
+ const preset = readLayer(join(agentDir, "settings.json"));
59
+ const repo = readLayer(join(root, ".pi", "settings.json"));
60
+ return resolveTelemetry(mergeGauntlet(preset?.piGauntlet, repo?.piGauntlet));
61
+ }
62
+
63
+ function resolveBase(root, explicit) {
64
+ for (const ref of explicit ? [explicit] : BASE_REFS) {
65
+ if (git(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok) return ref;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function salvage(root, rec, base, check) {
71
+ const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
72
+ if (presence.timedOut) return `restore failed ${rec}: timed out`;
73
+ if (presence.ok) return `present ${rec}`;
74
+ const history = git(root, ["log", `${base}..HEAD`, "--grep=^telemetry: ", "-1", "--format=%H"]);
75
+ if (history.timedOut) return `restore failed ${rec}: timed out`;
76
+ if (!history.stdout) return `no telemetry run ${rec}`;
77
+ const deletion = git(root, ["log", "-1", "--diff-filter=D", "--format=%H", `${base}..HEAD`, "--", rec]);
78
+ if (deletion.timedOut) return `restore failed ${rec}: timed out`;
79
+ const del = deletion.stdout;
80
+ if (!del) return `never written ${rec}`;
81
+ if (check) return `stripped ${rec} in ${del}`;
82
+
83
+ const abs = join(root, rec);
84
+ const onDisk = existsSync(abs);
85
+ const preStaged = git(root, ["ls-files", "--", rec]).stdout !== "";
86
+ // A pre-staged record is user-owned and can only be committed when the worktree matches it.
87
+ if (preStaged && (!onDisk || !git(root, ["diff", "--quiet", "--", rec]).ok)) {
88
+ return `restore failed ${rec}: staged copy differs from worktree`;
89
+ }
90
+ let created = false;
91
+ let stagedByScript = false;
92
+ const rollback = () => {
93
+ if (stagedByScript) git(root, ["reset", "-q", "--", rec]);
94
+ if (created) rmSync(abs, { force: true });
95
+ };
96
+ const failed = (reason) => {
97
+ rollback();
98
+ return `restore failed ${rec}: ${reason}`;
99
+ };
100
+
101
+ if (!onDisk) {
102
+ const co = git(root, ["checkout", `${del}^`, "--", rec]);
103
+ if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
104
+ created = true;
105
+ stagedByScript = true;
106
+ } else if (!preStaged) {
107
+ const add = git(root, ["add", "-f", "--", rec]);
108
+ if (!add.ok) return failed(add.stderr);
109
+ stagedByScript = true;
110
+ }
111
+ const commit = git(root, ["commit", "-q", "-m", `telemetry: restore record stripped in ${del.slice(0, 10)}`, "--", rec], COMMIT_TIMEOUT_MS);
112
+ if (!commit.ok) return failed(commit.stderr);
113
+ return `restored ${rec} from ${del}`;
114
+ }
115
+
116
+ function main() {
117
+ const opts = parseArgs(process.argv.slice(2));
118
+ const override = opts.dir === undefined
119
+ ? undefined
120
+ : resolveTelemetry({ telemetry: { enabled: true, dir: opts.dir } });
121
+ if (override?.warning) usage();
122
+ const top = git(opts.worktree, ["rev-parse", "--show-toplevel"]);
123
+ if (!existsSync(opts.worktree) || !top.ok) {
124
+ process.stderr.write(`not a git worktree: ${opts.worktree}\n`);
125
+ return;
126
+ }
127
+ const root = top.stdout;
128
+ const telemetry = telemetrySettings(root);
129
+ if (telemetry.warning) process.stderr.write(`warning: ${telemetry.warning}\n`);
130
+ if (!telemetry.enabled) return console.log("telemetry disabled");
131
+ const dir = override?.dir ?? telemetry.dir;
132
+ const base = resolveBase(root, opts.base);
133
+ if (!base) return console.log("no base ref");
134
+ const diff = git(root, ["diff", "--name-only", `${base}...HEAD`]);
135
+ if (!diff.ok) return console.log("no base ref");
136
+ const specs = diff.stdout.split("\n").filter((f) => f && isSpecPath(f) && git(root, ["cat-file", "-e", `HEAD:${f}`]).ok);
137
+ if (specs.length === 0) return console.log("no spec on branch");
138
+ for (const spec of specs) console.log(salvage(root, recordPathFor(dir, spec), base, opts.check));
139
+ }
140
+
141
+ main();
@@ -0,0 +1,364 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } 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
+
9
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-salvage.mjs");
10
+ const SPEC = "doc/specs/x.md";
11
+ const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
12
+ const RECORD_V1 = "schema: 1\nspec: doc/specs/x.md\nstatus: in_progress\n";
13
+ const RECORD_V2 = "schema: 1\nspec: doc/specs/x.md\nstatus: shipped\n";
14
+
15
+ const git = (cwd, args, env = {}) =>
16
+ spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], {
17
+ cwd, encoding: "utf8", env: { ...process.env, ...env },
18
+ });
19
+ const out = (cwd, args) => git(cwd, args).stdout.trim();
20
+ const write = (root, rel, text) => {
21
+ mkdirSync(join(root, dirname(rel)), { recursive: true });
22
+ writeFileSync(join(root, rel), text);
23
+ };
24
+ const commit = (root, msg, ...paths) => {
25
+ git(root, ["add", "-f", "--", ...paths]);
26
+ git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
27
+ return out(root, ["rev-parse", "HEAD"]);
28
+ };
29
+ const rmCommit = (root, msg, ...paths) => {
30
+ git(root, ["rm", "-q", "--", ...paths]);
31
+ git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
32
+ return out(root, ["rev-parse", "HEAD"]);
33
+ };
34
+ // main: README only. feat: spec (+ record when withRecord).
35
+ const repo = ({ withRecord = true } = {}) => {
36
+ const root = mkdtempSync(join(tmpdir(), "gts-"));
37
+ git(root, ["init", "-q", "-b", "main"]);
38
+ write(root, "README.md", "# fixture\n");
39
+ commit(root, "init", "README.md");
40
+ git(root, ["checkout", "-q", "-b", "feat"]);
41
+ write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
42
+ commit(root, "Add spec", SPEC);
43
+ if (withRecord) {
44
+ write(root, REC, RECORD_V1);
45
+ commit(root, "telemetry: doc/specs/x.md", REC);
46
+ }
47
+ return root;
48
+ };
49
+ // An empty preset dir by default: the developer's real ~/.pi/agent/settings.json must never leak into fixtures.
50
+ const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
51
+ process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
52
+ // The CLI commits with whatever identity git resolves, like the recorder; CI runners have none.
53
+ const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
54
+ const invoke = (args, env = {}, options = {}) => {
55
+ const r = spawnSync(process.execPath, [CLI, ...args], {
56
+ encoding: "utf8", env: { ...process.env, ...IDENTITY, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env }, timeout: options.timeout,
57
+ });
58
+ return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
59
+ };
60
+ const run = (root, args = [], env = {}) => invoke(["--worktree", root, "--base", "main", ...args], env);
61
+ const head = (root) => out(root, ["rev-parse", "HEAD"]);
62
+ const commitCount = (root) => Number(out(root, ["rev-list", "--count", "HEAD"]));
63
+ const headSubject = (root) => out(root, ["log", "-1", "--format=%s"]);
64
+ const headFiles = (root) => out(root, ["show", "--name-only", "--format=", "HEAD"]).split("\n").filter(Boolean);
65
+ const porcelain = (root) => out(root, ["status", "--porcelain"]);
66
+ const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
67
+
68
+ test("present: record in HEAD tree -> present, no commit", (t) => {
69
+ const root = repo(); cleanup(t, root);
70
+ const before = head(root);
71
+ const r = run(root);
72
+ assert.equal(r.status, 0, r.stderr);
73
+ assert.deepEqual(r.lines, [`present ${REC}`]);
74
+ assert.equal(head(root), before);
75
+ });
76
+
77
+ test("deleted once -> restored from <sha>, byte-identical, one telemetry: commit touching only the record", (t) => {
78
+ const root = repo(); cleanup(t, root);
79
+ const del = rmCommit(root, "Strip ephemeral plan and telemetry scaffolding", REC);
80
+ const n = commitCount(root);
81
+ const r = run(root);
82
+ assert.equal(r.status, 0, r.stderr);
83
+ assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
84
+ assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V1);
85
+ assert.equal(commitCount(root), n + 1);
86
+ assert.equal(headSubject(root), `telemetry: restore record stripped in ${del.slice(0, 10)}`);
87
+ assert.deepEqual(headFiles(root), [REC]);
88
+ assert.equal(porcelain(root), "");
89
+ });
90
+
91
+ test("deleted twice -> restores the newest (second) content", (t) => {
92
+ const root = repo(); cleanup(t, root);
93
+ rmCommit(root, "strip 1", REC);
94
+ write(root, REC, RECORD_V2);
95
+ commit(root, "telemetry: doc/specs/x.md", REC);
96
+ const del2 = rmCommit(root, "strip 2", REC);
97
+ const r = run(root);
98
+ assert.deepEqual(r.lines, [`restored ${REC} from ${del2}`]);
99
+ assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V2);
100
+ });
101
+
102
+ test("deleted, then rewritten on disk untracked -> on-disk copy committed, not DEL^", (t) => {
103
+ const root = repo(); cleanup(t, root);
104
+ const del = rmCommit(root, "strip", REC);
105
+ write(root, REC, RECORD_V2);
106
+ const r = run(root);
107
+ assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
108
+ assert.equal(out(root, ["show", `HEAD:${REC}`]), RECORD_V2.trim());
109
+ assert.equal(porcelain(root), "");
110
+ });
111
+
112
+ test("deleted and staged again uncommitted -> not present; staged copy committed", (t) => {
113
+ const root = repo(); cleanup(t, root);
114
+ const del = rmCommit(root, "strip", REC);
115
+ write(root, REC, RECORD_V2);
116
+ git(root, ["add", "-f", "--", REC]);
117
+ const r = run(root);
118
+ assert.deepEqual(r.lines, [`restored ${REC} from ${del}`]);
119
+ assert.equal(out(root, ["show", `HEAD:${REC}`]), RECORD_V2.trim());
120
+ assert.equal(porcelain(root), "");
121
+ });
122
+
123
+ test("pre-staged record missing from worktree -> failure preserves index and HEAD", (t) => {
124
+ const root = repo(); cleanup(t, root);
125
+ rmCommit(root, "strip", REC);
126
+ write(root, REC, RECORD_V2);
127
+ git(root, ["add", "-f", "--", REC]);
128
+ rmSync(join(root, REC));
129
+ const before = head(root);
130
+ const r = run(root);
131
+ assert.equal(r.stdout, `restore failed ${REC}: staged copy differs from worktree`);
132
+ assert.equal(out(root, ["show", `:${REC}`]), RECORD_V2.trim());
133
+ assert.equal(head(root), before);
134
+ assert.equal(existsSync(join(root, REC)), false);
135
+ });
136
+
137
+ test("pre-staged record differing from worktree -> failure preserves both copies and HEAD", (t) => {
138
+ const root = repo(); cleanup(t, root);
139
+ rmCommit(root, "strip", REC);
140
+ write(root, REC, RECORD_V2);
141
+ git(root, ["add", "-f", "--", REC]);
142
+ write(root, REC, RECORD_V1);
143
+ const before = head(root);
144
+ const r = run(root);
145
+ assert.equal(r.stdout, `restore failed ${REC}: staged copy differs from worktree`);
146
+ assert.equal(out(root, ["show", `:${REC}`]), RECORD_V2.trim());
147
+ assert.equal(readFileSync(join(root, REC), "utf8"), RECORD_V1);
148
+ assert.equal(head(root), before);
149
+ });
150
+
151
+ test("deleted in its introducing commit (amended away) -> nothing to restore, no file, clean index", (t) => {
152
+ const root = repo(); cleanup(t, root);
153
+ // The record's only commit is rewritten without it: git records no D entry whose parent lacks the blob,
154
+ // so the spec's "introducing commit" limitation surfaces as `never written` and the script leaves nothing behind.
155
+ git(root, ["rm", "-q", "--", REC]);
156
+ git(root, ["commit", "-q", "--amend", "--allow-empty", "-m", "telemetry: doc/specs/x.md"]);
157
+ const before = head(root);
158
+ const r = run(root);
159
+ assert.equal(r.status, 0, r.stderr);
160
+ assert.deepEqual(r.lines, [`never written ${REC}`]);
161
+ assert.equal(head(root), before);
162
+ assert.equal(existsSync(join(root, REC)), false);
163
+ assert.equal(porcelain(root), "");
164
+ });
165
+
166
+ test("no telemetry: commit on branch -> no telemetry run, no commit", (t) => {
167
+ const root = repo({ withRecord: false }); cleanup(t, root);
168
+ const n = commitCount(root);
169
+ const r = run(root);
170
+ assert.deepEqual(r.lines, [`no telemetry run ${REC}`]);
171
+ assert.equal(commitCount(root), n);
172
+ });
173
+
174
+ test("telemetry: commit for another path, no deletion of this one -> never written", (t) => {
175
+ const root = repo({ withRecord: false }); cleanup(t, root);
176
+ write(root, ".pi/gauntlet/telemetry/doc/specs/other.yaml", "x\n");
177
+ commit(root, "telemetry: doc/specs/other.md", ".pi/gauntlet/telemetry/doc/specs/other.yaml");
178
+ const r = run(root);
179
+ assert.deepEqual(r.lines, [`never written ${REC}`]);
180
+ });
181
+
182
+ test("--check on a stripped record -> stripped ... in <sha>, no commit, no file", (t) => {
183
+ const root = repo(); cleanup(t, root);
184
+ const del = rmCommit(root, "strip", REC);
185
+ const before = head(root);
186
+ const r = run(root, ["--check"]);
187
+ assert.deepEqual(r.lines, [`stripped ${REC} in ${del}`]);
188
+ assert.equal(head(root), before);
189
+ assert.equal(existsSync(join(root, REC)), false);
190
+ assert.equal(porcelain(root), "");
191
+ });
192
+
193
+ test("repo .pi/settings.json telemetry.enabled false -> telemetry disabled only", (t) => {
194
+ const root = repo(); cleanup(t, root);
195
+ write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
196
+ commit(root, "settings", ".pi/settings.json");
197
+ rmCommit(root, "strip", REC);
198
+ const r = run(root);
199
+ assert.equal(r.stdout, "telemetry disabled");
200
+ });
201
+
202
+ test("preset-only telemetry.dir via PI_CODING_AGENT_DIR resolves; --dir overrides both layers", (t) => {
203
+ const root = repo({ withRecord: false });
204
+ const agent = mkdtempSync(join(tmpdir(), "gts-agent-"));
205
+ cleanup(t, root, agent);
206
+ writeFileSync(join(agent, "settings.json"), JSON.stringify({ piGauntlet: { telemetry: { dir: "custom/dir" } } }));
207
+ const custom = "custom/dir/doc/specs/x.yaml";
208
+ write(root, custom, RECORD_V1);
209
+ commit(root, "telemetry: doc/specs/x.md", custom);
210
+ const del = rmCommit(root, "strip", custom);
211
+ const r = run(root, [], { PI_CODING_AGENT_DIR: agent });
212
+ assert.deepEqual(r.lines, [`restored ${custom} from ${del}`]);
213
+ const r2 = run(root, ["--dir", "elsewhere"], { PI_CODING_AGENT_DIR: agent });
214
+ assert.deepEqual(r2.lines, [`never written elsewhere/doc/specs/x.yaml`]);
215
+ });
216
+
217
+ test("rejecting pre-commit hook -> restore failed, index and worktree identical to before", (t) => {
218
+ const root = repo(); cleanup(t, root);
219
+ const del = rmCommit(root, "strip", REC);
220
+ write(root, ".git/hooks/pre-commit", "#!/bin/sh\nexit 1\n");
221
+ chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
222
+ const before = head(root);
223
+ const r = run(root);
224
+ assert.equal(r.status, 0);
225
+ assert.match(r.stdout, new RegExp(`^restore failed ${REC.replace(/\./g, "\\.")}: `));
226
+ assert.equal(head(root), before);
227
+ assert.equal(existsSync(join(root, REC)), false);
228
+ assert.equal(porcelain(root), "");
229
+ });
230
+
231
+ test("hanging pre-commit hook -> restore failed ... timed out within the bound, state rolled back", (t) => {
232
+ const root = repo(); cleanup(t, root);
233
+ rmCommit(root, "strip", REC);
234
+ write(root, ".git/hooks/pre-commit", "#!/bin/sh\nsleep 60\n");
235
+ chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
236
+ const before = head(root);
237
+ const r = invoke(["--worktree", root, "--base", "main"], { GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS: "2000" }, { timeout: 20_000 });
238
+ assert.equal(r.status, 0);
239
+ assert.equal(r.stdout, `restore failed ${REC}: timed out`);
240
+ assert.equal(head(root), before);
241
+ assert.equal(existsSync(join(root, REC)), false);
242
+ assert.equal(porcelain(root), "");
243
+ });
244
+
245
+ test("timed-out record presence query -> restore failed, no mutation", (t) => {
246
+ const root = repo();
247
+ const bin = mkdtempSync(join(tmpdir(), "gts-bin-"));
248
+ cleanup(t, root, bin);
249
+ const wrapper = join(bin, "git");
250
+ writeFileSync(wrapper, `#!/bin/sh
251
+ case "$*" in
252
+ *"cat-file -e HEAD:${REC}"*) sleep 60 ;;
253
+ esac
254
+ PATH="${process.env.PATH.split(":").filter((part) => part !== bin).join(":")}" exec git "$@"
255
+ `);
256
+ chmodSync(wrapper, 0o755);
257
+ const before = head(root);
258
+ const r = invoke(["--worktree", root, "--base", "main"], { PATH: `${bin}:${process.env.PATH}` }, { timeout: 20_000 });
259
+ assert.equal(r.status, 0);
260
+ assert.equal(r.stdout, `restore failed ${REC}: timed out`);
261
+ assert.equal(head(root), before);
262
+ assert.equal(porcelain(root), "");
263
+ });
264
+
265
+ test("two specs on the branch, one record stripped -> two lines, one restore", (t) => {
266
+ const root = repo(); cleanup(t, root);
267
+ const spec2 = "doc/specs/y.md"; const rec2 = ".pi/gauntlet/telemetry/doc/specs/y.yaml";
268
+ write(root, spec2, "# Y\n"); commit(root, "Add y", spec2);
269
+ write(root, rec2, RECORD_V1); commit(root, "telemetry: doc/specs/y.md", rec2);
270
+ const del = rmCommit(root, "strip", REC);
271
+ const n = commitCount(root);
272
+ const r = run(root);
273
+ assert.deepEqual(r.lines.sort(), [`present ${rec2}`, `restored ${REC} from ${del}`].sort());
274
+ assert.equal(commitCount(root), n + 1);
275
+ });
276
+
277
+ test("spec deleted on the branch -> no spec on branch", (t) => {
278
+ const root = repo(); cleanup(t, root);
279
+ rmCommit(root, "reap superseded spec", SPEC, REC);
280
+ const r = run(root);
281
+ assert.equal(r.stdout, "no spec on branch");
282
+ });
283
+
284
+ test("unresolvable base without --base -> no base ref; --base main works", (t) => {
285
+ const root = repo(); cleanup(t, root);
286
+ git(root, ["branch", "-m", "main", "trunk"]);
287
+ const bare = invoke(["--worktree", root]);
288
+ assert.equal(bare.status, 0);
289
+ assert.equal(bare.stdout, "no base ref");
290
+ const r = invoke(["--worktree", root, "--base", "trunk"]);
291
+ assert.equal(r.stdout, `present ${REC}`);
292
+ });
293
+
294
+ test("--worktree at a non-git directory -> stderr not a git worktree, empty stdout, exit 0", (t) => {
295
+ const dir = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, dir);
296
+ const r = invoke(["--worktree", dir]);
297
+ assert.equal(r.status, 0);
298
+ assert.equal(r.stdout, "");
299
+ assert.match(r.stderr, /^not a git worktree: /);
300
+ });
301
+
302
+ test("relative --worktree -> usage on stderr, exit 1, empty stdout", () => {
303
+ const r = invoke(["--worktree", "relative/path"]);
304
+ assert.equal(r.status, 1);
305
+ assert.equal(r.stdout, "");
306
+ assert.match(r.stderr, /^usage: gauntlet-telemetry-salvage/);
307
+ });
308
+
309
+ test("escaping --dir -> usage on stderr, exit 1", (t) => {
310
+ const root = repo(); cleanup(t, root);
311
+ const r = invoke(["--worktree", root, "--dir", "../outside"]);
312
+ assert.equal(r.status, 1);
313
+ assert.equal(r.stdout, "");
314
+ assert.match(r.stderr, /^usage: gauntlet-telemetry-salvage/);
315
+ });
316
+
317
+ test("trailing slash in --dir is normalized", (t) => {
318
+ const root = repo({ withRecord: false }); cleanup(t, root);
319
+ const custom = "custom/dir/doc/specs/x.yaml";
320
+ write(root, custom, RECORD_V1);
321
+ commit(root, "telemetry: doc/specs/x.md", custom);
322
+ const del = rmCommit(root, "strip", custom);
323
+ const r = run(root, ["--dir", "custom/dir/"]);
324
+ assert.deepEqual(r.lines, [`restored ${custom} from ${del}`]);
325
+ });
326
+
327
+ test("value flag followed by another flag -> usage, exit 1, no git call", (t) => {
328
+ const root = repo(); cleanup(t, root);
329
+ const before = head(root);
330
+ const r = invoke(["--worktree", root, "--base", "--check"]);
331
+ assert.equal(r.status, 1);
332
+ assert.match(r.stderr, /^usage:/);
333
+ assert.equal(head(root), before);
334
+ assert.equal(invoke(["--worktree"]).status, 1);
335
+ });
336
+
337
+ test("never pushes: remote refs unchanged after a successful restore", (t) => {
338
+ const root = repo();
339
+ const remote = mkdtempSync(join(tmpdir(), "gts-remote-"));
340
+ cleanup(t, root, remote);
341
+ git(remote, ["init", "-q", "--bare"]);
342
+ git(root, ["remote", "add", "origin", remote]);
343
+ git(root, ["push", "-q", "origin", "main", "feat"]);
344
+ const remoteBefore = out(remote, ["for-each-ref"]);
345
+ rmCommit(root, "strip", REC);
346
+ const r = run(root);
347
+ assert.match(r.stdout, /^restored /);
348
+ assert.equal(out(remote, ["for-each-ref"]), remoteBefore);
349
+ });
350
+
351
+ test("finishing Option 1 fixture: strip-on-branch + salvage + merge --squash leaves the record, not the plan, staged", (t) => {
352
+ const root = repo(); cleanup(t, root);
353
+ const plan = "doc/plans/x.md";
354
+ write(root, plan, "# plan\n"); commit(root, "Add plan", plan);
355
+ rmCommit(root, "Strip ephemeral plan and telemetry scaffolding", plan, REC);
356
+ const r = run(root);
357
+ assert.match(r.stdout, /^restored /);
358
+ git(root, ["checkout", "-q", "main"]);
359
+ git(root, ["merge", "--squash", "-q", "feat"]);
360
+ const staged = out(root, ["diff", "--cached", "--name-only"]).split("\n");
361
+ assert.ok(staged.includes(REC), "record staged");
362
+ assert.ok(staged.includes(SPEC), "spec staged");
363
+ assert.ok(!staged.includes(plan), "plan not staged");
364
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.10.0",
3
+ "version": "5.10.2",
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",
@@ -32,7 +32,8 @@
32
32
  "CHANGELOG.md"
33
33
  ],
34
34
  "bin": {
35
- "gauntlet-spec-index": "bin/gauntlet-spec-index.mjs"
35
+ "gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
36
+ "gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs"
36
37
  },
37
38
  "engines": {
38
39
  "node": ">=24.15.0"
@@ -66,7 +66,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
66
66
  2. Carry its `Worktree ready at <full-path>` value: spec path `<full-path>/doc/specs/<filename>.md`; every dispatch uses that `cwd`; git uses `git -C <full-path>`. Keep the process cwd unchanged.
67
67
  3. Write, review, and commit there.
68
68
 
69
- Spec, plan, and implementation share this worktree; finishing strips the ephemeral plan before squash. Explicit trivial one-off edits outside this flow need no worktree.
69
+ Spec, plan, and implementation share this worktree. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `/skill:finishing-a-development-branch` strips the plan before landing and restores a stripped record with `gauntlet-telemetry-salvage`. Explicit trivial one-off edits outside this flow need no worktree.
70
70
 
71
71
  ## The Process
72
72
 
@@ -82,7 +82,7 @@ One spec is default. Test seemingly independent concerns against `../shape-ticke
82
82
  outcome: <what a user observes once it ships>
83
83
  axis: <one item from the closed list>
84
84
 
85
- If any test fails, offer no split. Never split by service, package, repo, layer, or team. If all pass, ask whether to brainstorm the independent specs separately or explain their coupling.
85
+ If any test fails, offer no split. Never split by service, package, repo, layer, or team. If all pass, ask whether to brainstorm the independent specs separately or explain their coupling. Decompose a genuinely multi-concern request; never design it as one spec.
86
86
 
87
87
  ### 3. Understand the idea
88
88
 
@@ -92,7 +92,7 @@ Ask one question per message, preferably multiple choice, about purpose, constra
92
92
 
93
93
  Append only citable findings - schemas, hard constraints, contradictions, and scope-changing answers - to `## Appended during questionary` using `edit`.
94
94
 
95
- Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups. Put unverified claims in Open Questions or assumptions. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and asks the user to accept the corrected fact or override it; ask nothing else and propose nothing until answered. Record the outcome in the draft for `## Problem` or the relevant design decision.
95
+ Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups, in full sentences - no status-keyword lists, no template; if the design depends on no claims, one sentence says so. An unverified claim is not a stop: put it in Open Questions or a stated assumption. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and asks the user to accept the corrected fact or override it; ask nothing else and propose nothing until answered. Record the outcome in the draft for `## Problem` or the relevant design decision.
96
96
 
97
97
  ### 4. Explore approaches
98
98
 
@@ -104,7 +104,7 @@ Prefer clear testable boundaries, YAGNI, existing conventions, the owning schema
104
104
 
105
105
  ### 6. Present the design in two rounds
106
106
 
107
- Use two 300-500-word rounds with one approval each; revisions remain within that approval point. Ask once per round; round-1 approval without correction confirms the predecessor. Round 1 covers architecture, responsibilities, data flow, and `supersedes <path>, <scope>` when applicable. Round 2 covers errors, edges, tests, and `## Documentation impact`.
107
+ Use two rounds, targeting 300-500 words each, with one approval each; revisions remain within that approval point. Ask once per round; round-1 approval without correction confirms the predecessor. Round 1 covers architecture, responsibilities, data flow, and `supersedes <path>, <scope>` when applicable. Round 2 covers errors, edges, tests, and `## Documentation impact`.
108
108
 
109
109
  `## Documentation impact` is required. Cite `reference/documentation-impact.md` by relative path, do not restate its categories, and reproduce this template verbatim:
110
110
 
@@ -115,7 +115,7 @@ Use two 300-500-word rounds with one approval each; revisions remain within that
115
115
  - Derived / memory docs invalidated: <routers / AGENTS.md sections / topic guides / indexes, or "none">
116
116
  ```
117
117
 
118
- Use doc names, `none`, or `deferred: <trigger>`. Apply `reference/documentation-impact.md`; amend an owner before creating a standalone file. Put project taxonomy in the overrides file's `## documentation` block. Ship doc updates with code and verify them at conformance. Clarify when needed.
118
+ Use doc names, `none`, or `deferred: <trigger>`. Apply `reference/documentation-impact.md`; amend the existing owner; create a standalone file only where no doc owns the topic. Put project taxonomy in the overrides file's `## documentation` block (guidance only; no settings key). Doc updates ship in the same commit as the code and the conformance gate verifies them against the spec. Clarify when needed.
119
119
 
120
120
  ## Ticket Handling
121
121
 
@@ -123,7 +123,7 @@ A ticket is guidance, not sole truth. Fetch it; propose scope, approach, or acce
123
123
 
124
124
  ## First-Feature Oversight (Early Project Stages)
125
125
 
126
- For the first two features of a new module, long-lived component, schema area, or repeatable pattern, round 1 explicitly covers structure, naming, shared abstractions, persistence/schema, and proposed AGENTS.md/docs changes. Use no separate confirmation; later features follow established patterns.
126
+ For the first two features of a new module, long-lived component, schema area, or repeatable pattern, round 1 explicitly lists: directory and module structure; naming of public types, files, routes, and identifiers; each new shared abstraction's location, responsibility, and boundary; persistence/schema entity names, field types, and indexing; proposed AGENTS.md/docs additions. Use no separate confirmation; later features follow established patterns.
127
127
 
128
128
  ## Anti-Pattern: "Too simple to need a design"
129
129
 
@@ -267,7 +267,7 @@ One question at a time, YAGNI, 2-3 approaches, two design rounds, clarify freely
267
267
  - Inline scope or ambiguity checks ([owner](#spec-council-optional)).
268
268
  - Gate after failed/skipped critique or before placeholder re-scan ([owner](#spec-self-review-before-user-review-gate)).
269
269
  - Gate without summary `Read` last, or with a paraphrased summary ([owner](#user-review-gate)).
270
- - Human stop between gather and question one ([owner](#the-process)).
270
+ - Human stop between gather and question one ([owner](gatherer.md)).
271
271
  - Proposed-change execution before approval ([owner](#hard-constraint)).
272
272
  - Plan before approval; brainstorming invocation for an amend ([owner](#user-review-gate)).
273
273
  - Missing predecessor banner; invalid multi-spec split ([owner](#spec-self-review-before-user-review-gate); [owner](#2-scope-check)).
@@ -162,18 +162,38 @@ Which option?
162
162
 
163
163
  ### Step 5: Execute Choice
164
164
 
165
+ #### Strip the plan, keep the record (Options 1 and 2)
166
+
167
+ Run this on the feature branch before either landing path. The spec and its telemetry record (`<telemetry.dir>/<spec path with .md -> .yaml>`, default `.pi/gauntlet/telemetry/doc/specs/<spec>.yaml`) are deliverables and ship in the squash; only the plan is stripped. `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
168
+
169
+ ```bash
170
+ # Plans are ephemeral - if one was committed on this branch, remove it before landing.
171
+ PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
172
+ if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
173
+ git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
174
+ fi
175
+
176
+ # The telemetry record is a deliverable - restore it if a strip or a stray delete removed it.
177
+ node <bin>/gauntlet-telemetry-salvage.mjs --worktree "$WORKTREE" --base <base-branch>
178
+ ```
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.
181
+
165
182
  #### Option 1: Squash-merge to base
166
183
 
184
+ Run the strip-and-salvage block above first (plan stripped, telemetry record kept), then:
185
+
167
186
  ```bash
168
187
  git -C "$PRIMARY" checkout <base-branch>
169
188
  git -C "$PRIMARY" pull
170
189
  git -C "$PRIMARY" merge --squash "$FEATURE"
171
- git -C "$PRIMARY" rm doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
172
190
  git -C "$PRIMARY" commit -m "<imperative summary> (ref <ticket-id>)"
173
191
  (cd "$PRIMARY" && <Step 1 command for the service(s) touched>)
174
192
  ```
175
193
 
176
- The post-squash re-verify is not optional — `git merge --squash` can surface conflict-resolution mistakes the worktree-side run couldn't catch.
194
+ The plan was already removed on the branch, so the staged squash tree carries the spec, the telemetry record, and the implementation - never the plan.
195
+
196
+ The post-squash re-verify is not optional - `git merge --squash` can surface conflict-resolution mistakes the worktree-side run couldn't catch.
177
197
 
178
198
  Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMARY" branch -D "$FEATURE"`.
179
199
 
@@ -181,16 +201,14 @@ Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMA
181
201
 
182
202
  #### Option 2: Push and Create PR
183
203
 
184
- ```bash
185
- # Plans are ephemeral - if one was committed on this branch, remove it before the PR diff is opened.
186
- PLAN_PATH=doc/plans/<plan-file>.md # or <service>/doc/plans/<plan-file>.md
187
- if git -C "$WORKTREE" ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
188
- git -C "$WORKTREE" rm "$PLAN_PATH" && git -C "$WORKTREE" commit -m "Remove ephemeral plan doc"
189
- fi
204
+ Run the strip-and-salvage block above first (plan stripped, telemetry record kept), then:
190
205
 
206
+ ```bash
191
207
  # Push branch
192
208
  git -C "$WORKTREE" push -u origin "$FEATURE"
209
+ ```
193
210
 
211
+ ```bash
194
212
  # Create PR
195
213
  (cd "$WORKTREE" && gh pr create --title "<title>" --body "$(cat <<'EOF'
196
214
  ## Summary
@@ -245,10 +263,10 @@ Removal precedes branch deletion in both options; `git branch -d`/`-D` fails whi
245
263
 
246
264
  ## Quick Reference
247
265
 
248
- | Option | Merge | Push | Keep Worktree | Cleanup Branch | Plan-doc removal |
266
+ | Option | Merge | Push | Keep Worktree | Cleanup Branch | Plan strip + record salvage |
249
267
  |---|---|---|---|---|---|
250
- | 1. Squash-merge locally | yes (squash) | - | - | yes (after Step 6 removal) | yes (unconditional) |
251
- | 2. Create PR | - | yes | yes | - | yes (guarded, before push) |
268
+ | 1. Squash-merge locally | yes (squash) | - | - | yes (after Step 6 removal) | yes (guarded, on the branch before squash) |
269
+ | 2. Create PR | - | yes | yes | - | yes (guarded, on the branch before push) |
252
270
  | 3. Keep as-is | - | - | yes | - | - |
253
271
  | 4. Discard | - | - | - | yes (force, after Step 6 removal) | - |
254
272
 
@@ -284,9 +302,13 @@ A host-owned worktree (Step 6 "Otherwise") keeps both the worktree and the branc
284
302
  - **Problem:** Accidentally delete work
285
303
  - **Fix:** Require typed "discard" confirmation
286
304
 
287
- **Skipping the plan-doc deletion in Options 1 and 2 (any path that lands on base)**
288
- - **Problem:** Plan docs are ephemeral and shouldn't land on `<base-branch>`. Forgetting `git -C "$PRIMARY" rm doc/plans/<plan-file>.md` ships scaffolding to main.
289
- - **Fix:** The plan stays in the deleted branch's git history (`git -C "$PRIMARY" log --all -- doc/plans/...`). Spec stays on `<base-branch>`; plan does not.
305
+ **Skipping the strip-and-salvage block in Options 1 and 2 (any path that lands on base)**
306
+ - **Problem:** Plan docs are ephemeral and shouldn't land on `<base-branch>`; the telemetry record is a deliverable and must. Skipping the block ships the plan, or drops the record the spec index reads.
307
+ - **Fix:** Run the block on `$WORKTREE` before the squash or the push. The plan stays in the branch's git history (`git -C "$PRIMARY" log --all -- doc/plans/...`). Spec and telemetry record stay on `<base-branch>`; plan does not.
308
+
309
+ **Widening the plan strip to the telemetry record**
310
+ - **Problem:** `.pi/gauntlet/telemetry/**` looks like scaffolding next to the plan and gets deleted in the same commit - main then has no record for the run.
311
+ - **Fix:** Never `git rm` under the telemetry dir. The salvage restores a stripped record, but the deletion should not happen in the first place.
290
312
 
291
313
  ## Completion
292
314
 
@@ -309,10 +331,12 @@ Once the merge (and any deploy) has landed, `/skill:check-delivery <ticket-ref>`
309
331
  - Clean up worktrees you didn't create (provenance check)
310
332
  - Run any step without the `<worktree-path>` argument
311
333
  - Auto-proceed past an undispositioned carried-open gap
312
- - Skip the guarded plan-doc removal before push on Option 2 when a plan doc was committed
334
+ - Skip the strip-and-salvage block before the Option 1 squash or the Option 2 push
335
+ - Delete the telemetry record (`.pi/gauntlet/telemetry/**` or the configured `telemetry.dir`) on any path
313
336
 
314
337
  **Always:**
315
338
  - Verify tests before offering options
339
+ - Print the `gauntlet-telemetry-salvage.mjs` output verbatim in the ship completion message
316
340
  - Derive `$PRIMARY` and `$FEATURE` from `<worktree-path>` before presenting the menu
317
341
  - Present exactly 4 options (or 3 for detached HEAD)
318
342
  - Get typed confirmation for Option 4
@@ -164,6 +164,16 @@ verification command may write to the tree while the Reviewer reads it):
164
164
  `<sha>` is the assessed `headRefOid`, `<url>` degrades to `unavailable` when
165
165
  absent. Provenance checks on `worktree_root`/`run_cwd` bind only to the local
166
166
  path.
167
+ - **Telemetry record:** run `node <bin>/gauntlet-telemetry-salvage.mjs --worktree
168
+ <provisioned path> --base origin/<baseRefName> --check` (`<bin>` = `<directory of
169
+ this skill's SKILL.md>/../../bin`). Detect-only: it never mutates. `present` / `no
170
+ telemetry run` / `never written` land in `## Evidence` as one line each. `stripped
171
+ <path> in <sha>` mints a blocking `P#` (`source_ref`:
172
+ `gauntlet-telemetry-salvage`) whose drafted fix is "run the salvage without
173
+ `--check`" - the spec and its telemetry record are deliverables that ship in the
174
+ squash. On a cell with no push row (fork overlay, report-only states) the same
175
+ finding is a non-blocking follow-up instead: the record stays recoverable from the
176
+ PR head ref after merge, and blocking would stop a ship the gate cannot repair.
167
177
  - **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
168
178
  paraphrased. Any authored summary is labeled as a summary and never substitutes for
169
179
  `raw_tail`.
@@ -242,7 +252,13 @@ head (fixes pushed first); explicit selection with a head compare-and-swap that
242
252
  passes. A merge selection while any precondition fails is refused, naming the failing
243
253
  precondition, and the menu re-renders - never a dead end, never a silent merge. Merge
244
254
  always executes as `gh pr merge --match-head-commit <assessed-sha>`; push and merge
245
- are never bundled into one selection.
255
+ are never bundled into one selection, with one scoped exception: the selected merge
256
+ course first runs `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned
257
+ path> --base origin/<baseRefName>` (no `--check`). `present` -> merge as-is. `restored
258
+ <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
+ failed` -> merge proceeds, the reason is printed, and the follow-up names recovery
261
+ from the PR head ref.
246
262
 
247
263
  **Consent menu** (deterministic - this table is the golden-scenario oracle):
248
264
 
@@ -364,7 +380,8 @@ Actions (compose freely in the custom row):
364
380
  reviewed doc edits exist
365
381
  in the worktree)
366
382
  merge-squash | merge-commit (preconditions per Verdict;
367
- never bundled with a push)
383
+ never bundled with a push,
384
+ except the telemetry: restore commit)
368
385
  request-changes | review-comment | approve (approve: never own PR)
369
386
  reply <C#s> post drafted thread replies
370
387
  tracker <act> tracker action (only when a tracker tool resolved)
@@ -474,8 +491,11 @@ The menu is a state machine, not a one-shot report:
474
491
  orchestrator owns commit, gate, and push - never a child. Every dispatched
475
492
  child gets `cwd` = the PR worktree, **`worktree: true` forbidden** (a separate
476
493
  isolated worktree breaks the shared-tree contract - see Inline-first
477
- execution); is **edit-only, no git commands, no verification runs**; and its
478
- task is that batch's `P#` lines **plus the drafted edit already keyed to each
494
+ execution); is **edit-only, no git commands, no verification runs**, and must
495
+ never delete `.pi/gauntlet/telemetry/**` or anything under the configured
496
+ telemetry dir (a fix that "cleans up" the run's telemetry record is a defect in
497
+ the fix - the record is a deliverable); and its task is that batch's `P#` lines
498
+ **plus the drafted edit already keyed to each
479
499
  ID** in `## Drafted fixes / review` - the child applies the consented payload,
480
500
  it does not re-solve the finding. Below the cutoff (<= 2 worktree-fixable findings), the orchestrator
481
501
  applies inline instead of dispatching - the no-cohort path stays available at
@@ -486,7 +506,11 @@ The menu is a state machine, not a one-shot report:
486
506
  Once every dispatched/inline batch returns, the orchestrator commits the golden
487
507
  course as one local commit set - the code fixes plus any already-applied
488
508
  reviewed doc edits selected alongside them (one commit, or one per batch
489
- 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). **On
509
+ 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
+ `node <bin>/gauntlet-telemetry-salvage.mjs --worktree <provisioned path> --base
511
+ origin/<baseRefName>` (no `--check`); a `restored` commit rides the wave's single
512
+ push and the pushed SHA becomes the assessed head under the course's-own-push rule
513
+ in step 1. Print its stdout in the re-rendered report's `## Evidence`. **On
490
514
  green**, push **once**; gate and push are per-wave invariants, never per-fix or
491
515
  per-batch. **On red**, do not push: leave the commit(s) local, re-render with
492
516
  the unresolved `P#`s still open, and warn that unpushed fix commits sit in the