pi-gauntlet 5.6.2 → 5.8.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.
@@ -0,0 +1,686 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, test } from "node:test";
6
+ import registerTelemetry, { realFs, replayBranch, type Deps, type GitResult } from "./telemetry.ts";
7
+ import { DEFAULT_TELEMETRY_BUCKETS } from "./lib/gauntlet-settings.ts";
8
+ import { emptyAccumulators, parseRecord, serializeRecord } from "./lib/telemetry-record.ts";
9
+ import { guardReason } from "./lib/telemetry-ship.ts";
10
+
11
+
12
+ const originalDepth = process.env.PI_SUBAGENT_DEPTH;
13
+ process.env.PI_SUBAGENT_DEPTH = "0";
14
+ const tempDirs: string[] = [];
15
+ after(() => {
16
+ for (const d of tempDirs) rmSync(d, { recursive: true, force: true });
17
+ if (originalDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
18
+ else process.env.PI_SUBAGENT_DEPTH = originalDepth;
19
+ });
20
+
21
+ type Handler = (event: any, ctx: any) => unknown;
22
+
23
+ function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[]) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
24
+ const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
+ tempDirs.push(root);
26
+ mkdirSync(join(root, "doc/specs"), { recursive: true });
27
+ mkdirSync(join(root, "doc/plans"), { recursive: true });
28
+ const handlers = new Map<string, Handler[]>();
29
+ const gitCalls: string[][] = [];
30
+ const readFileCalls: string[] = [];
31
+ let clock = Date.parse("2026-09-17T10:00:00Z");
32
+ const deps: Deps = {
33
+ fs: { ...realFs, readFile: (p) => { readFileCalls.push(p); return realFs.readFile(p); } },
34
+ now: () => new Date((clock += 1000)).toISOString().replace(/\.\d{3}Z$/, "Z"),
35
+ git: async (args, cwd) => {
36
+ gitCalls.push(args);
37
+ const forced = o.gitFail?.(args);
38
+ if (forced) return forced;
39
+ if (args[0] === "rev-parse" && args.includes("--show-toplevel")) {
40
+ const toplevel = o.cwdSub && cwd === join(root, o.cwdSub) ? root : cwd;
41
+ return { code: 0, stdout: toplevel + "\n", stderr: "" };
42
+ }
43
+ if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "gh-33\n", stderr: "" };
44
+ if (args[0] === "config") return { code: 0, stdout: (args[1] === "user.name" ? "Tester" : "t@example.com") + "\n", stderr: "" };
45
+ if (args[0] === "ls-files") return { code: 0, stdout: "", stderr: "" };
46
+ return { code: 0, stdout: "", stderr: "" };
47
+ },
48
+ 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" } }),
49
+ };
50
+ let branch = o.branch ?? [];
51
+ const ctx = {
52
+ cwd: o.cwdSub ? join(root, o.cwdSub) : root,
53
+ sessionManager: { getBranch: () => branch, getSessionId: () => o.sessionId ?? "s1" },
54
+ model: o.model ?? { provider: "p", id: "m" },
55
+ thinkingLevel: o.thinkingLevel ?? "high",
56
+ getContextUsage: () => ({ tokens: o.contextTokens === undefined ? 1234 : o.contextTokens, contextWindow: 200000, percent: 1 }),
57
+ };
58
+ const pi = { on: (name: string, h: Handler) => handlers.set(name, [...(handlers.get(name) ?? []), h]) };
59
+ registerTelemetry(pi as any, deps);
60
+ const emit = async (name: string, event: unknown) => {
61
+ const out: unknown[] = [];
62
+ for (const h of handlers.get(name) ?? []) out.push(await h(event, ctx));
63
+ return out;
64
+ };
65
+ const recordPath = (spec = "doc/specs/a.md") => join(ctx.cwd, ".pi/gauntlet/telemetry", spec.replace(/\.md$/, ".yaml"));
66
+ const readRecord = (spec?: string) => parseRecord(readFileSync(recordPath(spec), "utf8"))!;
67
+ const commits = () => gitCalls.filter((a) => a[0] === "commit");
68
+ const phaseResult = (action: string, phases: Record<string, unknown>) => emit("tool_result", { toolName: "phase_tracker", toolCallId: "pt", input: {}, content: [], isError: false, details: { action, phases } });
69
+ const writeSpec = async (rel: string, body = "# Spec\n") => {
70
+ writeFileSync(join(root, rel), body);
71
+ await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
72
+ return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
73
+ };
74
+ return { root, handlers, emit, gitCalls, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
75
+ }
76
+
77
+ const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
78
+
79
+ test("enabled: false registers handlers that no-op", async () => {
80
+ const h = harness({ enabled: false });
81
+ await h.emit("session_start", { type: "session_start", reason: "startup" });
82
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
83
+ await h.writeSpec("doc/specs/a.md");
84
+ assert.equal(existsSync(h.recordPath()), false);
85
+ assert.equal(h.commits().length, 0);
86
+ });
87
+
88
+ test("PI_SUBAGENT_DEPTH=1 no-ops every handler", async () => {
89
+ process.env.PI_SUBAGENT_DEPTH = "1";
90
+ try {
91
+ const h = harness();
92
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
93
+ await h.writeSpec("doc/specs/a.md");
94
+ assert.equal(existsSync(h.recordPath()), false);
95
+ } finally {
96
+ process.env.PI_SUBAGENT_DEPTH = "0";
97
+ }
98
+ });
99
+
100
+ test("bind on first spec write flushes buffered phase event, mints run_id, commits at checkpoints only", async () => {
101
+ const h = harness();
102
+ await h.emit("session_start", { type: "session_start", reason: "startup" });
103
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
104
+ assert.equal(existsSync(h.recordPath()), false);
105
+ await h.writeSpec("doc/specs/a.md", "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing\n");
106
+ const rec = h.readRecord();
107
+ assert.match(rec.run_id, /^[0-9a-f-]{36}$/);
108
+ assert.equal(rec.spec, "doc/specs/a.md");
109
+ assert.equal(rec.branch, "gh-33");
110
+ assert.deepEqual(rec.sessions, ["s1"]);
111
+ assert.deepEqual(rec.author, { name: "Tester", email: "t@example.com" });
112
+ assert.deepEqual(rec.versions, { pi: "0.85.1" });
113
+ assert.deepEqual(rec.agent_overrides, { implementer: { model: "p/x" } });
114
+ assert.equal(rec.events[0].kind, "phase");
115
+ assert.equal(rec.created_at, rec.events[0].ts);
116
+ assert.equal((rec.events[0] as any).model, "p/m");
117
+ assert.equal((rec.events[0] as any).thinking, "high");
118
+ assert.equal(rec.derived.spec_writes.brainstorm?.count, 1);
119
+ const afterBind = h.commits().length;
120
+ assert.equal(afterBind, 1, "binding flushes the buffered start checkpoint once");
121
+ await h.phaseResult("substep", P({ brainstorm: "in_progress" }));
122
+ await h.phaseResult("status", P({ brainstorm: "in_progress" }));
123
+ assert.equal(h.commits().length, afterBind);
124
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
125
+ assert.equal(h.commits().length, afterBind + 1);
126
+ const rec2 = h.readRecord();
127
+ assert.equal(rec2.approved_at, rec2.events.at(-1)!.ts);
128
+ const commit = h.commits().at(-1)!;
129
+ assert.deepEqual(commit.slice(0, 4), ["commit", "-q", "-m", "telemetry: doc/specs/a.md"]);
130
+ assert.ok(commit.includes("--") && commit.at(-1)!.endsWith(".pi/gauntlet/telemetry/doc/specs/a.yaml"));
131
+ assert.ok(h.gitCalls.some((a) => a[0] === "add" && a[1] === "-f"));
132
+ });
133
+
134
+ test("bind derives approved_at from a buffered brainstorm completion", async () => {
135
+ const h = harness();
136
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
137
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
138
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
139
+ await h.writeSpec("doc/specs/a.md");
140
+ const rec = h.readRecord();
141
+ assert.equal(rec.events.length, 3);
142
+ assert.equal(rec.approved_at, rec.events[1].ts);
143
+ });
144
+
145
+ test("session shutdown before binding writes nothing", async () => {
146
+ const h = harness();
147
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
148
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
149
+ assert.equal(existsSync(join(h.root, ".pi")), false);
150
+ assert.equal(h.commits().length, 0);
151
+ });
152
+
153
+ test("replayBranch uses the last successful phase snapshot, plan pass, and paired successful spec write", () => {
154
+ const successfulPhases = P({ brainstorm: "complete", plan: "in_progress" });
155
+ const ignoredPhases = P({ brainstorm: "complete", plan: "complete" });
156
+ const replay = replayBranch([
157
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { phases: successfulPhases } } },
158
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", details: { phases: ignoredPhases, error: "failed" } } },
159
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: "/repo/doc/specs/old.md" } } },
160
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "fail", specPath: "/repo/doc/specs/ignored.md" } } },
161
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", details: { status: "pass", specPath: "/repo/doc/specs/a.md" } } },
162
+ { type: "message", message: { role: "assistant", content: [
163
+ { type: "toolCall", id: "w1", name: "write", arguments: { path: "doc/specs/a.md" } },
164
+ { type: "toolCall", id: "w2", name: "write", arguments: { path: "README.md" } },
165
+ { type: "toolCall", id: "w3", name: "write", arguments: { path: "doc/specs/failed.md" } },
166
+ ] } },
167
+ { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w1", isError: false } },
168
+ { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w2", isError: false } },
169
+ { type: "message", message: { role: "toolResult", toolName: "write", toolCallId: "w3", isError: true } },
170
+ ]);
171
+ assert.deepEqual(replay.phases, successfulPhases);
172
+ assert.equal(replay.planCheckSpec, "/repo/doc/specs/a.md");
173
+ assert.equal(replay.lastSpecWrite, "doc/specs/a.md");
174
+ });
175
+
176
+ test("session_start replay binds from plan_check details with no spec write, extends sessions, preserves run_id", async () => {
177
+ const first = harness();
178
+ await first.phaseResult("start", P({ brainstorm: "in_progress" }));
179
+ await first.writeSpec("doc/specs/a.md");
180
+ const runId = first.readRecord().run_id;
181
+ const second = harness({ sessionId: "s2", branch: [
182
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", toolCallId: "1", isError: false, details: { action: "start", phases: P({ brainstorm: "skipped", plan: "in_progress" }) } } },
183
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", toolCallId: "2", isError: false, details: { status: "pass", planPath: join(first.root, "doc/plans/a.md"), specPath: join(first.root, "doc/specs/a.md"), planSha256: "x", specSha256: "y" } } },
184
+ ] });
185
+ // point the second harness at the first root
186
+ second.ctx.cwd = first.root;
187
+ await second.emit("session_start", { type: "session_start", reason: "resume" });
188
+ const rec = parseRecord(readFileSync(first.recordPath(), "utf8"))!;
189
+ assert.equal(rec.run_id, runId);
190
+ assert.deepEqual(rec.sessions, ["s1", "s2"]);
191
+ assert.equal(rec.events.length, 1, "replay emits no duplicate events");
192
+ });
193
+
194
+ test("rename at spec-writing: write to a new spec while the bound one is a draft rebinds, moves the record, commits both paths", async () => {
195
+ const h = harness();
196
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
197
+ await h.writeSpec("doc/specs/draft.md", "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing\n");
198
+ const runId = h.readRecord("doc/specs/draft.md").run_id;
199
+ await h.writeSpec("doc/specs/final.md", "# Final\n");
200
+ assert.equal(existsSync(h.recordPath("doc/specs/draft.md")), false);
201
+ const rec = h.readRecord("doc/specs/final.md");
202
+ assert.equal(rec.run_id, runId);
203
+ assert.equal(rec.spec, "doc/specs/final.md");
204
+ assert.ok(rec.events.some((e) => e.kind === "spec_renamed"));
205
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
206
+ const commit = h.commits().at(-1)!;
207
+ assert.ok(commit.some((a) => a.endsWith("draft.yaml")) && commit.some((a) => a.endsWith("final.yaml")));
208
+ });
209
+
210
+ test("rename refuses an untracked destination collision and keeps the draft binding", async () => {
211
+ const h = harness();
212
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
213
+ await h.writeSpec("doc/specs/draft.md", "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing\n");
214
+ writeFileSync(h.recordPath("doc/specs/final.md"), "existing\n");
215
+ await h.writeSpec("doc/specs/final.md", "# Final\n");
216
+ const rec = h.readRecord("doc/specs/draft.md");
217
+ assert.equal(rec.spec, "doc/specs/draft.md");
218
+ assert.equal(readFileSync(h.recordPath("doc/specs/final.md"), "utf8"), "existing\n");
219
+ assert.deepEqual(rec.events.filter((e) => e.kind === "warning").map((e: any) => e.message), ["record move skipped: .pi/gauntlet/telemetry/doc/specs/final.yaml already exists"]);
220
+ });
221
+
222
+ test("rename refuses a tracked destination collision and keeps the draft binding", async () => {
223
+ const h = harness({ gitFail: (args) => args[0] === "ls-files" && args.at(-1)?.endsWith("final.yaml") ? { code: 0, stdout: args.at(-1)! + "\n", stderr: "" } : undefined });
224
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
225
+ await h.writeSpec("doc/specs/draft.md", "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing\n");
226
+ writeFileSync(h.recordPath("doc/specs/final.md"), "tracked existing\n");
227
+ await h.writeSpec("doc/specs/final.md", "# Final\n");
228
+ const rec = h.readRecord("doc/specs/draft.md");
229
+ assert.equal(rec.spec, "doc/specs/draft.md");
230
+ assert.equal(readFileSync(h.recordPath("doc/specs/final.md"), "utf8"), "tracked existing\n");
231
+ assert.ok(rec.events.some((e) => e.kind === "warning" && (e as any).message.endsWith("already exists")));
232
+ });
233
+
234
+ test("telemetry resolver warning is emitted once while its fallback directory is used", async () => {
235
+ const message = "telemetry.dir must be a non-empty path relative to the git toplevel; using the default";
236
+ const h = harness({ telemetryWarning: message, telemetryDir: ".pi/gauntlet/telemetry" });
237
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
238
+ await h.writeSpec("doc/specs/a.md");
239
+ await h.writeSpec("doc/specs/a.md", "# Again\n");
240
+ assert.equal(existsSync(h.recordPath()), true);
241
+ assert.deepEqual(h.readRecord().events.filter((e) => e.kind === "warning").map((e: any) => e.message), [message]);
242
+ });
243
+
244
+ test("phase reset unbinds; the next write to the same path continues the same record", async () => {
245
+ const h = harness();
246
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
247
+ await h.writeSpec("doc/specs/a.md");
248
+ const runId = h.readRecord().run_id;
249
+ await h.phaseResult("reset", P());
250
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
251
+ await h.writeSpec("doc/specs/a.md");
252
+ const rec = h.readRecord();
253
+ assert.equal(rec.run_id, runId);
254
+ assert.deepEqual(rec.events.map((e) => e.kind), ["phase", "phase", "phase"]);
255
+ assert.equal((rec.events[1] as any).action, "reset");
256
+ });
257
+
258
+ test("commit failure becomes a warning event and is retried at the next checkpoint; not-a-git-repo skips commits", async () => {
259
+ let fail = true;
260
+ const h = harness({ gitFail: (args) => (args[0] === "commit" && fail ? { code: 1, stdout: "", stderr: "fatal: hook rejected\nmore" } : undefined) });
261
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
262
+ await h.writeSpec("doc/specs/a.md");
263
+ let rec = h.readRecord();
264
+ assert.deepEqual(rec.events.filter((e) => e.kind === "warning").map((e: any) => e.message), ["git commit failed: fatal: hook rejected"]);
265
+ fail = false;
266
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
267
+ assert.equal(h.commits().length, 2);
268
+ const nogit = harness({ gitFail: (args) => (args[0] === "rev-parse" ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined) });
269
+ await nogit.phaseResult("start", P({ brainstorm: "in_progress" }));
270
+ await nogit.writeSpec("doc/specs/a.md");
271
+ rec = nogit.readRecord();
272
+ assert.equal(nogit.commits().length, 0);
273
+ assert.equal(rec.events.filter((e) => e.kind === "warning").length, 1);
274
+ });
275
+
276
+ test("second session rehydrates all derived accumulators with exactly total and live blocks", async () => {
277
+ const h = harness({ contextTokens: 4321 });
278
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
279
+ await h.writeSpec("doc/specs/a.md");
280
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
281
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
282
+ await h.emit("input", { type: "input", text: "approve", source: "interactive" });
283
+ await h.emit("message_end", { type: "message_end", message: { role: "assistant", usage: usage(10, 2) } });
284
+ await h.emit("tool_result", subagentResult([{ agent: "implementer", exitCode: 0, model: "p/i", usage: usage(20, 3) }]));
285
+ await h.emit("turn_end", { type: "turn_end", turnIndex: 1, message: {}, toolResults: [] });
286
+ await h.emit("session_compact", { type: "session_compact", reason: "threshold" });
287
+ await h.writeSpec("doc/specs/a.md", "# amended\n");
288
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
289
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "in_progress" }));
290
+ await h.emit("tool_result", { toolName: "plan_tracker", toolCallId: "pt", input: {}, content: [], isError: false, details: { action: "update", tasks: [{ name: "done", status: "complete" }, { name: "skip", status: "skipped" }] } });
291
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
292
+ let rec = h.readRecord();
293
+ assert.deepEqual(Object.keys(rec.accumulators), ["total"]);
294
+ rec.accumulators.dead = { ...emptyAccumulators(), amendments: 5 };
295
+ writeFileSync(h.recordPath(), serializeRecord(rec));
296
+
297
+ const h2 = harness({ sessionId: "s2" });
298
+ h2.ctx.cwd = h.root;
299
+ await h2.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
300
+ await h2.writeSpec("doc/specs/a.md");
301
+ rec = h2.readRecord();
302
+ assert.deepEqual(Object.keys(rec.accumulators).sort(), ["s2", "total"]);
303
+ assert.equal(rec.derived.phases.plan?.user_messages, 1);
304
+ assert.equal(rec.derived.phases.plan?.peak_context, 4321);
305
+ assert.equal(rec.derived.phases.plan?.compactions, 1);
306
+ assert.deepEqual(rec.derived.personas.implementer?.tokens, { input: 20, output: 3, cache_read: 0, cache_write: 0, cost: 0 });
307
+ assert.deepEqual(rec.derived.plan, { tasks: 2, complete: 1, failed: 0, skipped: 1 });
308
+ assert.equal(rec.derived.amendments, 7);
309
+ });
310
+
311
+ 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 } });
312
+
313
+ test("pre-binding observations merge into the bound record in event order", async () => {
314
+ const h = harness();
315
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
316
+ await h.emit("tool_result", subagentResult([{ agent: "scout", exitCode: 0, usage: usage(10) }]));
317
+ await h.emit("model_select", { type: "model_select", model: { provider: "p", id: "next" } });
318
+ await h.emit("tool_result", { toolName: "plan_check", toolCallId: "pc", input: {}, content: [], isError: false, details: { status: "fail" } });
319
+ const planResult = (status: string, id: string) => h.emit("tool_result", { toolName: "plan_tracker", toolCallId: id, input: { action: "update" }, content: [], isError: false, details: { action: "update", tasks: [{ name: "a", status }] } });
320
+ await planResult("complete", "pt1");
321
+ await planResult("in_progress", "pt2");
322
+ await h.writeSpec("doc/specs/a.md");
323
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
324
+ const rec = h.readRecord();
325
+ assert.equal(rec.derived.personas.scout.dispatches, 1);
326
+ assert.equal(rec.derived.personas.scout.tokens?.input, 10);
327
+ assert.equal(rec.derived.gates.plan_rounds, 1);
328
+ assert.equal(rec.derived.gates.task_reopens, 1);
329
+ assert.equal(rec.events.filter((e) => e.kind === "gate" && (e as any).gate === "task_reopen").length, 1);
330
+ assert.deepEqual(rec.events.map((e) => e.kind), ["phase", "dispatch", "config_change", "plan_check", "gate"]);
331
+ });
332
+
333
+ test("pre-binding observations are discarded on shutdown without a telemetry file", async () => {
334
+ const h = harness();
335
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
336
+ await h.emit("tool_result", subagentResult([{ agent: "scout", exitCode: 0, usage: usage(10) }]));
337
+ await h.emit("model_select", { type: "model_select", model: { provider: "p", id: "next" } });
338
+ await h.emit("tool_result", { toolName: "plan_check", toolCallId: "pc", input: {}, content: [], isError: false, details: { status: "fail" } });
339
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
340
+ assert.equal(existsSync(join(h.root, ".pi/gauntlet/telemetry")), false);
341
+ });
342
+
343
+ async function boundInPlan(over: Parameters<typeof harness>[0] = {}) {
344
+ const h = harness(over);
345
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
346
+ await h.writeSpec("doc/specs/a.md");
347
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
348
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
349
+ return h;
350
+ }
351
+
352
+ const subagentResult = (results: unknown[], content = "") => ({ toolName: "subagent", toolCallId: "sa", input: {}, content: [{ type: "text", text: content }], isError: false, details: { results } });
353
+
354
+ test("subagent results: dispatch events, persona tokens, spec_rounds, reviews, conformance_loops; async results: [] contribute nothing", async () => {
355
+ const h = await boundInPlan();
356
+ await h.emit("tool_result", subagentResult([
357
+ { agent: "implementer", exitCode: 0, model: "p/impl:high", usage: usage(100, 10, 1) },
358
+ { agent: "implementer", exitCode: 1, model: "p/impl:high", usage: usage(50, 5, 0.5) },
359
+ { agent: "spec-summarizer", exitCode: 0, model: "p/s", usage: usage(1) },
360
+ { agent: "code-reviewer", exitCode: 0, model: "p/r", usage: usage(1) },
361
+ ], "- [major] a\n- [minor] b\n- [minor] c"));
362
+ await h.emit("tool_result", subagentResult([]));
363
+ const rec = h.readRecord();
364
+ const dispatches = rec.events.filter((e) => e.kind === "dispatch") as any[];
365
+ assert.equal(dispatches.length, 4);
366
+ assert.deepEqual(dispatches[0], { ts: dispatches[0].ts, session: "s1", phase: "plan", kind: "dispatch", agent: "implementer", model: "p/impl:high" });
367
+ assert.equal(dispatches[1].exit, 1);
368
+ assert.deepEqual(rec.derived.personas.implementer, { dispatches: 2, models: ["p/impl:high"], tokens: { input: 150, output: 15, cache_read: 0, cache_write: 0, cost: 1.5 } });
369
+ assert.equal(rec.derived.gates.spec_rounds, 1);
370
+ assert.deepEqual(rec.derived.reviews?.["code-reviewer"], { dispatches: 1, nonzero_exit: 0, findings: { blocker: 0, major: 1, minor: 2 } });
371
+ assert.equal(rec.derived.conformance_loops, 0);
372
+ assert.deepEqual(rec.derived.phases.plan?.tokens, { input: 152, output: 15, cache_read: 0, cache_write: 0, cost: 1.5 });
373
+ });
374
+
375
+ test("conformance-reviewer during verify counts loops and retains the latest open gap count", async () => {
376
+ const h = await boundInPlan();
377
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
378
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "in_progress" }));
379
+ await h.emit("tool_result", subagentResult([{ agent: "conformance-reviewer", exitCode: 0, usage: usage(1) }], "Conformance verdict: GAPS\nG1:\n verdict: MISSING\nG2:\n verdict: PARTIAL"));
380
+ assert.equal(h.readRecord().derived.conformance_open_gaps, 2);
381
+ await h.emit("tool_result", subagentResult([{ agent: "conformance-reviewer", exitCode: 2, usage: usage(1) }], "Conformance verdict: CONFORMS"));
382
+ const rec = h.readRecord();
383
+ assert.equal(rec.derived.conformance_loops, 2);
384
+ assert.equal(rec.derived.conformance_open_gaps, 0);
385
+ assert.equal(rec.derived.reviews?.["conformance-reviewer"]?.nonzero_exit, 1);
386
+ });
387
+
388
+ test("message_end sums assistant usage into the in-progress phase (unphased outside); turn_end tracks peak_context; compaction counts", async () => {
389
+ const h = await boundInPlan({ contextTokens: 5000 });
390
+ await h.emit("message_end", { type: "message_end", message: { role: "assistant", usage: usage(10, 2, 0.1) } });
391
+ await h.emit("message_end", { type: "message_end", message: { role: "user" } });
392
+ await h.emit("message_end", { type: "message_end", message: { role: "assistant", usage: usage(0) } });
393
+ await h.emit("turn_end", { type: "turn_end", turnIndex: 1, message: {}, toolResults: [] });
394
+ await h.emit("session_compact", { type: "session_compact", reason: "threshold" });
395
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
396
+ await h.emit("message_end", { type: "message_end", message: { role: "assistant", usage: usage(3) } });
397
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
398
+ const rec = h.readRecord();
399
+ assert.deepEqual(rec.derived.phases.plan?.tokens, { input: 10, output: 2, cache_read: 0, cache_write: 0, cost: 0.1 });
400
+ assert.equal(rec.derived.phases.plan?.peak_context, 5000);
401
+ assert.equal(rec.derived.phases.plan?.compactions, 1);
402
+ assert.deepEqual(rec.derived.phases.unphased?.tokens, { input: 3, output: 0, cache_read: 0, cache_write: 0, cost: 0 });
403
+ });
404
+
405
+ test("getContextUsage null tokens -> peak_context omitted", async () => {
406
+ const h = await boundInPlan({ contextTokens: null });
407
+ await h.emit("turn_end", { type: "turn_end", turnIndex: 1, message: {}, toolResults: [] });
408
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
409
+ assert.equal("peak_context" in (h.readRecord().derived.phases.plan ?? {}), false);
410
+ });
411
+
412
+ test("input counts user_messages only for plan|implement|verify|ship, only interactive non-slash text", async () => {
413
+ const h = harness();
414
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
415
+ await h.writeSpec("doc/specs/a.md");
416
+ await h.emit("input", { type: "input", text: "hello", source: "interactive" });
417
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
418
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
419
+ await h.emit("input", { type: "input", text: "approve", source: "interactive" });
420
+ await h.emit("input", { type: "input", text: "/skill:x", source: "interactive" });
421
+ await h.emit("input", { type: "input", text: "auto", source: "extension" });
422
+ await h.emit("input", { type: "input", text: "rpc", source: "rpc" });
423
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
424
+ const rec = h.readRecord();
425
+ assert.equal(rec.derived.phases.plan?.user_messages, 2);
426
+ assert.equal("user_messages" in (rec.derived.phases.brainstorm ?? {}), false);
427
+ });
428
+
429
+ test("model_select / thinking_level_select mid-phase emit config_change", async () => {
430
+ const h = await boundInPlan();
431
+ await h.emit("model_select", { type: "model_select", model: { provider: "q", id: "n" }, previousModel: undefined, source: "set" });
432
+ await h.emit("thinking_level_select", { type: "thinking_level_select", level: "low", previousLevel: "high" });
433
+ const cc = h.readRecord().events.filter((e) => e.kind === "config_change") as any[];
434
+ assert.deepEqual(cc.map((e) => [e.model, e.thinking]), [["q/n", undefined], [undefined, "low"]]);
435
+ });
436
+
437
+ test("plan_tracker: reopen counts task_reopens; all-terminal snapshot auto-completes implement and records plan totals", async () => {
438
+ const h = await boundInPlan();
439
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
440
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "in_progress" }));
441
+ const pt = (action: string, tasks: { name: string; status: string }[], id = "pt1") => h.emit("tool_result", { toolName: "plan_tracker", toolCallId: id, input: { action }, content: [], isError: false, details: { action, tasks } });
442
+ await pt("init", [{ name: "a", status: "pending" }, { name: "b", status: "pending" }]);
443
+ await pt("update", [{ name: "a", status: "complete" }, { name: "b", status: "pending" }]);
444
+ await pt("update", [{ name: "a", status: "in_progress" }, { name: "b", status: "pending" }]);
445
+ let rec = h.readRecord();
446
+ assert.equal(rec.derived.gates.task_reopens, 1);
447
+ assert.ok(rec.events.some((e) => e.kind === "gate" && (e as any).gate === "task_reopen"));
448
+ await pt("update", [{ name: "a", status: "complete" }, { name: "b", status: "skipped" }]);
449
+ rec = h.readRecord();
450
+ assert.deepEqual(rec.derived.plan, { tasks: 2, complete: 1, failed: 0, skipped: 1 });
451
+ assert.ok(rec.events.some((e) => e.kind === "phase" && (e as any).action === "complete" && (e as any).name === "implement"));
452
+ assert.equal(rec.derived.phases.implement?.completed_at !== undefined, true);
453
+ });
454
+
455
+ test("grant_fix_rounds emits a gate event and counts fix_round_grants", async () => {
456
+ const h = await boundInPlan();
457
+ await h.emit("tool_result", { toolName: "phase_tracker", toolCallId: "g", input: {}, content: [], isError: false, details: { action: "grant_fix_rounds", rounds: 2, phases: P({ brainstorm: "complete", plan: "in_progress" }) } });
458
+ const rec = h.readRecord();
459
+ assert.equal(rec.derived.gates.fix_round_grants, 1);
460
+ assert.deepEqual(rec.events.filter((e) => e.kind === "gate").map((e: any) => [e.gate, e.rounds]), [["fix_round_grant", 2]]);
461
+ });
462
+
463
+ test("spec writes: spec_writes per phase, amendments coalesce until an input event, post-ship edits warn, banner edits count nothing, links refresh", async () => {
464
+ const h = await boundInPlan();
465
+ await h.writeSpec("doc/specs/a.md", "# A\n\n> **Supersedes:** [doc/specs/old.md](./old.md)\n");
466
+ await h.writeSpec("doc/specs/a.md", "# A v3\n");
467
+ let rec = h.readRecord();
468
+ assert.equal(rec.derived.spec_writes.plan?.count, 2);
469
+ assert.equal(rec.derived.spec_writes.brainstorm?.count, 1);
470
+ assert.equal(rec.derived.amendments, 1, "two writes with no input between them = one amendment");
471
+ assert.equal(rec.supersedes, undefined);
472
+ await h.emit("input", { type: "input", text: "change x", source: "interactive" });
473
+ await h.writeSpec("doc/specs/a.md", "# A v4\n");
474
+ assert.equal(h.readRecord().derived.amendments, 2);
475
+ // predecessor banner edit on another spec: supersedes gains it, nothing else counted
476
+ writeFileSync(join(h.root, "doc/specs/pred.md"), "# Pred\n\n> **Superseded by:** [doc/specs/a.md](./a.md) - fully\n");
477
+ const editInput = { path: "doc/specs/pred.md", edits: [{ oldText: "# Pred\n", newText: "# Pred\n\n> **Superseded by:** [doc/specs/a.md](./a.md) - fully\n" }] };
478
+ await h.emit("tool_result", { toolName: "edit", toolCallId: "e", input: editInput, content: [], isError: false, details: { diff: "", patch: "" } });
479
+ rec = h.readRecord();
480
+ assert.deepEqual(rec.supersedes, ["doc/specs/pred.md"]);
481
+ assert.equal(rec.spec, "doc/specs/a.md");
482
+ await h.writeSpec("doc/specs/a.md", "# A without banners\n");
483
+ assert.deepEqual(h.readRecord().supersedes, ["doc/specs/pred.md"]);
484
+ // post-ship edit warns and counts
485
+ rec.shipped_at = "2026-09-17T12:00:00Z";
486
+ writeFileSync(h.recordPath(), serializeRecord(rec));
487
+ const h2 = harness({ sessionId: "s2" });
488
+ h2.ctx.cwd = h.root;
489
+ await h2.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
490
+ const out = await h2.writeSpec("doc/specs/a.md", "# A v5\n");
491
+ const patched = out.find(Boolean) as { content: { type: string; text: string }[] };
492
+ assert.match(patched.content[0].text, /spec shipped at 2026-09-17T12:00:00Z; write a follow-up spec that supersedes it/);
493
+ assert.equal(h2.readRecord().derived.spec_edits_after_ship, 1);
494
+ });
495
+
496
+ test("rehydration drops a body-only supersedes link removed by the new session's first write", async () => {
497
+ const first = await boundInPlan();
498
+ await first.writeSpec("doc/specs/a.md", "# A\n\n> **Supersedes:** doc/specs/old.md\n");
499
+
500
+ const second = harness({ sessionId: "s2" });
501
+ second.ctx.cwd = first.root;
502
+ await second.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
503
+ const body = "# A without banners\n";
504
+ writeFileSync(join(first.root, "doc/specs/a.md"), body);
505
+ await second.emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: "doc/specs/a.md", content: body } });
506
+ await second.emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: "doc/specs/a.md", content: body }, content: [], isError: false, details: undefined });
507
+
508
+ assert.equal(second.readRecord().supersedes, undefined);
509
+ });
510
+
511
+ test("rehydration does not read a traversal-valued supersedes link outside the repository", async () => {
512
+ const first = await boundInPlan();
513
+ await first.writeSpec("doc/specs/a.md", "# A\n\n> **Supersedes:** ../../../../dev/zero\n");
514
+
515
+ const second = harness({ sessionId: "s2", branch: [
516
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", toolCallId: "2", isError: false, details: { status: "pass", specPath: join(first.root, "doc/specs/a.md") } } },
517
+ ] });
518
+ second.ctx.cwd = first.root;
519
+ await second.emit("session_start", { type: "session_start", reason: "resume" });
520
+
521
+ assert.ok(second.readFileCalls.every((p) => p === first.root || p.startsWith(first.root + "/")));
522
+ assert.deepEqual(second.readRecord().supersedes, ["../../../../dev/zero"]);
523
+ });
524
+
525
+ test("rehydration retains a predecessor link that also appeared in the bound spec body", async () => {
526
+ const first = await boundInPlan();
527
+ const predecessor = "# Old\n\n> **Superseded by:** [doc/specs/a.md](./a.md)\n";
528
+ writeFileSync(join(first.root, "doc/specs/old.md"), predecessor);
529
+ await first.writeSpec("doc/specs/a.md", "# A\n\n> **Supersedes:** doc/specs/old.md\n");
530
+
531
+ const second = harness({ sessionId: "s2", branch: [
532
+ { type: "message", message: { role: "toolResult", toolName: "phase_tracker", toolCallId: "1", isError: false, details: { action: "start", phases: P({ brainstorm: "complete", plan: "in_progress" }) } } },
533
+ { type: "message", message: { role: "toolResult", toolName: "plan_check", toolCallId: "2", isError: false, details: { status: "pass", planPath: join(first.root, "doc/plans/a.md"), specPath: join(first.root, "doc/specs/a.md"), planSha256: "x", specSha256: "y" } } },
534
+ ] });
535
+ second.ctx.cwd = first.root;
536
+ await second.emit("session_start", { type: "session_start", reason: "resume" });
537
+ const body = "# A without banners\n";
538
+ writeFileSync(join(first.root, "doc/specs/a.md"), body);
539
+ await second.emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: "doc/specs/a.md", content: body } });
540
+ await second.emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: "doc/specs/a.md", content: body }, content: [], isError: false, details: undefined });
541
+
542
+ assert.deepEqual(second.readRecord().supersedes, ["doc/specs/old.md"]);
543
+ });
544
+
545
+ test("last matched test command during verify/ship records derived.tests from the bash result", async () => {
546
+ const h = await boundInPlan();
547
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
548
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "in_progress" }));
549
+ const bash = async (id: string, command: string, isError: boolean) => {
550
+ await h.emit("tool_call", { toolName: "bash", toolCallId: id, input: { command } });
551
+ await h.emit("tool_result", { toolName: "bash", toolCallId: id, input: { command }, content: [], isError, details: undefined });
552
+ };
553
+ await bash("b1", "cd x && npm test", true);
554
+ assert.deepEqual(h.readRecord().derived.tests, { command: "npm test", result: "fail" });
555
+ await bash("b2", "npm test", false);
556
+ await bash("b3", "ls", true);
557
+ assert.deepEqual(h.readRecord().derived.tests, { command: "npm test", result: "pass" });
558
+ });
559
+
560
+ const shipGit = (args: string[]): GitResult | undefined => {
561
+ if (args[0] === "rev-parse" && args.includes("--verify")) return args.includes("origin/HEAD") ? { code: 128, stdout: "", stderr: "" } : { code: 0, stdout: "main\n", stderr: "" };
562
+ if (args[0] === "merge-base") return { code: 0, stdout: "abc123\n", stderr: "" };
563
+ if (args[0] === "diff" && args.includes("--name-only")) return { code: 0, stdout: "extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n", stderr: "" };
564
+ if (args[0] === "diff" && args.includes("--numstat")) return { code: 0, stdout: "100\t5\textensions/telemetry.ts\n40\t0\textensions/telemetry.test.ts\n9\t9\tdoc/specs/a.md\n2\t1\tREADME.md\n", stderr: "" };
565
+ if (args[0] === "rev-list") return { code: 0, stdout: "9\n", stderr: "" };
566
+ return undefined;
567
+ };
568
+
569
+ async function boundInShip(gitFail?: (args: string[]) => GitResult | undefined) {
570
+ const h = harness({ gitFail: (args) => gitFail?.(args) ?? shipGit(args) });
571
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
572
+ await h.writeSpec("doc/specs/a.md");
573
+ await h.phaseResult("complete", P({ brainstorm: "complete" }));
574
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" }));
575
+ const bash = async (id: string, command: string, isError = false) => {
576
+ const commitsBefore = h.commits().length;
577
+ const blocked = await h.emit("tool_call", { toolName: "bash", toolCallId: id, input: { command } });
578
+ const commitsAtCall = h.commits().length;
579
+ await h.emit("tool_result", { toolName: "bash", toolCallId: id, input: { command }, content: [], isError, details: undefined });
580
+ return { blocked: blocked.find(Boolean), committedBeforeRun: commitsAtCall > commitsBefore };
581
+ };
582
+ return { ...h, bash };
583
+ }
584
+
585
+ test("ship attempt: shipped_at + diff flushed and committed before the bash command; git push then gh pr create yields one ship event; frozen afterwards", async () => {
586
+ const h = await boundInShip();
587
+ const r = await h.bash("s1", "git push -u origin HEAD");
588
+ assert.equal(r.committedBeforeRun, true);
589
+ let rec = h.readRecord();
590
+ assert.equal(rec.status, "shipped");
591
+ assert.ok(rec.shipped_at);
592
+ assert.deepEqual(rec.derived.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
593
+ assert.deepEqual(rec.derived.diff, { base: "abc123", commits: 9, buckets: { code: { files: 1, insertions: 100, deletions: 5 }, test: { files: 1, insertions: 40, deletions: 0 }, docs: { files: 1, insertions: 2, deletions: 1 } } });
594
+ assert.equal(rec.derived.gates.ship_option, "pr");
595
+ const shipEvents = rec.events.filter((e) => e.kind === "ship") as any[];
596
+ assert.deepEqual([shipEvents.length, shipEvents[0].option, shipEvents[0].command], [1, "pr", "git push -u origin HEAD"]);
597
+ const before = readFileSync(h.recordPath(), "utf8");
598
+ const commitsBefore = h.commits().length;
599
+ await h.bash("s2", "gh pr create --fill");
600
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "complete" }));
601
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
602
+ assert.equal(readFileSync(h.recordPath(), "utf8"), before, "frozen: no writes after a successful ship");
603
+ assert.equal(h.commits().length, commitsBefore);
604
+ });
605
+
606
+ test("ship command fails: ship_failed, terminal fields cleared, diff dropped, guard inactive, new attempt allowed", async () => {
607
+ const h = await boundInShip();
608
+ await h.bash("s1", "git merge --squash gh-33", true);
609
+ let rec = h.readRecord();
610
+ assert.equal(rec.status, "in_progress");
611
+ assert.equal(rec.shipped_at, undefined);
612
+ assert.equal(rec.derived.diff, undefined);
613
+ assert.equal(rec.derived.gates.ship_option, undefined);
614
+ assert.deepEqual(rec.events.slice(-2).map((e) => e.kind), ["ship", "ship_failed"]);
615
+ await h.bash("s2", "git merge --squash gh-33");
616
+ rec = h.readRecord();
617
+ assert.equal(rec.status, "shipped");
618
+ assert.equal(rec.derived.gates.ship_option, "squash");
619
+ });
620
+
621
+ test("discard: abandoned committed on the branch before the command; frozen afterwards", async () => {
622
+ const h = await boundInShip();
623
+ const r = await h.bash("d1", "git worktree remove .worktrees/gh-33 && git branch -D gh-33");
624
+ assert.equal(r.committedBeforeRun, true);
625
+ const rec = h.readRecord();
626
+ assert.equal(rec.status, "abandoned");
627
+ assert.ok(rec.abandoned_at);
628
+ assert.equal(rec.shipped_at, undefined);
629
+ assert.equal((rec.events.at(-1) as any).option, "discard");
630
+ const before = readFileSync(h.recordPath(), "utf8");
631
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
632
+ assert.equal(readFileSync(h.recordPath(), "utf8"), before);
633
+ });
634
+
635
+ test("failed discard does not recreate a removed worktree", async () => {
636
+ const h = await boundInShip();
637
+ const command = "git worktree remove .worktrees/gh-33 && git branch -D gh-33";
638
+ await h.emit("tool_call", { toolName: "bash", toolCallId: "d1", input: { command } });
639
+ rmSync(h.root, { recursive: true, force: true });
640
+ await h.emit("tool_result", { toolName: "bash", toolCallId: "d1", input: { command }, content: [], isError: true, details: undefined });
641
+ assert.equal(existsSync(h.root), false);
642
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
643
+ assert.equal(existsSync(h.root), false);
644
+ });
645
+
646
+ test("keep (phase complete ship with no live ship event): shipped_at set and committed, not frozen", async () => {
647
+ const h = await boundInShip();
648
+ const commitsBefore = h.commits().length;
649
+ await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "complete" }));
650
+ const rec = h.readRecord();
651
+ assert.equal(rec.status, "shipped");
652
+ assert.equal(rec.derived.gates.ship_option, "keep");
653
+ assert.equal(h.commits().length, commitsBefore + 1);
654
+ await h.emit("session_compact", { type: "session_compact", reason: "manual" });
655
+ await h.emit("session_shutdown", { type: "session_shutdown", reason: "quit" });
656
+ assert.equal(h.readRecord().derived.phases.unphased?.compactions, 1, "keep does not freeze");
657
+ });
658
+
659
+ test("no base ref: diff omitted with a warning", async () => {
660
+ const h = await boundInShip((args) => (args[0] === "rev-parse" && args.includes("--verify") ? { code: 128, stdout: "", stderr: "" } : undefined));
661
+ await h.bash("s1", "git push");
662
+ const rec = h.readRecord();
663
+ assert.equal(rec.derived.diff, undefined);
664
+ assert.ok(rec.events.some((e) => e.kind === "warning" && /base/.test((e as any).message)));
665
+ });
666
+
667
+ test("guard: write to a shipped spec during brainstorm is blocked before any state change; edit passes; other phases pass", async () => {
668
+ const h = await boundInShip();
669
+ await h.bash("s1", "git push");
670
+ const before = readFileSync(h.recordPath(), "utf8");
671
+ const h2 = harness();
672
+ h2.ctx.cwd = h.root;
673
+ await h2.phaseResult("start", P({ brainstorm: "in_progress" }));
674
+ const [blocked] = await h2.emit("tool_call", { toolName: "write", toolCallId: "w1", input: { path: "doc/specs/a.md", content: "# again" } });
675
+ assert.deepEqual(blocked, { block: true, reason: guardReason("doc/specs/a.md", h.readRecord().shipped_at!, ".pi/gauntlet/telemetry/doc/specs/a.yaml") });
676
+ assert.equal(readFileSync(h.recordPath(), "utf8"), before, "shipped record byte-identical");
677
+ const [editPass] = await h2.emit("tool_call", { toolName: "edit", toolCallId: "e1", input: { path: "doc/specs/a.md", edits: [] } });
678
+ assert.equal(editPass, undefined);
679
+ const [otherPass] = await h2.emit("tool_call", { toolName: "write", toolCallId: "w2", input: { path: "doc/specs/b.md", content: "# new" } });
680
+ assert.equal(otherPass, undefined);
681
+ const h3 = harness();
682
+ h3.ctx.cwd = h.root;
683
+ await h3.phaseResult("start", P({ brainstorm: "complete", plan: "in_progress" }));
684
+ const [planPass] = await h3.emit("tool_call", { toolName: "write", toolCallId: "w3", input: { path: "doc/specs/a.md", content: "# again" } });
685
+ assert.equal(planPass, undefined);
686
+ });