pi-gauntlet 5.9.0 → 5.10.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -1
  3. package/agents/spec-council-member.md +4 -2
  4. package/agents/spec-council-synthesizer.md +3 -1
  5. package/extensions/lib/checkout.test.ts +59 -0
  6. package/extensions/lib/checkout.ts +57 -0
  7. package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
  8. package/extensions/lib/gauntlet-settings-loader.ts +11 -7
  9. package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
  10. package/extensions/lib/phase-tracker-helpers.ts +16 -17
  11. package/extensions/lib/telemetry-paths.test.ts +10 -0
  12. package/extensions/lib/telemetry-paths.ts +9 -5
  13. package/extensions/phase-tracker.test.ts +75 -23
  14. package/extensions/phase-tracker.ts +20 -29
  15. package/extensions/telemetry.test.ts +51 -10
  16. package/extensions/telemetry.ts +57 -41
  17. package/extensions/verify-before-ship.test.ts +16 -0
  18. package/extensions/verify-before-ship.ts +4 -4
  19. package/package.json +1 -1
  20. package/skills/brainstorming/SKILL.md +80 -192
  21. package/skills/finishing-a-development-branch/SKILL.md +51 -88
  22. package/skills/gauntlet-resume/SKILL.md +1 -3
  23. package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
  24. package/skills/roasting-the-spec/SKILL.md +4 -2
  25. package/skills/subagent-driven-development/SKILL.md +18 -15
  26. package/skills/using-git-worktrees/SKILL.md +46 -39
  27. package/skills/verification-before-completion/reference/conformance-check.md +6 -6
  28. package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
  29. package/skills/writing-plans/SKILL.md +5 -3
  30. package/skills/writing-plans/reference/plan-contract.md +1 -1
@@ -9,12 +9,13 @@
9
9
  import { execFile } from "node:child_process";
10
10
  import { randomUUID } from "node:crypto";
11
11
  import * as nodeFs from "node:fs";
12
- import { dirname, join, posix } from "node:path";
12
+ import { dirname, isAbsolute, join, posix, resolve } from "node:path";
13
13
  import * as piRuntime from "@earendil-works/pi-coding-agent";
14
14
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { DEFAULT_TEST_COMMANDS, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
17
17
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
18
+ import { checkoutOf } from "./lib/checkout.ts";
18
19
  import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
19
20
  import { sha256 } from "./lib/plan-check.ts";
20
21
  import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
@@ -100,9 +101,9 @@
100
101
  }
101
102
 
102
103
  function realSettings(cwd: string): SettingsSnapshot {
103
- const { gauntlet, errors } = loadGauntletSettings(cwd);
104
+ const { gauntlet, errors, root } = loadGauntletSettings(cwd);
104
105
  const telemetry = resolveTelemetry(gauntlet);
105
- const sm = SettingsManager.create(cwd, getAgentDir());
106
+ const sm = SettingsManager.create(root, getAgentDir());
106
107
  const layer = (s: unknown) => ((s as { subagents?: { agentOverrides?: unknown } })?.subagents?.agentOverrides ?? undefined) as Record<string, unknown> | undefined;
107
108
  const preset = layer(sm.getGlobalSettings());
108
109
  const repo = layer(sm.getProjectSettings());
@@ -158,8 +159,7 @@
158
159
 
159
160
  export default function (pi: ExtensionAPI, deps: Deps = realDeps) {
160
161
  let phases: PhaseMap = emptyPhases();
161
- let toplevel: string | undefined;
162
- let inGit = false;
162
+ let toplevel: string | undefined; // toplevel of the bound record's checkout; undefined until bind
163
163
  let boundSpec: string | undefined; // repo-relative
164
164
  let record: TelemetryRecord | undefined;
165
165
  const predecessorLinks = new Set<string>();
@@ -180,12 +180,13 @@
180
180
  return s;
181
181
  };
182
182
 
183
- const ensureToplevel = async (ctx: ExtensionContext): Promise<string> => {
184
- if (toplevel) return toplevel;
185
- const r = await deps.git(["rev-parse", "--show-toplevel"], ctx.cwd);
186
- inGit = r.code === 0 && r.stdout.trim() !== "";
187
- toplevel = inGit ? r.stdout.trim() : ctx.cwd;
188
- return toplevel;
183
+ // Resolves a tool path (relative to ctx.cwd or absolute) to its owning checkout and
184
+ // checkout-relative key. undefined outside any checkout.
185
+ const locate = async (ctx: ExtensionContext, p: string): Promise<{ toplevel: string; rel: string } | undefined> => {
186
+ const candidateAbs = isAbsolute(p) ? p : resolve(ctx.cwd, p);
187
+ const co = await checkoutOf(candidateAbs, deps.git);
188
+ const rel = co ? repoRelativeToolPath(co.toplevel, ctx.cwd, p) : undefined;
189
+ return co && rel ? { toplevel: co.toplevel, rel } : undefined;
189
190
  };
190
191
 
191
192
  const live = (): Accumulators => record
@@ -228,7 +229,7 @@
228
229
  };
229
230
 
230
231
  const commitRecord = async () => {
231
- if (!record || !recordRel || frozen || !inGit) return;
232
+ if (!record || !recordRel || frozen || !toplevel) return;
232
233
  const text = deps.fs.readFile(abs(recordRel)) ?? "";
233
234
  if (text === lastCommitted) return;
234
235
  const paths = [recordRel, ...(oldRecordRel ? [oldRecordRel] : [])];
@@ -273,7 +274,8 @@
273
274
  return newRecord({ spec: specRel, session: sessionId, now: buffer[0]?.ts ?? deps.now(), runId: randomUUID() });
274
275
  };
275
276
 
276
- const bind = async (specRel: string, snap: SettingsSnapshot) => {
277
+ const bind = async (specRel: string, snap: SettingsSnapshot, specToplevel: string) => {
278
+ toplevel = specToplevel;
277
279
  currentDir = snap.telemetry.dir;
278
280
  boundSpec = specRel;
279
281
  recordRel = recordPathFor(currentDir, specRel);
@@ -302,7 +304,6 @@
302
304
  const approval = record.events.find((e) => e.kind === "phase" && e.name === "brainstorm" && (e.action === "complete" || e.action === "skip"));
303
305
  if (approval) record.approved_at ??= approval.ts;
304
306
  buffer = [];
305
- if (!inGit) warn("not a git repository: record is written but never committed");
306
307
  if (!settingsWarned && (snap.errors.length || snap.telemetry.warning)) {
307
308
  settingsWarned = true;
308
309
  if (snap.errors.length) warn(settingsErrorWarning(snap.errors));
@@ -312,6 +313,7 @@
312
313
  };
313
314
 
314
315
  const unbind = () => {
316
+ toplevel = undefined;
315
317
  boundSpec = undefined;
316
318
  record = undefined;
317
319
  recordRel = undefined;
@@ -347,8 +349,8 @@
347
349
  warn(`record move skipped: ${to} already exists`);
348
350
  return false;
349
351
  }
350
- const trackedResult = inGit ? await deps.git(["ls-files", "--error-unmatch", "--", from], toplevel!) : undefined;
351
- const tracked = trackedResult?.code === 0 && trackedResult.stdout.trim() !== "";
352
+ const trackedResult = await deps.git(["ls-files", "--error-unmatch", "--", from], toplevel!);
353
+ const tracked = trackedResult.code === 0 && trackedResult.stdout.trim() !== "";
352
354
  try {
353
355
  deps.fs.mkdirp(dirname(abs(to)));
354
356
  if (tracked) {
@@ -408,14 +410,13 @@
408
410
  const snap = enabledSettings(ctx);
409
411
  if (!snap) return;
410
412
  sessionId = ctx.sessionManager.getSessionId();
411
- await ensureToplevel(ctx);
412
413
  currentDir = snap.telemetry.dir;
413
414
  const replay = replayBranch(ctx.sessionManager.getBranch());
414
415
  phases = replay.phases;
415
416
  const candidate = replay.planCheckSpec ?? replay.lastSpecWrite;
416
- const rel = candidate ? repoRelativeToolPath(toplevel!, ctx.cwd, candidate) : undefined;
417
- if (rel && isSpecPath(rel)) {
418
- await bind(rel, snap);
417
+ const loc = candidate ? await locate(ctx, candidate) : undefined;
418
+ if (loc && isSpecPath(loc.rel)) {
419
+ await bind(loc.rel, snap, loc.toplevel);
419
420
  await flush(false);
420
421
  }
421
422
  });
@@ -436,19 +437,20 @@
436
437
  const snap = enabledSettings(ctx);
437
438
  if (!snap) return undefined;
438
439
  if (!sessionId) sessionId = ctx.sessionManager.getSessionId();
439
- await ensureToplevel(ctx);
440
440
  if (event.toolName === "phase_tracker" && !event.isError) {
441
441
  await applyPhaseDetails(event.details as never, snap);
442
442
  return undefined;
443
443
  }
444
444
  if (event.toolName === "plan_check" && !event.isError) {
445
445
  const d = event.details as { status?: string; specPath?: string; planPath?: string } | undefined;
446
- const spec = d?.specPath ? repoRelativeToolPath(toplevel!, ctx.cwd, d.specPath) : undefined;
447
- const plan = d?.planPath ? repoRelativeToolPath(toplevel!, ctx.cwd, d.planPath) : undefined;
446
+ const specLoc = d?.specPath ? await locate(ctx, d.specPath) : undefined;
447
+ const planLoc = d?.planPath ? await locate(ctx, d.planPath) : undefined;
448
+ const spec = specLoc?.rel;
449
+ const plan = planLoc?.rel;
448
450
  const pass = d?.status === "pass";
449
- if (pass && spec && spec !== boundSpec) {
451
+ if (pass && specLoc && (specLoc.rel !== boundSpec || specLoc.toplevel !== toplevel)) {
450
452
  if (record) unbind();
451
- await bind(spec, snap);
453
+ await bind(specLoc.rel, snap, specLoc.toplevel);
452
454
  }
453
455
  live().gates.plan_rounds += 1;
454
456
  pushEvent({ kind: "plan_check", pass, spec, plan });
@@ -457,9 +459,12 @@
457
459
  }
458
460
  if ((event.toolName === "write" || event.toolName === "edit" || event.toolName === "read") && !event.isError) {
459
461
  const rawPath = (event.input as { path?: unknown }).path;
460
- const rel = typeof rawPath === "string" ? repoRelativeToolPath(toplevel!, ctx.cwd, rawPath) : undefined;
461
- if (!rel) return undefined;
462
- return onSpecInteraction(event.toolName, rel, event, snap);
462
+ const loc = typeof rawPath === "string" ? await locate(ctx, rawPath) : undefined;
463
+ if (!loc) {
464
+ if (typeof rawPath === "string" && isSpecPath(toPosix(rawPath))) warn(`spec ${rawPath} is outside a git checkout; telemetry not recorded`);
465
+ return undefined;
466
+ }
467
+ return onSpecInteraction(event.toolName, loc, event, snap);
463
468
  }
464
469
  if (event.toolName === "subagent" && !event.isError) {
465
470
  await onSubagentResult(event.details, event.content);
@@ -493,23 +498,35 @@
493
498
  });
494
499
 
495
500
  // Binding + spec-write bookkeeping; the bound-spec hooks are defined in block 2.
496
- const onSpecInteraction = async (tool: "write" | "edit" | "read", rel: string, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
501
+ const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
502
+ const rel = loc.rel;
497
503
  if (!boundSpec) {
498
504
  if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
499
- await bind(rel, snap);
505
+ await bind(rel, snap, loc.toplevel);
500
506
  const patch = onBoundSpecWrite(tool, rel, event);
501
507
  await flush(true);
502
508
  return patch;
503
509
  } else if (isPlanPath(rel)) {
504
- const spec = planSpecHeader(deps.fs.readFile(abs(rel)) ?? "");
505
- const specRel = spec ? repoRelativeToolPath(toplevel!, toplevel!, spec) : undefined;
506
- if (specRel && deps.fs.exists(abs(specRel))) {
507
- await bind(specRel, snap);
510
+ const spec = planSpecHeader(deps.fs.readFile(join(loc.toplevel, rel)) ?? "");
511
+ const specRel = spec ? repoRelativeToolPath(loc.toplevel, loc.toplevel, spec) : undefined;
512
+ if (specRel && deps.fs.exists(join(loc.toplevel, specRel))) {
513
+ await bind(specRel, snap, loc.toplevel);
508
514
  await flush(true);
509
515
  }
510
516
  }
511
517
  return undefined;
512
518
  }
519
+ if (loc.toplevel !== toplevel) {
520
+ // Another checkout: a fresh run, never a rename of this record.
521
+ if ((tool === "write" || tool === "edit") && isSpecPath(rel)) {
522
+ unbind();
523
+ await bind(rel, snap, loc.toplevel);
524
+ const patch = onBoundSpecWrite(tool, rel, event);
525
+ await flush(true);
526
+ return patch;
527
+ }
528
+ return undefined;
529
+ }
513
530
  if (tool === "read") return undefined;
514
531
  if (rel === boundSpec) {
515
532
  const patch = onBoundSpecWrite(tool, rel, event);
@@ -603,7 +620,7 @@
603
620
  let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
604
621
 
605
622
  const computeDiff = async (snap: SettingsSnapshot) => {
606
- if (!record || !inGit) return;
623
+ if (!record || !toplevel) return;
607
624
  let baseRef: string | undefined;
608
625
  for (const ref of BASE_REFS) {
609
626
  if ((await deps.git(["rev-parse", "--verify", "--quiet", ref], toplevel!)).code === 0) {
@@ -665,13 +682,12 @@
665
682
  if (event.toolName === "write") {
666
683
  const rawPath = (event.input as { path?: unknown }).path;
667
684
  if (typeof rawPath === "string" && phases.brainstorm.status === "in_progress") {
668
- await ensureToplevel(ctx);
669
- const rel = repoRelativeToolPath(toplevel!, ctx.cwd, rawPath);
670
- if (rel && isSpecPath(rel)) {
671
- const recRel = recordPathFor(snap.telemetry.dir, rel);
672
- const existing = deps.fs.readFile(join(toplevel!, recRel));
685
+ const loc = await locate(ctx, rawPath);
686
+ if (loc && isSpecPath(loc.rel)) {
687
+ const recRel = recordPathFor(snap.telemetry.dir, loc.rel);
688
+ const existing = deps.fs.readFile(join(loc.toplevel, recRel));
673
689
  const parsed = existing ? parseRecord(existing) : undefined;
674
- if (parsed?.shipped_at) return { block: true, reason: guardReason(rel, parsed.shipped_at, recRel) };
690
+ if (parsed?.shipped_at) return { block: true, reason: guardReason(loc.rel, parsed.shipped_at, recRel) };
675
691
  }
676
692
  }
677
693
  return undefined;
@@ -72,6 +72,22 @@ test("gh pr create warns when unverified", async () => {
72
72
  assert.match(warning ?? "", /ran without verification/);
73
73
  });
74
74
 
75
+ test("git -C <worktree> push and merge --squash are ship events (AC 5)", async () => {
76
+ for (const cmd of ["git -C /p push -u origin HEAD", "git -C /p merge --squash f"]) {
77
+ const h = harness();
78
+ await editSource(h);
79
+ await h.emitEvent("tool_call", bashCall("s1", cmd));
80
+ assert.match(warningOf(await h.emitEvent("tool_result", bashResult("s1"))) ?? "", /ran without verification/, cmd);
81
+ }
82
+ });
83
+
84
+ test("a quoted mention of git push is not a ship event", async () => {
85
+ const h = harness();
86
+ await editSource(h);
87
+ await h.emitEvent("tool_call", bashCall("q1", 'rg "git push" skills/'));
88
+ assert.equal((await h.emitEvent("tool_result", bashResult("q1")))[0], undefined);
89
+ });
90
+
75
91
  test("a passing recognised run clears the warning for push", async () => {
76
92
  const h = harness();
77
93
  await editSource(h);
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Verify-before-ship extension
3
3
  *
4
- * Single-session verification gate for shipping commands (git push / gh pr create).
4
+ * Single-session verification gate for statement-start shipping commands: git merge
5
+ * --squash, git push, and gh pr create (with any git global flags).
5
6
  * Local commits are not ship events; commit-time review-cadence advisories live in
6
7
  * phase-tracker. Tracks whether a recognised verification command has succeeded
7
8
  * since the last source-file write; injects an advisory warning into the tool
@@ -32,8 +33,7 @@ import {
32
33
  settingsErrorWarning,
33
34
  } from "./lib/gauntlet-settings.ts";
34
35
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
35
-
36
- const SHIP_CMD = /\b(git\s+push|gh\s+pr\s+create)\b/;
36
+ import { matchShipStatement } from "./lib/telemetry-paths.ts";
37
37
 
38
38
  const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|rb|go|rs|java|swift|kt)$/;
39
39
  const TEST_PATH = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.|_test\.(py|go|rb)$/;
@@ -101,7 +101,7 @@ export default function (pi: ExtensionAPI) {
101
101
  return undefined;
102
102
  }
103
103
 
104
- if (SHIP_CMD.test(command) && !verified) {
104
+ if (matchShipStatement(command) && !verified) {
105
105
  if (settingsWarned && !settingsErrShown) {
106
106
  addShipWarning(event.toolCallId, settingsWarned);
107
107
  settingsErrShown = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "5.9.0",
3
+ "version": "5.10.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",