pi-gauntlet 8.3.0 → 8.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/bin/gauntlet-performance.mjs +477 -64
  4. package/bin/gauntlet-performance.test.mjs +27 -27
  5. package/bin/gauntlet-spec-index.mjs +290 -47
  6. package/bin/gauntlet-spec-index.test.mjs +42 -18
  7. package/bin/gauntlet-telemetry-seal.mjs +220 -77
  8. package/bin/gauntlet-telemetry-seal.test.mjs +222 -457
  9. package/extensions/lib/gauntlet-settings.test.ts +12 -14
  10. package/extensions/lib/gauntlet-settings.ts +7 -16
  11. package/extensions/lib/telemetry-branch.test.ts +102 -0
  12. package/extensions/lib/telemetry-branch.ts +145 -0
  13. package/extensions/lib/telemetry-diff.test.ts +2 -2
  14. package/extensions/lib/telemetry-diff.ts +1 -1
  15. package/extensions/lib/telemetry-leg.ts +163 -0
  16. package/extensions/lib/telemetry-record.ts +2 -0
  17. package/extensions/lib/vcs.ts +6 -23
  18. package/extensions/phase-tracker.test.ts +6 -3
  19. package/extensions/phase-tracker.ts +2 -0
  20. package/extensions/plan-tracker.test.ts +4 -2
  21. package/extensions/plan-tracker.ts +1 -0
  22. package/extensions/telemetry-leg-failures.test.ts +138 -0
  23. package/extensions/telemetry-leg-fields.test.ts +130 -0
  24. package/extensions/telemetry-leg-no-write.test.ts +90 -0
  25. package/extensions/telemetry-leg-rounds.test.ts +157 -0
  26. package/extensions/telemetry-leg-trackers.test.ts +114 -0
  27. package/extensions/telemetry-leg.test.ts +151 -0
  28. package/extensions/telemetry-settlement-contract.test.ts +67 -0
  29. package/extensions/telemetry.test.ts +44 -27
  30. package/extensions/telemetry.ts +68 -48
  31. package/extensions/test-support/git-env.mjs +13 -0
  32. package/package.json +1 -1
  33. package/skills/brainstorming/SKILL.md +1 -1
  34. package/skills/check-delivery/SKILL.md +2 -0
  35. package/skills/finishing-a-development-branch/SKILL.md +13 -13
  36. package/skills/finishing-a-development-branch/reference/verification-set.md +2 -2
  37. package/skills/gatekeep-pr/reference/batch.md +3 -1
  38. package/skills/gatekeep-pr/reference/cleanup.md +1 -1
  39. package/skills/gatekeep-pr/reference/post-selection-loop.md +7 -1
  40. package/skills/gauntlet-resume/reference/seed.md +1 -6
  41. package/skills/shape-ticket/SKILL.md +1 -1
  42. package/skills/shape-ticket/reference/repo-audit.md +2 -2
@@ -1,6 +1,7 @@
1
1
  /**
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
2
+ * Telemetry extension: records run and session-leg YAML from pi events alone.
3
+ * Session legs seal through telemetry-leg.ts at terminal settlement. Runs bind
4
+ * only after `phase_tracker start brainstorm` armed the
4
5
  * flow, writes the record to disk without ever staging or committing it (the seal bin
5
6
  * commits once at finish), and blocks `write` into an already-shipped spec while
6
7
  * brainstorm is in progress. Pure helpers: extensions/lib/telemetry-*.ts. The default
@@ -14,7 +15,7 @@
14
15
  import * as piRuntime from "@earendil-works/pi-coding-agent";
15
16
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
16
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
- import { DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
18
+ import { DEFAULT_TELEMETRY_DIR, DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
18
19
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
19
20
  import { checkoutOf } from "./lib/checkout.ts";
20
21
  import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
@@ -24,6 +25,8 @@
24
25
  import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countStructuredFindings, countOpenGaps, countReopens, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
25
26
  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";
26
27
  import { guardReason } from "./lib/telemetry-ship.ts";
28
+ import { readTelemetryBranch, telemetryRepo, type syncTelemetryBranch } from "./lib/telemetry-branch.ts";
29
+ import { registerLegTelemetry } from "./lib/telemetry-leg.ts";
27
30
  import { buildCouncil, parseAudit, parseChairReport, resolveMembers, type Cluster, type MemberResult } from "./lib/telemetry-council.ts";
28
31
 
29
32
  // ---- deps seam -------------------------------------------------------------------
@@ -51,6 +54,9 @@
51
54
  testCommands?: string[];
52
55
  }
53
56
  export interface Deps {
57
+ syncTelemetryBranch?: typeof syncTelemetryBranch;
58
+ telemetryRepo?: typeof telemetryRepo;
59
+ readTelemetryBranch?: typeof readTelemetryBranch;
54
60
  fs: FsPort;
55
61
  git: (args: string[], cwd: string) => Promise<GitResult>;
56
62
  // jj runner; production uses realJj for both checkout detection and the ship-time diff.
@@ -205,13 +211,16 @@
205
211
  let boundSpec: string | undefined; // repo-relative
206
212
  let record: TelemetryRecord | undefined;
207
213
  const predecessorLinks = new Set<string>();
208
- let recordRel: string | undefined; // repo-relative record path
214
+ let recordRel: string | undefined; // absolute record path outside the working tree
209
215
  let buffer: TelemetryEvent[] = [];
210
216
  let pending: Accumulators = emptyAccumulators();
211
217
  let frozen = false;
218
+ let recordWritten = false;
212
219
  let gauntletEntered = false;
213
220
  let settingsWarned = false;
214
221
  let sessionId = "";
222
+ const guardCache = new Map<string, Map<string, string>>();
223
+ let guardWarned = false;
215
224
  let currentDir = "";
216
225
  let councilPending: { memberResults: MemberResult[]; chair?: { model: string; dispatches: number; clusters?: Cluster[] } } | undefined;
217
226
 
@@ -235,7 +244,7 @@
235
244
  // Disarmed and unbound: activity outside the gauntlet is never charged to the next run.
236
245
  const live = (): Accumulators => record
237
246
  ? (record.accumulators[sessionId] ??= emptyAccumulators())
238
- : gauntletEntered ? pending : emptyAccumulators();
247
+ : legAcc() ?? (gauntletEntered ? pending : emptyAccumulators());
239
248
 
240
249
  const canBind = (): boolean => gauntletEntered && !frozen && phases.ship.status !== "complete";
241
250
 
@@ -252,8 +261,8 @@
252
261
  const warn = (message: string) => pushEvent({ kind: "warning", message });
253
262
 
254
263
  // ---- record store ----
255
- // The record is the file on disk; nothing here stages or commits. A record the seal bin
256
- // already stamped is frozen so a later flush cannot overwrite the seal.
264
+ // The seal either keeps a stamped file for retry or removes it after committing.
265
+ // Neither may be overwritten by a later recorder flush.
257
266
  const sealedAt = (path: string): boolean => {
258
267
  const text = deps.fs.readFile(path);
259
268
  const parsed = text ? parseRecord(text) : undefined;
@@ -266,8 +275,8 @@
266
275
  frozen = true;
267
276
  return;
268
277
  }
269
- const target = abs(recordRel);
270
- if (sealedAt(target)) {
278
+ const target = recordRel;
279
+ if ((recordWritten && !deps.fs.exists(target)) || sealedAt(target)) {
271
280
  frozen = true;
272
281
  return;
273
282
  }
@@ -281,6 +290,7 @@
281
290
  deps.fs.mkdirp(dirname(target));
282
291
  deps.fs.writeFile(target + ".tmp", serializeRecord(record));
283
292
  deps.fs.rename(target + ".tmp", target);
293
+ recordWritten = true;
284
294
  } catch (e) {
285
295
  pushEvent({ kind: "warning", message: `record write failed: ${String((e as Error).message ?? e).split("\n")[0]}` });
286
296
  }
@@ -288,27 +298,9 @@
288
298
 
289
299
  const flush = writeRecord;
290
300
 
291
- // Excluded untracked paths are invisible to `git status --porcelain`, skipped by
292
- // `git add -A`, and never block `git worktree remove`; the seal stages with `git add -f`.
293
- const ensureExcluded = async () => {
294
- const common = await deps.git(["rev-parse", "--path-format=absolute", "--git-common-dir"], toplevel!);
295
- const commonDir = common.stdout.trim();
296
- if (common.code !== 0 || !commonDir) return void warn(`exclude skipped: git rev-parse --git-common-dir failed: ${common.stderr.trim().split("\n")[0]}`);
297
- const line = `${currentDir.replace(/\/+$/, "")}/`;
298
- const file = join(commonDir, "info", "exclude");
299
- const existing = deps.fs.readFile(file) ?? "";
300
- if (existing.split("\n").some((l) => l.trim() === line)) return;
301
- try {
302
- deps.fs.mkdirp(dirname(file));
303
- deps.fs.writeFile(file, existing + (existing && !existing.endsWith("\n") ? "\n" : "") + line + "\n");
304
- } catch (e) {
305
- warn(`exclude write failed: ${String((e as Error).message ?? e).split("\n")[0]}`);
306
- }
307
- };
308
-
309
301
  const loadOrCreate = (specRel: string): TelemetryRecord => {
310
302
  const rel = recordPathFor(currentDir, specRel);
311
- const existing = deps.fs.readFile(abs(rel));
303
+ const existing = deps.fs.readFile(rel);
312
304
  const parsed = existing ? parseRecord(existing) : undefined;
313
305
  if (parsed) {
314
306
  // fold stale live blocks (sessions that died without shutdown) into total
@@ -324,13 +316,40 @@
324
316
  return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
325
317
  };
326
318
 
327
- const sealedOnDisk = (top: string, rel: string, dir: string): boolean => sealedAt(join(top, recordPathFor(dir, rel)));
319
+ const shippedRecords = (top: string, snap: SettingsSnapshot): Map<string, string> => {
320
+ const key = JSON.stringify([top, snap.telemetry.remote]);
321
+ let records = guardCache.get(key);
322
+ if (!records) {
323
+ records = new Map();
324
+ guardCache.set(key, records);
325
+ const repo = (deps.telemetryRepo ?? telemetryRepo)(top);
326
+ const result = (deps.readTelemetryBranch ?? readTelemetryBranch)(top, { remote: snap.telemetry.remote, prefix: `${repo}/brainstorming/` });
327
+ for (const entry of result.records) {
328
+ const parsed = parseRecord(entry.bytes);
329
+ if (parsed?.shipped_at) records.set(parsed.spec, parsed.shipped_at);
330
+ }
331
+ if (result.warning && !guardWarned) {
332
+ guardWarned = true;
333
+ pi.sendMessage({ customType: "telemetry-warning", content: `telemetry guard unavailable: ${result.warning}`, display: true });
334
+ }
335
+ }
336
+ return records;
337
+ };
328
338
 
329
339
  const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string, via: "git" | "jj") => {
330
- if (sealedOnDisk(specToplevel, specRel, snap.telemetry.dir)) return;
340
+ if (via === "git" && shippedRecords(specToplevel, snap).has(specRel)) return;
341
+ let metadataDir = join(specToplevel, ".jj");
342
+ if (via === "git") {
343
+ const common = await deps.git(["rev-parse", "--path-format=absolute", "--git-common-dir"], specToplevel);
344
+ if (common.code !== 0 || !common.stdout.trim()) {
345
+ warn(`record bind skipped: git common dir unavailable: ${common.stderr.trim().split("\n")[0]}`);
346
+ return;
347
+ }
348
+ metadataDir = common.stdout.trim();
349
+ }
331
350
  toplevel = specToplevel;
332
351
  checkoutVia = via;
333
- currentDir = snap.telemetry.dir;
352
+ currentDir = join(metadataDir, "gauntlet", "telemetry");
334
353
  boundSpec = specRel;
335
354
  recordRel = recordPathFor(currentDir, specRel);
336
355
  predecessorLinks.clear();
@@ -363,7 +382,6 @@
363
382
  if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
364
383
  if (snap.telemetry.warning) warn(snap.telemetry.warning);
365
384
  }
366
- if (via === "git") await ensureExcluded();
367
385
  refreshLinks();
368
386
  };
369
387
 
@@ -374,6 +392,7 @@
374
392
  record = undefined;
375
393
  recordRel = undefined;
376
394
  frozen = false;
395
+ recordWritten = false;
377
396
  councilPending = undefined;
378
397
  };
379
398
 
@@ -400,18 +419,13 @@
400
419
  const rebind = async (newSpec: string): Promise<boolean> => {
401
420
  const from = recordRel!;
402
421
  const to = recordPathFor(currentDir, newSpec);
403
- if (deps.fs.exists(abs(to))) {
422
+ if (deps.fs.exists(to)) {
404
423
  warn(`record move skipped: ${to} already exists`);
405
424
  return false;
406
425
  }
407
- const trackedResult = await deps.git(["ls-files", "--error-unmatch", "--", from], toplevel!);
408
- const tracked = trackedResult.code === 0 && trackedResult.stdout.trim() !== "";
409
426
  try {
410
- deps.fs.mkdirp(dirname(abs(to)));
411
- if (tracked) {
412
- const mv = await deps.git(["mv", "--", from, to], toplevel!);
413
- if (mv.code !== 0) deps.fs.rename(abs(from), abs(to));
414
- } else deps.fs.rename(abs(from), abs(to));
427
+ deps.fs.mkdirp(dirname(to));
428
+ deps.fs.rename(from, to);
415
429
  } catch (e) {
416
430
  warn(`record move failed: ${String((e as Error).message ?? e).split("\n")[0]}`);
417
431
  return false;
@@ -493,8 +507,11 @@
493
507
  lastPlanTasks = undefined;
494
508
  amendmentOpen = false;
495
509
  pendingTest.clear();
510
+ if (sessionId !== ctx.sessionManager.getSessionId()) {
511
+ guardCache.clear();
512
+ guardWarned = false;
513
+ }
496
514
  sessionId = ctx.sessionManager.getSessionId();
497
- currentDir = snap.telemetry.dir;
498
515
  const replay = replayBranch(ctx.sessionManager.getBranch(), snap.specDirs);
499
516
  lastPlanTasks = replay.lastPlanTasks;
500
517
  phases = replay.phases;
@@ -535,6 +552,10 @@
535
552
  await applyPhaseDetails(event.details as never, snap);
536
553
  return undefined;
537
554
  }
555
+ if (event.toolName === "subagent" && legAcc()) {
556
+ await onSubagentResult(event.details, event.content, event.isError);
557
+ return undefined;
558
+ }
538
559
  if (!record && !canBind()) return undefined;
539
560
  if (event.toolName === "plan_check" && !event.isError) {
540
561
  const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
@@ -605,7 +626,7 @@
605
626
  }
606
627
  if (loc.toplevel !== toplevel) {
607
628
  // Another checkout: a fresh run, never a rename of this record.
608
- if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs) && canBind() && !sealedOnDisk(loc.toplevel, rel, snap.telemetry.dir)) {
629
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs) && canBind() && (loc.via === "jj" || !shippedRecords(loc.toplevel, snap).has(rel))) {
609
630
  unbind();
610
631
  await bind(rel, snap, loc.toplevel, loc.via);
611
632
  if (!boundSpec) return undefined;
@@ -751,7 +772,7 @@
751
772
  if (!record || !toplevel || liveShipEvent(record.events)) return;
752
773
  record.status = "shipped";
753
774
  record.shipped_at = deps.now();
754
- const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: currentDir, buckets: snap.telemetry.buckets, planDirs: snap.planDirs });
775
+ const out = await computeJjDiff({ jj: deps.jj ?? realJj, cwd: toplevel, spec: record.spec, dir: DEFAULT_TELEMETRY_DIR, buckets: snap.telemetry.buckets, planDirs: snap.planDirs });
755
776
  if (out.warning) warn(out.warning);
756
777
  record.derived.modified_files = out.modified_files;
757
778
  record.derived.diff = out.diff;
@@ -766,11 +787,9 @@
766
787
  const rawPath = (event.input as { path?: unknown }).path;
767
788
  if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
768
789
  const loc = await locate(ctx, rawPath);
769
- if (loc && isSpecPath(loc.rel, snap.specDirs)) {
770
- const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
771
- const existing = deps.fs.readFile(join(loc.toplevel, recRel));
772
- const parsed = existing ? parseRecord(existing) : undefined;
773
- if (parsed?.shipped_at) return { block: true, reason: guardReason(loc.rel, parsed.shipped_at, recRel) };
790
+ if (loc?.via === "git" && isSpecPath(loc.rel, snap.specDirs)) {
791
+ const shippedAt = shippedRecords(loc.toplevel, snap).get(loc.rel);
792
+ if (shippedAt) return { block: true, reason: guardReason(loc.rel, shippedAt, "gauntlet/telemetry") };
774
793
  }
775
794
  }
776
795
  return undefined;
@@ -874,4 +893,5 @@
874
893
  refreshLinks();
875
894
  };
876
895
 
896
+ const legAcc = registerLegTelemetry(pi, deps, () => !!record);
877
897
  }
@@ -0,0 +1,13 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export function isolatedGitEnv(scratch) {
5
+ const env = {
6
+ GIT_CONFIG_GLOBAL: join(scratch, "gitconfig"),
7
+ GIT_CONFIG_NOSYSTEM: "1",
8
+ GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@example.test",
9
+ GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@example.test",
10
+ };
11
+ writeFileSync(env.GIT_CONFIG_GLOBAL, "");
12
+ return env;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "8.3.0",
3
+ "version": "8.4.1",
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",
@@ -69,7 +69,7 @@ The spec is the first commit in a dedicated worktree, never a separate commit on
69
69
 
70
70
  Read [reference/legs.md](reference/legs.md) and settle membership before creation. Invoke `/skill:using-git-worktrees` for each member with explicit `$ROOT` = its primary and `$BRANCH` = the shared branch; carry every `Worktree ready at <full-path>` value, home first. Root the home spec at `<home worktree>/<home spec dir>/<filename>.md`; root each dispatch and `git -C` in its owning leg's worktree. Keep the process cwd unchanged. Gather each leg's rules and record portable contracts and companion artifacts using that reference; a one-member run adds only its one-block `## Legs` section.
71
71
 
72
- 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 the record before landing (a `telemetry:` commit under git; the working-copy snapshot in a plain jj workspace). Explicit trivial one-off edits outside this flow need no worktree.
72
+ Keep spec, plan, and implementation in this worktree. Ship the spec and implementation; strip only the plan. Let `/skill:finishing-a-development-branch` seal and publish the telemetry record to the orphan `gauntlet/telemetry` branch before landing; keep recorder state in Git common-dir metadata, outside the working tree. In a plain jj workspace, retain local metadata and report that publication is unavailable. Use no worktree for explicit trivial one-off edits outside this flow.
73
73
 
74
74
  ## The Process
75
75
 
@@ -262,6 +262,8 @@ Start the message with `gauntlet: gate=check-delivery/confirmation-gate` before
262
262
 
263
263
  Start the message with `gauntlet: done delivered` when the batched write landed. Start the message with `gauntlet: done already-recorded` for idempotent delivery. Start the message with `gauntlet: done manual` when the write halves are emitted for manual execution. Start the message with `gauntlet: stuck` followed on that line by the concrete reason once the findings-comment gate is answered or no comment is offered; use this for any partial write failure too. A declined success write ends with `gauntlet: stuck write declined`.
264
264
 
265
+ After every selected write or follow-on step finishes, put one plain `<ref>: <outcome>` line below the terminal marker, including singleton and early STOP paths. Use the supplied ticket ref and exactly one outcome: `delivered`, `manual`, `declined`, `already recorded`, `skipped`, or `failed - <reason>`. Map a declined success write to `declined`, skipped-no-tracker to `skipped`, and a failed stage, findings-only path or partial write to `failed - <reason>`; report a terminal ticket without recorded delivery as `skipped`. Keep rows outside tracker payloads, without bullets, code fences or emphasis. In set mode use `reference/batch.md`'s one final roster settlement, never a per-ticket terminal marker while siblings continue.
266
+
265
267
  Any `unexplained gap` or failed stage -> **failure path**: no status
266
268
  advance, no delivery comment, no marker. The findings may be offered as a
267
269
  **findings comment** (no `Delivered:` line, so it never trips idempotency) -
@@ -226,7 +226,7 @@ Rows 1-2 run the Option 1/2 blocks; row 3 runs the Keep block and row 4 the Disc
226
226
 
227
227
  #### Strip the plan, seal the record (Options 1-3)
228
228
 
229
- Run this on the feature branch before any 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. The record is an untracked, git-excluded file until this step commits it (in a plain jj workspace there is no commit; jj snapshots the stamped record into the working-copy change). `<bin>` is `<directory of this skill's SKILL.md>/../../bin`, resolved from the skill's `<location>` in the system prompt.
229
+ Run this on the feature branch before any landing path. Ship the spec and implementation; strip only the plan. Let the seal publish the telemetry record to the orphan `gauntlet/telemetry` branch without changing the product checkout, index or HEAD. Keep in-progress records in Git common-dir metadata, outside the working tree. Resolve `<bin>` as `<directory of this skill's SKILL.md>/../../bin` from the skill's `<location>` in the system prompt.
230
230
 
231
231
  Read the existing artifact's path-only `**Spec:**` header (repo-relative, backticks stripped) before removal; pass that value as `--spec`, including for a line-1 ledger. Resolve the spec dirs with `gauntlet_setting({ key: "flowGuards" })` and use their sibling `plans` dirs for plan paths. Enumerate both PLAN_PATH candidates: the task plan paired by basename and `<sibling plans dir>/<spec stem>-ledger.md`. Preserve the spec and telemetry.
232
232
 
@@ -242,13 +242,13 @@ elif [ -f "$WORKTREE/$PLAN_PATH" ] && jj -R "$WORKTREE" root >/dev/null 2>&1; th
242
242
  fi
243
243
  done
244
244
 
245
- # Seal the telemetry record: stamp shipped, compute the diff, commit it once.
246
- # The bin detects a plain jj workspace (no .git) itself: jj diff, no commit step.
245
+ # Seal the telemetry record: stamp shipped, compute the diff, publish to gauntlet/telemetry.
246
+ # The bin reports publication unavailable in a plain jj workspace and keeps local metadata.
247
247
  # --option is pr for Options 1-2 and squash for Option 3; --spec is the artifact header's **Spec:** value (omit when no artifact is known).
248
248
  node <bin>/gauntlet-telemetry-seal.mjs --worktree "$WORKTREE" --option <pr|squash> --base <base-branch> --spec <spec path>
249
249
  ```
250
250
 
251
- The seal prints one line per record (`sealed <path>`, `already sealed <path>`) or one bare outcome (`no telemetry run`, `telemetry disabled`) and exits 0; print its stdout verbatim in the ship completion message. Any nonzero exit stops the flow before the first landing command, quoting the bin's line: exit 2 (`no record at <path>`, `unparseable record <path>: <reason>`) means this gauntlet run's recorder never armed or its record is corrupt; exit 1 is a usage, environment, or git/jj failure (`seal failed <path>: <reason>` restored the pre-seal bytes). The user decides; never land without the seal.
251
+ Print the seal's stdout verbatim in the ship completion message. Continue landing on exit 0 for every telemetry outcome, including `no telemetry run`, `telemetry: skipped (unparseable record <path>)` and `telemetry: not pushed (<reason>)`; missing or malformed records and publication failures never block shipping. Retain an unparseable record in place and preserve unpublished telemetry for retry. In a plain jj workspace, report `telemetry: not pushed (jj workspace: no git remote)` and retain local metadata. Stop before the first landing command only on exit 1 for a usage error (invalid arguments or base), quote the bin's line and ask the user how to proceed.
252
252
  #### PR-subject row 1: Push to the PR branch
253
253
  For the close-absorbed-PR question, start the message with `gauntlet: gate=finishing-a-development-branch/pr-subject-row-1`. Read and apply [menu-layout](../brainstorming/reference/menu-layout.md). Do not report `done pushed` while this gate awaits an answer. Capture the scoped verification set and accepted-complete task/thread evidence before the strip. Refetch threads per recorded PR using the paginated single-`$n` query (one invocation per PR) owned by [pr-head](../using-git-worktrees/reference/pr-head.md); retain PR attribution and use the query's location rendering. For home, use the table below and omit threads now resolved on GitHub. For members, copy recorded dispositions and mark newly resolved or newly opened states from the refresh; never use home's first-parent history as member-fix proof.
254
254
  | Refetched thread / evidence | Resolution |
@@ -346,7 +346,7 @@ git -C "$PRIMARY" commit -m "<imperative summary> (ref <ticket-id>)"
346
346
  (cd "$PRIMARY" && <the Step 1 set>)
347
347
  ```
348
348
 
349
- The plan was already removed and the telemetry record sealed on the branch, so the staged squash tree carries the spec, the telemetry record, and the implementation - never the plan.
349
+ Keep only the spec and implementation in the staged squash tree; the stripped plan stays in feature history and the sealed telemetry record belongs to `gauntlet/telemetry`.
350
350
 
351
351
  The post-squash re-verify is not optional - `git merge --squash` can surface conflict-resolution mistakes the worktree-side run couldn't catch. For a ledger, run the per-pair post-squash recipe in [verification-set.md](reference/verification-set.md) instead of the non-ledger subshell above.
352
352
 
@@ -356,7 +356,7 @@ Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMA
356
356
 
357
357
  #### Option 4: Keep As-Is
358
358
 
359
- Report: "Keeping branch <name>. Worktree preserved at <path>." The telemetry record stays untracked and in_progress in the worktree; a later finishing run on this worktree seals it.
359
+ Report: "Keeping branch <name>. Worktree preserved at <path>." Leave telemetry in local metadata; Keep As-Is publishes no record.
360
360
 
361
361
  **Don't cleanup worktree.**
362
362
 
@@ -374,7 +374,7 @@ Type 'discard' to confirm.
374
374
 
375
375
  Wait for exact confirmation.
376
376
 
377
- If confirmed: Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMARY" branch -D "$FEATURE"`. The record is git-excluded, so git worktree remove needs no --force; it goes with the worktree.
377
+ If confirmed: Cleanup worktree (Step 6), then, if Step 6 removed the worktree, `git -C "$PRIMARY" branch -D "$FEATURE"`. Publish no telemetry for Discard; leave common-dir metadata outside worktree cleanup.
378
378
 
379
379
  ### Step 6: Cleanup Workspace
380
380
 
@@ -449,12 +449,12 @@ A host-owned worktree (Step 6: no `home:` match, or the legacy "Otherwise") keep
449
449
  - **Fix:** Require typed "discard" confirmation
450
450
 
451
451
  **Skipping the strip-and-seal block in Options 1-3 (any path that lands on base)**
452
- - **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.
453
- - **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.
452
+ - **Problem:** Skipping the block ships an ephemeral plan or skips publication of the record the spec index reads.
453
+ - **Fix:** Run the block on `$WORKTREE` before the squash or push. Preserve the spec on `<base-branch>` and the telemetry record on `gauntlet/telemetry`; retain the plan only in feature history.
454
454
 
455
- **Widening the plan strip to the telemetry record**
456
- - **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.
457
- - **Fix:** Never `git rm` under the telemetry dir. Until the seal the record is untracked and git-excluded, so a plan strip cannot reach it; after the seal it is a committed deliverable.
455
+ **Treating telemetry as a checkout deliverable**
456
+ - **Problem:** Staging a recorder file pollutes the product diff and bypasses branch publication.
457
+ - **Fix:** Let the seal own publication from common-dir metadata; stage no telemetry record in the product checkout.
458
458
 
459
459
  ## Completion
460
460
  Start the final outcome message with exactly one terminal line before summaries. Start the message with `gauntlet: done pr-created` after successful normal or draft PR creation. Start the message with `gauntlet: done merged` after successful squash-merge and post-merge verification. Start the message with `gauntlet: done kept` after Keep. Start the message with `gauntlet: done discarded` only after typed discard authorization and successful discard. Start the message with `gauntlet: done pushed` after successful PR-subject push and body edit, only after the close-member gate is answered or no members qualify. Start the message with `gauntlet: stuck` followed on that line by a short concrete reason for an executed operation that fails, before error evidence or a return menu. Do not combine a terminal marker with a gate marker in one message; a subsequent menu starts a new gate message. Pending choices, implementation stalls and conformance escalation are not terminal outcomes. Read and apply [menu-layout](../brainstorming/reference/menu-layout.md). Preserve strip, seal, cleanup and ship-state rules below; terminal reporting does not complete ship after Discard or failure. Once the chosen option (Options 1-4 or PR-subject row 1 - not Discard) is executed successfully, mark the ship phase complete:
@@ -478,7 +478,7 @@ Once the merge (and any deploy) has landed, `/skill:check-delivery <ticket-ref>`
478
478
  - Run any step without the `<worktree-path>` argument
479
479
  - Auto-proceed past an undispositioned carried-open gap
480
480
  - Skip the strip-and-seal block before the Option 3 squash or the Option 1/2 push
481
- - Delete the telemetry record (`.pi/gauntlet/telemetry/**` or the configured `telemetry.dir`) on any path
481
+ - Delete common-dir telemetry metadata or stage it into the product checkout
482
482
 
483
483
  **Always:**
484
484
  - Verify, or apply the Step 1 skip rule, before offering options
@@ -19,7 +19,7 @@ For ledger commands outside a checkout's configured test-command matcher, verify
19
19
 
20
20
  ## Skip proof
21
21
 
22
- **Skip rule.** Skip the run only when this set passed on a known clean commit in the verify phase of this session and no edit or write landed outside `<telemetry.dir>` since. Check the current tree: `git -C "$WORKTREE" diff --quiet <commit the run passed on> -- . ':!<telemetry.dir>'` must exit 0, and `git -C "$WORKTREE" status --porcelain --untracked-files=all -- . ':!<telemetry.dir>'` must exit 0 with empty output (`<telemetry.dir>` defaults to `.pi/gauntlet/telemetry`). These checks cover committed, staged, unstaged, and untracked changes outside telemetry. Otherwise run it once; a missing verified commit, a failed check, or uncertainty means run.
22
+ **Skip rule.** Skip the run only when this set passed on a known clean commit in the verify phase of this session and no checkout edit or write landed since. Check the entire current tree: `git -C "$WORKTREE" diff --quiet <commit the run passed on> -- .` must exit 0, and `git -C "$WORKTREE" status --porcelain --untracked-files=all -- .` must exit 0 with empty output. Cover committed, staged, unstaged, and untracked changes without path exclusions; recorder metadata lives outside the working tree. Otherwise run it once; a missing verified commit, a failed check, or uncertainty means run.
23
23
 
24
24
  Apply the proof independently in each owning checkout. Post-squash verification is mandatory; the skip proof does not cover a changed primary tree.
25
25
 
@@ -31,4 +31,4 @@ For a fresh-session retry after a ledger was stripped, recover the committed pre
31
31
 
32
32
  ## Post-squash execution
33
33
 
34
- For Option 3, run each delivered leg's retained pairs in order as `(cd "<leg primary>" && <command>) > "$LOG_DIR/gate-<i>.log" 2>&1`, one command per pair, retaining its gate index and fail-fast exit reporting. For coordinated delivery, run only the just-squashed leg's pairs at that leg's delivery step; every remaining leg gets its own post-squash run when delivered. A failed-squash receipt retry runs those same pairs against the delivered primary, not the feature worktree, and never repeats the squash. Retain the Step 1 command set and execution form for non-ledger subjects.
34
+ For Option 3, run each delivered leg's retained pairs in order as `(cd "<leg primary>" && <command>) > "$LOG_DIR/gate-<i>.log" 2>&1`, one command per pair; print `gate <i> exit <code> <log path>` after each and stop on the first failure. For coordinated delivery, run only the just-squashed leg's pairs at that leg's delivery step; every remaining leg gets its own post-squash run when delivered. A failed-squash receipt retry runs those same pairs against the delivered primary, not the feature worktree, and never repeats the squash. Retain the Step 1 command set and execution form for non-ledger subjects.
@@ -92,6 +92,8 @@ Show evidence only in-place from record/helper paths, then re-ask. Queue all oth
92
92
 
93
93
  Run parent cleanup/stop only after all children finish, never on a live child's tree. For cleanup apply cleanup guards and ordered overrides. Keep the PR and post rows in the roster, set cleanup_done, and re-render with cleanup gone. Cleanup does not retire.
94
94
 
95
- For non-final stop run `cleanup.md` `## Non-final stop` after all children finish. Under terminal overlays leave checkout as found and print leftover path/branch. Delete only that PR's record, helper outputs, and log_path files under existing teardown rules, then retire it. Await all next-batch results and re-render every nonretired PR; repeat until all retire.
95
+ For non-final stop run `cleanup.md` `## Non-final stop` after all children finish. Under terminal overlays leave checkout as found and print leftover path/branch. Retain the PR's row from `post-selection-loop.md` `### Settlement rows` in the parent roster before deleting only that PR's record, helper outputs, and log_path files under existing teardown rules, then retire it. While any PR remains, start a retirement message with `gauntlet: retired #<N> - <reason>`, never `gauntlet: done` or `gauntlet: stuck`; keep private child result lines unchanged. Await all next-batch results and re-render every nonretired PR; repeat until all retire.
96
+
97
+ After the last PR retires and every selected cleanup or follow-on step finishes, render one final message. Start the message with `gauntlet: stuck retired` when any retained outcome is `stuck - <reason>`, otherwise `gauntlet: done retired`. Print every retained PR row below it in roster order, including PRs retired in earlier rounds. Emit no per-PR terminal message before this set settlement.
96
98
 
97
99
  Initialize parent tracker tasks `pr-<N> state: <record path>`; mark in_progress on dispatch, complete on retirement, failed on unretried failure. Child trackers are session-local stages, not parent roster tasks. Use a plain checklist if plan_tracker is unavailable.
@@ -22,7 +22,7 @@ Consequence clause for the menu row (`decision-menu.md`): `remove <worktree.path
22
22
 
23
23
  Follow the [shared layout](../../brainstorming/reference/menu-layout.md).
24
24
 
25
- Start the message with `gauntlet: stuck` followed on that line by the reason: use the obstacle's `stuck - ` text as the reason, or `stopped at <template id>` for initial, assess-terminal, and contract-exception. On a failed reset/assertion use the actual error instead. Put cleanup results below that one first line.
25
+ Finish the stop consequences below before the final message. Start the message with `gauntlet: stuck` followed on that line by the reason: use the obstacle's `stuck - ` text as the reason, or `stopped at <template id>` for initial, assess-terminal, and contract-exception. On a failed reset/assertion use the actual error instead. Print the PR row from `post-selection-loop.md` `### Settlement rows` with `stuck - <reason>` below the marker, then cleanup results. In set mode use `batch.md`'s per-PR retirement marker and retain the row for the final roster settlement.
26
26
 
27
27
  Enter here for `stop` on the initial, assess-terminal, obstacle, or contract-exception menu. On a `rebased locally` head, run `git -C <worktree.path> reset --hard <pre_head>` first; assert `HEAD == <pre_head>` and a clean tree. Stop on a failed assertion.
28
28
 
@@ -195,9 +195,15 @@ On completion or timeout, refresh then run steps 2-5 of Re-render plus step 1's
195
195
 
196
196
  Before posting or committing any external payload - review bodies, replies, commit subjects, tracker comments - re-read it against SKILL.md `## Wording rules` and any `## comms style` rules: ASCII only; no headings or template scaffolding under ~150 words; findings `file:line`-specific where one exists; ends on the fix or asked action, not a recap; never invents content to fill a section. The `AC coverage` block is the one exception: it posts verbatim, heading and checkbox rows included.
197
197
 
198
+ ### Settlement rows
199
+
200
+ Emit the terminal message only after every selected follow-on step finishes and the cleanup pick is answered (run or declined by stop); a report or menu is not settlement. Below its marker print one plain `#<N>@<head sha>: <outcome>[ (<ticket ref>)]` row per PR in roster order, singleton included. Use the full assessed `headRefOid`, not the merge commit or a shortened display SHA. Use the last gathered head when assessment could not start. Append the parenthetical only when the PR title or body's first line names a ticket under the overrides `## Issue tracker` `refs:` convention; otherwise omit it. Use exactly `approved`, `merged`, `closed-pr`, `final`, `merge-queued`, or `stuck - <reason>`, retaining the actual stop/error reason. Keep rows without bullets, code fences or emphasis; put checkout paths and cleanup results after them.
201
+
202
+ For set mode, retain each row before deleting its PR record and follow `batch.md`'s retirement and final-settlement rules. Keep private child result lines unchanged.
203
+
198
204
  ### Teardown
199
205
 
200
- Start the message with `gauntlet: done approved` for stop on approved. Start the message with `gauntlet: done merged` for stop on own-merge. Start the message with `gauntlet: done closed-pr` for stop on closed-pr. Start the message with `gauntlet: done final` for stop on final. Start the message with `gauntlet: done merge-queued` for stop on merge-queued. Put the checkout path and local branch below the terminal line. A report Verdict above a menu is not a terminal. Start the message with `gauntlet: stuck` followed by the actual error on any hard assertion failure, including a delegated fix-wave restore/assert failure, and end with the actual error; do not render a continuation menu.
206
+ Start the message with `gauntlet: done approved` for stop on approved. Start the message with `gauntlet: done merged` for stop on own-merge. Start the message with `gauntlet: done closed-pr` for stop on closed-pr. Start the message with `gauntlet: done final` for stop on final. Start the message with `gauntlet: done merge-queued` for stop on merge-queued. Print the row from `### Settlement rows`, then the checkout path and local branch. A report Verdict above a menu is not a terminal. Start the message with `gauntlet: stuck` followed by the actual error on any hard assertion failure, including a delegated fix-wave restore/assert failure; print the PR row with `stuck - <actual error>` below it and end with the actual error, without a continuation menu. In set mode use `batch.md`'s per-PR retirement marker until every PR retires.
201
207
 
202
208
  Delegate non-final stop (initial, assess terminal, obstacle, contract exception) to `cleanup.md` `## Non-final stop`. Keep terminal cleanup behind its own pick; terminal stop leaves the checkout as found and reports its path and local branch. Render cleanup only on terminal templates; never run cleanup steps 2-4 for a non-final stop.
203
209
 
@@ -13,12 +13,7 @@ Before starting the seed sequence, read the seed's header. A `**Home:**` header
13
13
  stop: `not tracked: <seed> - commit it first`. Line 1 of `<seed>` equal to
14
14
  `# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing` -> stop:
15
15
  `context draft, not a spec: <seed> - finish brainstorming`.
16
- 3. **Not shipped.** Read `<primary>/<telemetry.dir>/<rel with .md -> .yaml>`
17
- (`piGauntlet.telemetry.dir`, default `.pi/gauntlet/telemetry`). A `shipped_at:` or
18
- `abandoned_at:` line carrying a value -> stop:
19
- `spec already shipped at <ts> - write a superseding spec`. No record, or
20
- `in_progress`: continue; the extension folds this run into the record on
21
- `plan_check` pass.
16
+ 3. **Not shipped.** Resolve `piGauntlet.telemetry` from preset and primary project settings; `enabled: false` skips this check. Run `git -C <primary> rev-parse --git-common-dir`; resolve a relative result against `<primary>` and read `<git common dir>/gauntlet/telemetry/<rel with .md -> .yaml>`. When git resolution fails and `jj root` in `<primary>` succeeds, read `<jj root>/.jj/gauntlet/telemetry/<rel with .md -> .yaml>` instead. When the local record is absent, fetch `gauntlet/telemetry` from `telemetry.remote` (default `origin`) and read `<owner>/<repo>/brainstorming/<rel with .md -> .yaml>` from the fetched ref, using the last fetched ref on fetch failure. Degrade to the local-only check when no fetched ref is available, including jj-only workspaces. A valued `shipped_at:` or `abandoned_at:` stops: `spec already shipped at <ts> - write a superseding spec`. No record or `in_progress`: continue; let the extension bind on `plan_check` pass.
22
17
  4. **Name.** `<name>` = basename of `<rel>` minus `.md`, minus a leading `YYYY-MM-DD-`:
23
18
  `2026-09-17-gh-31-gauntlet-resume.md` -> `gh-31-gauntlet-resume`. Same shape as
24
19
  brainstorming's Filename Convention slug; wrappers receive it as `<name>`.
@@ -112,7 +112,7 @@ See Roast below. Surface source-gap findings at step 8; never apply them by inve
112
112
 
113
113
  ### 8 - Confirmation gate
114
114
 
115
- Finish initial preparation and roasts for all eligible tickets before the first gate. Present only the current ticket in ref order: decline skips it; revision rechecks and re-presents its exact diff; approval completes step 9 before the next ticket prints. Never treat a reply as approval of an unseen ticket. Present stopped tickets' draft/report at their turn with no approval option; preserve a split's N-body subset-selection gate. End with one line per ref: `<ref>: written | closed | close failed (<operation>: <reason>) | close by hand | dropped | declined | stopped | no-op | unfetched` (use the drafted title for create).
115
+ Finish initial preparation and roasts for all eligible tickets before the first gate. Present only the current ticket in ref order: decline skips it; revision rechecks and re-presents its exact diff; approval completes step 9 before the next ticket prints. Never treat a reply as approval of an unseen ticket. Present stopped tickets' draft/report at their turn with no approval option; preserve a split's N-body subset-selection gate. End with the settlement and per-ref rows from `reference/repo-audit.md` after the selected writes finish, including singleton creates.
116
116
 
117
117
  Present, per proposed issue:
118
118
 
@@ -206,6 +206,6 @@ Stop on the first nonzero exit without retry or fallback body write. Report the
206
206
 
207
207
  Drop runs nothing and ends `<drafted title>: dropped`; create anyway files only the displayed premise-rewritten body with its contradiction in the premise report. Preserve headless stops and split subset selection.
208
208
 
209
- Apply a reply only to the current ticket: decline skips it; revision rechecks and re-presents it; approval runs step 9's re-fetch, comparison, and body-then-comment write before the next gate. Treat another ticket's reply as no approval for an unseen diff. Give stopped tickets a hard-stop report without an approval number. Retain the N-body subset-selection gate for a split within one ticket. On an ended run, start the message with one batch settlement line: `gauntlet: done <counters>`. Start the message with `gauntlet: stuck` followed on that line by the dynamic counters instead when any `close-failed`, `close-by-hand`, or `unfetched` count is nonzero. Emit nonzero counters only, comma-joined without spaces, in this fixed order: `written,closed,declined,dropped,no-op,stopped,unfetched,close-failed,close-by-hand`; format each as `<key>=<count>`. Start the message with `gauntlet: done written=1` for an ended singleton whose only outcome is one written ticket; use the same counters rule for every singleton, including `gauntlet: done stopped=1`. Count an integrity stop left without recovery values as `stopped`, not declined; human decline and drop are not failures. While a hard-stop recovery question remains active, emit `gauntlet: question` and no settlement line. Keep ended stopped items settled, without reopening questions or offering new writes.
209
+ Apply a reply only to the current ticket: decline skips it; revision rechecks and re-presents it; approval runs step 9's re-fetch, comparison, and body-then-comment write before the next gate. Treat another ticket's reply as no approval for an unseen diff. Give stopped tickets a hard-stop report without an approval number. Retain the N-body subset-selection gate for a split within one ticket. On an ended run, after every selected write and follow-on step finishes, start the final message with one batch settlement line: `gauntlet: done <counters>`. Start the message with `gauntlet: stuck` followed on that line by the dynamic counters instead when any `close-failed`, `close-by-hand`, or `unfetched` count is nonzero. Emit nonzero counters only, comma-joined without spaces, in this fixed order: `written,closed,declined,dropped,no-op,stopped,unfetched,close-failed,close-by-hand`; format each as `<key>=<count>`. Start the message with `gauntlet: done written=1` for an ended singleton whose only outcome is one written ticket; use the same counters rule for every singleton, including `gauntlet: done stopped=1`. Count an integrity stop left without recovery values as `stopped`, not declined; human decline and drop are not failures. While a hard-stop recovery question remains active, emit `gauntlet: question` and no settlement line. Keep ended stopped items settled, without reopening questions or offering new writes.
210
210
 
211
- Preserve one line per ref below the settlement line: `<ref>: written | closed | close failed (<operation>: <reason>) | close by hand | dropped | declined | stopped | no-op | unfetched` (use the drafted title for create).
211
+ Print one plain line per item below the settlement line, in roster order, including singletons: `<ref>: <outcome>`. Use the actual ref returned by the write for a successful create or enrichment; use the drafted title only for a dropped or declined create with no ticket. Keep the outcome exactly one of `written`, `closed`, `close failed (<operation>: <reason>)`, `close by hand`, `dropped`, `declined`, `stopped`, `no-op`, `unfetched`; put other explanations on separate lines. Render rows without bullets, code fences or emphasis.