pi-gauntlet 5.18.7 → 5.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Telemetry extension (#33): records one committed YAML record per gauntlet run,
3
- * keyed by spec path, from pi events alone (no skill cooperation), and blocks
4
- * `write` into an already-shipped spec while brainstorm is in progress.
5
- * Pure helpers: extensions/lib/telemetry-*.ts. The default export takes a `deps`
6
- * seam ({ fs, git, now, settings }) for the harness tests.
2
+ * Telemetry extension: records one YAML record per gauntlet run, keyed by spec path,
3
+ * from pi events alone. Binds only after `phase_tracker start brainstorm` armed the
4
+ * flow, writes the record to disk without ever staging or committing it (the seal bin
5
+ * commits once at finish), and blocks `write` into an already-shipped spec while
6
+ * brainstorm is in progress. Pure helpers: extensions/lib/telemetry-*.ts. The default
7
+ * export takes a `deps` seam ({ fs, git, jj, now, settings }) for the harness tests.
7
8
  */
8
9
 
9
10
  import { execFile } from "node:child_process";
@@ -16,12 +17,13 @@
16
17
  import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
17
18
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
18
19
  import { checkoutOf } from "./lib/checkout.ts";
19
- import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
20
+ import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
20
21
  import { sha256 } from "./lib/plan-check.ts";
21
- import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parsePatchNumstat, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
22
+ import { SUPERSEDED_BY_RE, isPlanPath, isSpecPath, isSupersededByBanner, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
23
+ import { computeJjDiff } from "./lib/telemetry-diff.ts";
22
24
  import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
23
25
  import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
24
- import { BASE_REFS, guardReason, modifiedFilesFrom } from "./lib/telemetry-ship.ts";
26
+ import { guardReason } from "./lib/telemetry-ship.ts";
25
27
 
26
28
  // ---- deps seam -------------------------------------------------------------------
27
29
 
@@ -131,15 +133,25 @@
131
133
 
132
134
  interface ReplayResult {
133
135
  phases: PhaseMap;
134
- planCheckSpec?: string; // absolute
135
- lastSpecWrite?: string; // as passed to the tool
136
+ gauntletEntered: boolean;
137
+ planCheckSpec?: string;
138
+ lastSpecWrite?: string;
136
139
  }
137
140
 
138
- // Walks the session branch like phase-tracker.ts: successful phase_tracker results
139
- // set the phase map; plan_check pass specPath and successful spec writes feed binding.
140
- // Silent: no events are emitted (each was already flushed when it happened).
141
+ // gauntlet-resume's reconstruction skips brainstorm with `resume: <spec path>`; the path
142
+ // is the bind candidate on that route because no spec write follows.
143
+ const RESUME_REASON_RE = /^resume: (\S+)$/;
144
+ export function resumeSpecOf(phases: PhaseMap | undefined): string | undefined {
145
+ const b = phases?.brainstorm;
146
+ const m = b?.status === "skipped" && typeof b.reason === "string" ? RESUME_REASON_RE.exec(b.reason) : null;
147
+ return m && isSpecPath(toPosix(m[1])) ? m[1] : undefined;
148
+ }
149
+
150
+ // Walks the session branch like phase-tracker.ts: successful phase_tracker results set
151
+ // the phase map and the flow-entry marker; bind candidates are taken only while armed
152
+ // and dropped on reset. Silent: no events are emitted.
141
153
  export function replayBranch(entries: Iterable<unknown>): ReplayResult {
142
- const out: ReplayResult = { phases: emptyPhases() };
154
+ const out: ReplayResult = { phases: emptyPhases(), gauntletEntered: false };
143
155
  const calls = new Map<string, { name: string; args: unknown }>();
144
156
  for (const raw of entries) {
145
157
  const entry = raw as { type?: string; message?: { role?: string; content?: unknown; toolName?: string; toolCallId?: string; isError?: boolean; details?: unknown } };
@@ -152,14 +164,22 @@
152
164
  }
153
165
  if (msg.role !== "toolResult") continue;
154
166
  if (msg.toolName === "phase_tracker") {
155
- const d = msg.details as { phases?: PhaseMap; error?: string } | undefined;
156
- if (d?.phases && !d.error) out.phases = d.phases;
167
+ const d = msg.details as { action?: string; phases?: PhaseMap; error?: string } | undefined;
168
+ if (!d?.phases || d.error) continue;
169
+ out.phases = d.phases;
170
+ out.gauntletEntered = nextGauntletEntered(out.gauntletEntered, d.action ?? "", d.phases.brainstorm.status);
171
+ if (d.action === "reset") {
172
+ out.planCheckSpec = undefined;
173
+ out.lastSpecWrite = undefined;
174
+ }
175
+ const resumed = d.action === "skip" ? resumeSpecOf(d.phases) : undefined;
176
+ if (out.gauntletEntered && resumed) out.lastSpecWrite = resumed;
157
177
  } else if (msg.toolName === "plan_check") {
158
178
  const d = msg.details as { status?: string; specPath?: string } | undefined;
159
- if (d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
160
- } else if (msg.toolName === "write" && !msg.isError && msg.toolCallId) {
179
+ if (out.gauntletEntered && d?.status === "pass" && typeof d.specPath === "string") out.planCheckSpec = d.specPath;
180
+ } else if ((msg.toolName === "write" || msg.toolName === "edit") && !msg.isError && msg.toolCallId) {
161
181
  const p = (calls.get(msg.toolCallId)?.args as { path?: unknown } | undefined)?.path;
162
- if (typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
182
+ if (out.gauntletEntered && typeof p === "string" && isSpecPath(toPosix(p))) out.lastSpecWrite = p;
163
183
  }
164
184
  }
165
185
  return out;
@@ -177,14 +197,13 @@
177
197
  let record: TelemetryRecord | undefined;
178
198
  const predecessorLinks = new Set<string>();
179
199
  let recordRel: string | undefined; // repo-relative record path
180
- let oldRecordRel: string | undefined; // pending rename partner for the next commit
181
200
  let buffer: TelemetryEvent[] = [];
182
201
  let pending: Accumulators = emptyAccumulators();
183
- let lastCommitted = "";
184
202
  let frozen = false;
203
+ let gauntletEntered = false;
185
204
  let settingsWarned = false;
186
- let notGitWarned = false;
187
205
  let sessionId = "";
206
+ let currentDir = "";
188
207
 
189
208
  // Per-event settings read; undefined => this event is a no-op.
190
209
  const enabledSettings = (ctx: ExtensionContext): SettingsSnapshot | undefined => {
@@ -203,9 +222,12 @@
203
222
  return co && rel ? { toplevel: co.toplevel, rel, via: co.via } : undefined;
204
223
  };
205
224
 
225
+ // Disarmed and unbound: activity outside the gauntlet is never charged to the next run.
206
226
  const live = (): Accumulators => record
207
227
  ? (record.accumulators[sessionId] ??= emptyAccumulators())
208
- : pending;
228
+ : gauntletEntered ? pending : emptyAccumulators();
229
+
230
+ const canBind = (): boolean => gauntletEntered && !frozen && phases.ship.status !== "complete";
209
231
 
210
232
  const abs = (rel: string): string => join(toplevel!, rel);
211
233
  const phaseNow = (): PhaseKey => currentPhase(phases);
@@ -213,26 +235,38 @@
213
235
  const pushEvent = (e: Omit<TelemetryEvent, "ts" | "session" | "phase"> & Partial<BaseEvent>): TelemetryEvent => {
214
236
  const full = { ts: deps.now(), session: sessionId, phase: phaseNow(), ...e } as TelemetryEvent;
215
237
  if (record) record.events.push(full);
216
- else buffer.push(full);
238
+ else if (gauntletEntered) buffer.push(full);
217
239
  return full;
218
240
  };
219
241
 
220
242
  const warn = (message: string) => pushEvent({ kind: "warning", message });
221
243
 
222
244
  // ---- record store ----
245
+ // The record is the file on disk; nothing here stages or commits. A record the seal bin
246
+ // already stamped is frozen so a later flush cannot overwrite the seal.
247
+ const sealedAt = (path: string): boolean => {
248
+ const text = deps.fs.readFile(path);
249
+ const parsed = text ? parseRecord(text) : undefined;
250
+ return !!parsed && parsed.status !== "in_progress";
251
+ };
252
+
223
253
  const writeRecord = () => {
224
254
  if (!record || !recordRel || frozen) return;
225
255
  if (!toplevel || !deps.fs.exists(toplevel)) {
226
256
  frozen = true;
227
257
  return;
228
258
  }
259
+ const target = abs(recordRel);
260
+ if (sealedAt(target)) {
261
+ frozen = true;
262
+ return;
263
+ }
229
264
  const capped = capEvents(record.events);
230
265
  if (capped.dropped) {
231
266
  record.events = capped.events;
232
267
  live().events_dropped += capped.dropped;
233
268
  }
234
269
  record.derived = derive(record, deps.now());
235
- const target = abs(recordRel);
236
270
  try {
237
271
  deps.fs.mkdirp(dirname(target));
238
272
  deps.fs.writeFile(target + ".tmp", serializeRecord(record));
@@ -242,43 +276,26 @@
242
276
  }
243
277
  };
244
278
 
245
- const commitRecord = async () => {
246
- if (!record || !recordRel || frozen || !toplevel) return;
247
- if (checkoutVia === "jj") {
248
- // A jj-bound checkout has no .git to commit into; say so once per session instead
249
- // of failing `git commit` at every checkpoint.
250
- if (!notGitWarned) {
251
- notGitWarned = true;
252
- warn("record written, not committed: not a git checkout");
253
- }
254
- return;
255
- }
256
- const text = deps.fs.readFile(abs(recordRel)) ?? "";
257
- if (text === lastCommitted) return;
258
- const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
259
- const add = await deps.git(["add", "-f", "--", ...paths], toplevel!);
260
- let commit = add.code === 0 ? await deps.git(["commit", "-q", "-m", `telemetry: ${record.spec}`, "--", ...paths], toplevel!) : add;
261
- if (commit.code !== 0 && /index\.lock/.test(commit.stderr)) {
262
- await new Promise((r) => setTimeout(r, 200));
263
- commit = await deps.git(["commit", "-q", "-m", `telemetry: ${record.spec}`, "--", ...paths], toplevel!);
264
- }
265
- if (commit.code !== 0) {
266
- warn(`git commit failed: ${(commit.stderr.trim().split("\n")[0] || "unknown error")}`);
267
- writeRecord();
268
- return;
279
+ const flush = writeRecord;
280
+
281
+ // Excluded untracked paths are invisible to `git status --porcelain`, skipped by
282
+ // `git add -A`, and never block `git worktree remove`; the seal stages with `git add -f`.
283
+ const ensureExcluded = async () => {
284
+ const common = await deps.git(["rev-parse", "--path-format=absolute", "--git-common-dir"], toplevel!);
285
+ const commonDir = common.stdout.trim();
286
+ if (common.code !== 0 || !commonDir) return void warn(`exclude skipped: git rev-parse --git-common-dir failed: ${common.stderr.trim().split("\n")[0]}`);
287
+ const line = `${currentDir.replace(/\/+$/, "")}/`;
288
+ const file = join(commonDir, "info", "exclude");
289
+ const existing = deps.fs.readFile(file) ?? "";
290
+ if (existing.split("\n").some((l) => l.trim() === line)) return;
291
+ try {
292
+ deps.fs.mkdirp(dirname(file));
293
+ deps.fs.writeFile(file, existing + (existing && !existing.endsWith("\n") ? "\n" : "") + line + "\n");
294
+ } catch (e) {
295
+ warn(`exclude write failed: ${String((e as Error).message ?? e).split("\n")[0]}`);
269
296
  }
270
- lastCommitted = text;
271
297
  };
272
298
 
273
- // Write point = every event / accumulator change on a boundary; checkpoint = also commit.
274
- const flush = async (checkpoint: boolean) => {
275
- if (!record) return;
276
- writeRecord();
277
- if (checkpoint) await commitRecord();
278
- };
279
-
280
- let currentDir = "";
281
-
282
299
  const loadOrCreate = (specRel: string): TelemetryRecord => {
283
300
  const rel = recordPathFor(currentDir, specRel);
284
301
  const existing = deps.fs.readFile(abs(rel));
@@ -297,7 +314,10 @@
297
314
  return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
298
315
  };
299
316
 
317
+ const sealedOnDisk = (top: string, rel: string, dir: string): boolean => sealedAt(join(top, recordPathFor(dir, rel)));
318
+
300
319
  const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string, via: "git" | "jj") => {
320
+ if (sealedOnDisk(specToplevel, specRel, snap.telemetry.dir)) return;
301
321
  toplevel = specToplevel;
302
322
  checkoutVia = via;
303
323
  currentDir = snap.telemetry.dir;
@@ -333,6 +353,7 @@
333
353
  if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
334
354
  if (snap.telemetry.warning) warn(snap.telemetry.warning);
335
355
  }
356
+ if (via === "git") await ensureExcluded();
336
357
  refreshLinks();
337
358
  };
338
359
 
@@ -342,8 +363,6 @@
342
363
  boundSpec = undefined;
343
364
  record = undefined;
344
365
  recordRel = undefined;
345
- oldRecordRel = undefined;
346
- lastCommitted = "";
347
366
  frozen = false;
348
367
  };
349
368
 
@@ -387,22 +406,22 @@
387
406
  return false;
388
407
  }
389
408
  pushEvent({ kind: "spec_renamed", from: boundSpec!, to: newSpec });
390
- oldRecordRel = from;
391
409
  boundSpec = newSpec;
392
410
  recordRel = to;
393
411
  record!.spec = newSpec;
394
- lastCommitted = "";
395
412
  return true;
396
413
  };
397
414
 
398
415
  // ---- phase mirror ----
399
416
  const applyPhaseDetails = async (details: { action?: string; phases?: PhaseMap; error?: string; rounds?: number } | undefined, snap: SettingsSnapshot) => {
400
417
  if (!details?.phases || details.error) return;
401
- const transitions = diffPhases(phases, details.phases, details.action ?? "");
402
- if (details.action === "grant_fix_rounds") {
418
+ const action = details.action ?? "";
419
+ gauntletEntered = nextGauntletEntered(gauntletEntered, action, details.phases.brainstorm.status);
420
+ const transitions = diffPhases(phases, details.phases, action);
421
+ if (action === "grant_fix_rounds") {
403
422
  live().gates.fix_round_grants += 1;
404
423
  pushEvent({ kind: "gate", gate: "fix_round_grant", rounds: typeof details.rounds === "number" ? details.rounds : undefined });
405
- await flush(false);
424
+ flush();
406
425
  }
407
426
  for (const t of transitions) {
408
427
  const model = ctxModel();
@@ -415,36 +434,63 @@
415
434
  );
416
435
  phases = details.phases;
417
436
  if (record && t.name === "brainstorm" && (t.action === "complete" || t.action === "skip")) record.approved_at ??= e.ts;
418
- if (record && t.name === "ship" && t.action === "complete") await onShipKeep(snap);
419
- await flush(true);
420
- if (t.action === "reset") unbind();
437
+ if (record && t.name === "ship" && t.action === "complete" && checkoutVia === "jj") await onShipKeep(snap);
438
+ flush();
439
+ if (t.action === "reset") {
440
+ // Epoch ends: nothing recorded before the next `start brainstorm` belongs to a run.
441
+ unbind();
442
+ buffer = [];
443
+ pending = emptyAccumulators();
444
+ }
421
445
  }
422
446
  phases = details.phases;
447
+ if (action === "skip" && !boundSpec && canBind() && lastCtx) {
448
+ const resumed = resumeSpecOf(details.phases);
449
+ const loc = resumed ? await locate(lastCtx, resumed) : undefined;
450
+ if (loc && isSpecPath(loc.rel)) {
451
+ await bind(loc.rel, snap, loc.toplevel, loc.via);
452
+ flush();
453
+ }
454
+ }
423
455
  };
424
456
 
425
457
  let lastCtx: ExtensionContext | undefined;
426
458
  const ctxModel = (): string | undefined => (lastCtx?.model ? `${lastCtx.model.provider}/${lastCtx.model.id}` : undefined);
427
459
  const ctxThinking = (): string | undefined => lastCtx?.thinkingLevel;
428
460
 
429
- // Keep-branch ship: shipped_at at `phase complete ship`. Defined in block 3.
461
+ // jj keep-path seal on `phase complete ship`; git checkouts are sealed by the bin. Defined in block 3.
430
462
  let onShipKeep: (snap: SettingsSnapshot) => Promise<void>;
431
463
 
432
464
  // ---- handlers ----
433
- pi.on("session_start", async (_event, ctx) => {
465
+ // Same lifecycle set as phase-tracker.ts: every navigation rebuilds marker, phases, and
466
+ // binding from the target branch in order.
467
+ const reconstruct = async (ctx: ExtensionContext) => {
434
468
  lastCtx = ctx;
435
469
  const snap = enabledSettings(ctx);
436
470
  if (!snap) return;
471
+ flush();
472
+ unbind();
473
+ buffer = [];
474
+ pending = emptyAccumulators();
475
+ lastPlanTasks = undefined;
476
+ amendmentOpen = false;
477
+ pendingTest.clear();
437
478
  sessionId = ctx.sessionManager.getSessionId();
438
479
  currentDir = snap.telemetry.dir;
439
480
  const replay = replayBranch(ctx.sessionManager.getBranch());
440
481
  phases = replay.phases;
482
+ gauntletEntered = replay.gauntletEntered;
483
+ if (!canBind()) return;
441
484
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
442
485
  const loc = candidate ? await locate(ctx, candidate) : undefined;
443
486
  if (loc && isSpecPath(loc.rel)) {
444
487
  await bind(loc.rel, snap, loc.toplevel, loc.via);
445
- await flush(false);
488
+ flush();
446
489
  }
447
- });
490
+ };
491
+ for (const event of ["session_start", "session_switch", "session_fork", "session_tree"] as const) {
492
+ pi.on(event, async (_event, ctx) => reconstruct(ctx));
493
+ }
448
494
 
449
495
  pi.on("session_shutdown", async (_event, ctx) => {
450
496
  const snap = enabledSettings(ctx);
@@ -454,7 +500,7 @@
454
500
  record.accumulators.total = foldAccumulators(record.accumulators.total ?? emptyAccumulators(), liveBlock);
455
501
  delete record.accumulators[sessionId];
456
502
  }
457
- await flush(true);
503
+ flush();
458
504
  });
459
505
 
460
506
  pi.on("tool_result", async (event, ctx) => {
@@ -466,6 +512,7 @@
466
512
  await applyPhaseDetails(event.details as never, snap);
467
513
  return undefined;
468
514
  }
515
+ if (!record && !canBind()) return undefined;
469
516
  if (event.toolName === "plan_check" && !event.isError) {
470
517
  const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
471
518
  const specLoc = d?.specPath ? await locate(ctx, d.specPath) : undefined;
@@ -473,13 +520,13 @@
473
520
  const spec = specLoc?.rel;
474
521
  const plan = planLoc?.rel;
475
522
  const pass = d?.status === "pass";
476
- if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
523
+ if (pass && canBind() && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
477
524
  if (record) unbind();
478
525
  await bind(specLoc.rel, snap, specLoc.toplevel, specLoc.via);
479
526
  }
480
527
  live().gates.plan_rounds += 1;
481
528
  pushEvent({ kind: "plan_check", pass, spec, plan });
482
- await flush(pass);
529
+ flush();
483
530
  return undefined;
484
531
  }
485
532
  if ((event.toolName === "write" || event.toolName === "edit" || event.toolName === "read") && !event.isError) {
@@ -500,21 +547,11 @@
500
547
  return undefined;
501
548
  }
502
549
  if (event.toolName === "bash") {
503
- if (pendingShip && pendingShip.id === event.toolCallId) {
504
- pendingShip = undefined;
505
- if (event.isError) {
506
- pushEvent({ kind: "ship_failed" });
507
- clearShipState();
508
- await flush(false);
509
- } else {
510
- frozen = true;
511
- }
512
- }
513
550
  const matched = pendingTest.get(event.toolCallId);
514
551
  if (matched && record) {
515
552
  pendingTest.delete(event.toolCallId);
516
553
  record.derived.tests = { command: truncateCommand(matched), result: event.isError ? "fail" : "pass" };
517
- await flush(false);
554
+ flush();
518
555
  }
519
556
  return undefined;
520
557
  }
@@ -528,26 +565,28 @@
528
565
  if (!boundSpec) {
529
566
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
530
567
  await bind(rel, snap, loc.toplevel, loc.via);
568
+ if (!boundSpec) return undefined;
531
569
  const patch = onBoundSpecWrite(tool, rel, event);
532
- await flush(true);
570
+ flush();
533
571
  return patch;
534
572
  } else if (isPlanPath(rel)) {
535
573
  const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
536
574
  const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
537
575
  if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
538
576
  await bind(specRel, snap, loc.toplevel, loc.via);
539
- await flush(true);
577
+ flush();
540
578
  }
541
579
  }
542
580
  return undefined;
543
581
  }
544
582
  if (loc.toplevel !== toplevel) {
545
583
  // Another checkout: a fresh run, never a rename of this record.
546
- if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
584
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
547
585
  unbind();
548
586
  await bind(rel, snap, loc.toplevel, loc.via);
587
+ if (!boundSpec) return undefined;
549
588
  const patch = onBoundSpecWrite(tool, rel, event);
550
- await flush(true);
589
+ flush();
551
590
  return patch;
552
591
  }
553
592
  return undefined;
@@ -555,17 +594,17 @@
555
594
  if (tool === "read") return undefined;
556
595
  if (rel === boundSpec) {
557
596
  const patch = onBoundSpecWrite(tool, rel, event);
558
- await flush(false);
597
+ flush();
559
598
  return patch;
560
599
  }
561
600
  if (tool === "write" && isSpecPath(rel) && phases.brainstorm.status === "in_progress" && isDraftOrMissing(boundSpec)) {
562
601
  if (await rebind(rel)) onBoundSpecWrite(tool, rel, event);
563
- await flush(true);
602
+ flush();
564
603
  return undefined;
565
604
  }
566
605
  if (tool === "edit" && isSpecPath(rel)) {
567
606
  onOtherSpecEdit(rel, event);
568
- await flush(false);
607
+ flush();
569
608
  }
570
609
  return undefined;
571
610
  };
@@ -622,7 +661,7 @@
622
661
  }
623
662
  }
624
663
  }
625
- await flush(false);
664
+ flush();
626
665
  };
627
666
 
628
667
  const onPlanTracker = async (details: unknown, snap: SettingsSnapshot) => {
@@ -639,97 +678,20 @@
639
678
  await applyPhaseDetails({ action: "complete", phases: next }, snap);
640
679
  return;
641
680
  }
642
- if (record) await flush(false);
643
- };
644
-
645
- let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
646
-
647
- const computeDiff = async (snap: SettingsSnapshot) => {
648
- if (checkoutVia === "jj") await computeJjDiff(snap);
649
- else await computeGitDiff(snap);
650
- };
651
-
652
- const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
653
-
654
- const computeJjDiff = async (snap: SettingsSnapshot) => {
655
- if (!record || !toplevel) return;
656
- const jj = deps.jj ?? realJj;
657
- const failed = (sub: string, r: GitResult) => warn(`diff omitted: jj ${sub} failed: ${r.stderr.trim().split("\n")[0] || "unknown error"}`);
658
- // Without the ::M guard an empty mainline resolves to @ and counts the full history.
659
- const baseR = await jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], toplevel);
660
- if (baseR.code !== 0) return failed("log", baseR);
661
- const base = baseR.stdout.trim().split("\n")[0].trim();
662
- if (!base) return warn("diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
663
- const patch = await jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], toplevel);
664
- if (patch.code !== 0) return failed("diff", patch);
665
- const rows = parsePatchNumstat(patch.stdout);
666
- if (!rows) return warn("diff omitted: jj diff unparseable");
667
- const dir = currentDir.replace(/\/+$/, "");
668
- // Exclude record-only @ and other telemetry-only revisions from the count.
669
- const count = await jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], toplevel);
670
- if (count.code !== 0) return failed("log", count);
671
- const n = count.stdout.trim();
672
- if (!/^\d+$/.test(n)) return warn(`diff omitted: jj log --count unparseable: ${n}`);
673
- const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), record.spec, currentDir);
674
- const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
675
- record.derived.modified_files = files;
676
- record.derived.diff = { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), snap.telemetry.buckets) };
677
- };
678
-
679
- const computeGitDiff = async (snap: SettingsSnapshot) => {
680
- if (!record || !toplevel) return;
681
- let baseRef: string | undefined;
682
- for (const ref of BASE_REFS) {
683
- if ((await deps.git(["rev-parse", "--verify", "--quiet", ref], toplevel!)).code === 0) {
684
- baseRef = ref;
685
- break;
686
- }
687
- }
688
- if (!baseRef) {
689
- warn("diff omitted: no base ref among origin/HEAD, main, master");
690
- return;
691
- }
692
- const mb = await deps.git(["merge-base", "HEAD", baseRef], toplevel!);
693
- if (mb.code !== 0 || !mb.stdout.trim()) {
694
- warn(`diff omitted: merge-base failed: ${mb.stderr.trim().split("\n")[0]}`);
695
- return;
696
- }
697
- const base = mb.stdout.trim();
698
- const names = await deps.git(["diff", "--name-only", `${base}...HEAD`], toplevel!);
699
- const files = modifiedFilesFrom(names.stdout, record.spec, currentDir);
700
- const numstat = await deps.git(["diff", "--numstat", `${base}...HEAD`], toplevel!);
701
- const count = await deps.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], toplevel!);
702
- record.derived.modified_files = files;
703
- record.derived.diff = { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), snap.telemetry.buckets) };
704
- };
705
-
706
- const clearShipState = () => {
707
- if (!record) return;
708
- record.shipped_at = undefined;
709
- record.abandoned_at = undefined;
710
- record.status = "in_progress";
711
- record.derived.diff = undefined;
712
- record.derived.modified_files = undefined;
713
- };
714
-
715
- const shipAttempt = async (option: "squash" | "pr" | "keep" | "discard", command: string | undefined, snap: SettingsSnapshot) => {
716
- if (!record) return;
717
- const ts = deps.now();
718
- if (option === "discard") {
719
- record.status = "abandoned";
720
- record.abandoned_at = ts;
721
- } else {
722
- record.status = "shipped";
723
- record.shipped_at = ts;
724
- await computeDiff(snap);
725
- }
726
- pushEvent({ kind: "ship", option, command: command ? truncateCommand(command) : undefined });
727
- await flush(true);
681
+ if (record) flush();
728
682
  };
729
683
 
684
+ // jj only: finishing cannot run in a plain jj workspace, so `phase complete ship` seals
685
+ // the record in place (jj snapshots the working copy; there is no commit step).
730
686
  onShipKeep = async (snap) => {
731
- if (!record || liveShipEvent(record.events)) return;
732
- await shipAttempt("keep", undefined, snap);
687
+ if (!record || !toplevel || liveShipEvent(record.events)) return;
688
+ record.status = "shipped";
689
+ record.shipped_at = deps.now();
690
+ const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets });
691
+ if (out.warning) warn(out.warning);
692
+ record.derived.modified_files = out.modified_files;
693
+ record.derived.diff = out.diff;
694
+ pushEvent({ kind: "ship", option: "keep" });
733
695
  };
734
696
 
735
697
  pi.on("tool_call", async (event, ctx) => {
@@ -751,20 +713,6 @@
751
713
  }
752
714
  if (event.toolName === "bash") {
753
715
  const command = String((event.input as { command?: unknown }).command ?? "");
754
- if (record && !frozen && phases.ship.status === "in_progress" && !liveShipEvent(record.events)) {
755
- const ship = matchShipStatement(command);
756
- if (ship) {
757
- pendingShip = { id: event.toolCallId, option: ship.option };
758
- await shipAttempt(ship.option, ship.statement, snap);
759
- return undefined;
760
- }
761
- const discard = matchDiscardStatement(command);
762
- if (discard) {
763
- pendingShip = { id: event.toolCallId, option: "discard" };
764
- await shipAttempt("discard", discard, snap);
765
- return undefined;
766
- }
767
- }
768
716
  if (record && (phases.verify.status === "in_progress" || phases.ship.status === "in_progress")) {
769
717
  const statement = matchTestStatement(command, snap.testCommands ?? DEFAULT_TEST_COMMANDS);
770
718
  if (statement) pendingTest.set(event.toolCallId, statement);
@@ -804,21 +752,21 @@
804
752
  lastCtx = ctx;
805
753
  if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
806
754
  pushEvent({ kind: "config_change", model: `${event.model.provider}/${event.model.id}` });
807
- await flush(false);
755
+ flush();
808
756
  });
809
757
 
810
758
  pi.on("thinking_level_select", async (event, ctx) => {
811
759
  lastCtx = ctx;
812
760
  if (!enabledSettings(ctx) || phaseNow() === "unphased") return;
813
761
  pushEvent({ kind: "config_change", thinking: String(event.level) });
814
- await flush(false);
762
+ flush();
815
763
  });
816
764
 
817
765
  pi.on("session_compact", async (_event, ctx) => {
818
766
  if (!enabledSettings(ctx)) return;
819
767
  const acc = phaseAcc();
820
768
  acc.compactions = (acc.compactions ?? 0) + 1;
821
- await flush(false);
769
+ flush();
822
770
  });
823
771
 
824
772
  onBoundSpecWrite = (tool, rel, event) => {
@@ -832,7 +780,6 @@
832
780
  acc.spec_writes[k] = { count: cur.count + 1, last_sha256: sha256(new TextEncoder().encode(body)) };
833
781
  refreshLinks();
834
782
  if (record.shipped_at) {
835
- acc.spec_edits_after_ship += 1;
836
783
  const warning = `⚠️ spec shipped at ${record.shipped_at}; write a follow-up spec that supersedes it`;
837
784
  return { content: [{ type: "text" as const, text: warning }, ...(event.content as { type: string }[])] };
838
785
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.18.7",
3
+ "version": "5.20.0",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "bin": {
35
35
  "gauntlet-spec-index": "bin/gauntlet-spec-index.mjs",
36
- "gauntlet-telemetry-salvage": "bin/gauntlet-telemetry-salvage.mjs",
36
+ "gauntlet-telemetry-seal": "bin/gauntlet-telemetry-seal.mjs",
37
37
  "gauntlet-performance": "bin/gauntlet-performance.mjs"
38
38
  },
39
39
  "engines": {
@@ -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. 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.
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, then seals and commits the record before landing. Explicit trivial one-off edits outside this flow need no worktree.
70
70
 
71
71
  ## The Process
72
72