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
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, test } from "node:test";
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, realpathSync } from "node:fs";
4
5
  import { createHash } from "node:crypto";
5
6
  import { tmpdir } from "node:os";
6
7
  import { isAbsolute, join } from "node:path";
@@ -26,6 +27,18 @@ const tempCwd = (settings?: unknown) => {
26
27
  return dir;
27
28
  };
28
29
 
30
+ // Primary checkout with one linked worktree at .worktrees/x. Real git: plan_check and Guard 2
31
+ // resolve checkouts with gitSync, and macOS temp paths need realpath to compare toplevels.
32
+ const gitPrimary = () => {
33
+ const primary = realpathSync(tempCwd());
34
+ const git = (...args: string[]) => execFileSync("git", ["-C", primary, ...args], { stdio: "pipe" });
35
+ git("init", "-q");
36
+ git("-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init");
37
+ mkdirSync(join(primary, "doc"));
38
+ git("worktree", "add", "-q", join(primary, ".worktrees", "x"), "-b", "x");
39
+ return { primary, worktree: join(primary, ".worktrees", "x") };
40
+ };
41
+
29
42
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
30
43
  type Phase = (typeof PHASES)[number];
31
44
  type Status = "pending" | "in_progress" | "complete" | "skipped";
@@ -720,7 +733,7 @@ const FIXTURE_SPEC = [
720
733
 
721
734
  const FIXTURE_PLAN = `# Fixture Plan
722
735
 
723
- **Spec:** \`spec.md\`
736
+ **Spec:** \`doc/specs/spec.md\`
724
737
 
725
738
  **Verification:** npm test
726
739
 
@@ -772,8 +785,10 @@ function writePlanFixture(
772
785
  ) {
773
786
  const planText = opts.mutatePlan ? opts.mutatePlan(FIXTURE_PLAN) : FIXTURE_PLAN;
774
787
  const specText = opts.mutateSpec ? opts.mutateSpec(FIXTURE_SPEC) : FIXTURE_SPEC;
775
- const planPath = join(dir, "plan.md");
776
- const specPath = join(dir, "spec.md");
788
+ const planPath = join(dir, "doc/plans/plan.md");
789
+ const specPath = join(dir, "doc/specs/spec.md");
790
+ mkdirSync(join(dir, "doc/plans"), { recursive: true });
791
+ mkdirSync(join(dir, "doc/specs"), { recursive: true });
777
792
  writeFileSync(planPath, planText);
778
793
  writeFileSync(specPath, specText);
779
794
  writeFileSync(join(dir, "file-a.ts"), "// a\n");
@@ -793,8 +808,45 @@ test("plan_check is registered", () => {
793
808
  assert.ok(h.tools.some((t) => t.name === "plan_check"));
794
809
  });
795
810
 
796
- test("plan_check pass: text, details, and stamp arming when a flow is entered", async () => {
811
+ test("plan_check roots at the plan's checkout: worktree plan passes from <primary>/doc (AC 1)", async () => {
812
+ const { primary, worktree } = gitPrimary();
813
+ const { planPath } = writePlanFixture(worktree);
814
+ const h = harness({ cwd: join(primary, "doc") });
815
+ await h.emit("session_start");
816
+ const tool = h.tools.find((t) => t.name === "plan_check")!;
817
+ const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as { content: { text: string }[]; details: { status: string; specPath: string } };
818
+ assert.match(res.content[0].text, /^PASS/);
819
+ assert.equal(res.details.specPath, join(worktree, "doc/specs/spec.md"));
820
+ });
821
+
822
+ test("plan_check outside any checkout: input finding", async () => {
797
823
  const dir = tempCwd();
824
+ const { planPath } = writePlanFixture(dir);
825
+ const h = harness({ cwd: dir });
826
+ await h.emit("session_start");
827
+ const tool = h.tools.find((t) => t.name === "plan_check")!;
828
+ const res = (await tool.execute("t1", { planPath }, undefined, undefined, h.ctx)) as { content: { text: string }[]; details: { status: string } };
829
+ assert.equal(res.details.status, "fail");
830
+ assert.match(res.content[0].text, /input @ line 0: plan is not inside a git checkout/);
831
+ });
832
+
833
+ const bashCall = (id: string, command: string) => ({ toolName: "bash", toolCallId: id, input: { command } });
834
+ const blocked = (results: unknown[]) => (results[0] as { block?: boolean } | undefined)?.block === true;
835
+
836
+ test("Guard 2 evaluates the command's target checkout (AC 3)", async () => {
837
+ const { primary, worktree } = gitPrimary();
838
+ const h = harness({ cwd: join(primary, "doc"), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
839
+ await h.emit("session_start");
840
+ assert.ok(blocked(await h.emitEvent("tool_call", bashCall("b1", "git switch -c y"))), "bare switch from <primary>/doc blocks");
841
+ assert.ok(blocked(await h.emitEvent("tool_call", bashCall("b2", `git -C ${primary} switch -c y`))), "-C <primary> blocks");
842
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b3", `git -C ${worktree} switch -c y`))), "-C <worktree> passes");
843
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b4", `cd ${worktree} && git checkout -b y`))), "cd <worktree> passes");
844
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b5", "git -C /nonexistent/dir switch -c y"))), "unresolvable target passes");
845
+ assert.ok(!blocked(await h.emitEvent("tool_call", bashCall("b6", "git worktree add .worktrees/y -b y"))), "git worktree is exempt");
846
+ });
847
+
848
+ test("plan_check pass: text, details, and stamp arming when a flow is entered", async () => {
849
+ const dir = gitPrimary().primary;
798
850
  const { planPath, specPath, planText, specText } = writePlanFixture(dir);
799
851
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
800
852
  await h.emit("session_start");
@@ -824,7 +876,7 @@ test("plan_check pass: text, details, and stamp arming when a flow is entered",
824
876
  });
825
877
 
826
878
  test("plan_check pass outside a flow: no stamp, plain-linter text", async () => {
827
- const dir = tempCwd();
879
+ const dir = gitPrimary().primary;
828
880
  const { planPath } = writePlanFixture(dir);
829
881
  const h = harness({ cwd: dir });
830
882
  await h.emit("session_start");
@@ -834,7 +886,7 @@ test("plan_check pass outside a flow: no stamp, plain-linter text", async () =>
834
886
  });
835
887
 
836
888
  test("plan_check fail: mutated plan body, no error key, clears stamp", async () => {
837
- const dir = tempCwd();
889
+ const dir = gitPrimary().primary;
838
890
  const { planPath } = writePlanFixture(dir, {
839
891
  mutatePlan: (t) => t.replace("implements helperFn() for parsing.", "implements the helper for parsing."),
840
892
  });
@@ -857,9 +909,9 @@ test("plan_check fail: mutated plan body, no error key, clears stamp", async ()
857
909
  });
858
910
 
859
911
  test("plan_check fail: unresolvable spec path", async () => {
860
- const dir = tempCwd();
912
+ const dir = gitPrimary().primary;
861
913
  const { planPath } = writePlanFixture(dir, {
862
- mutatePlan: (t) => t.replace("**Spec:** `spec.md`", "**Spec:** `missing-spec.md`"),
914
+ mutatePlan: (t) => t.replace("**Spec:** `doc/specs/spec.md`", "**Spec:** `missing-spec.md`"),
863
915
  });
864
916
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
865
917
  await h.emit("session_start");
@@ -881,11 +933,11 @@ test("plan_check fail: unresolvable spec path", async () => {
881
933
  });
882
934
 
883
935
  test("plan_check fail: header **Spec:** extraction is confined to the header (a path-only-looking line below the separator does not count)", async () => {
884
- const dir = tempCwd();
936
+ const dir = gitPrimary().primary;
885
937
  const { planPath } = writePlanFixture(dir, {
886
938
  mutatePlan: (t) =>
887
939
  t
888
- .replace("**Spec:** `spec.md`\n\n", "")
940
+ .replace("**Spec:** `doc/specs/spec.md`\n\n", "")
889
941
  .replace(
890
942
  "## Wave 1 - Two parallel tasks",
891
943
  "## Wave 1 - Two parallel tasks\n\n**Spec:** `not-the-header-spec.md`\n",
@@ -907,9 +959,9 @@ test("plan_check fail: header **Spec:** extraction is confined to the header (a
907
959
  });
908
960
 
909
961
  test("plan_check fail: plan header has no path-only **Spec:** line at all", async () => {
910
- const dir = tempCwd();
962
+ const dir = gitPrimary().primary;
911
963
  const { planPath } = writePlanFixture(dir, {
912
- mutatePlan: (t) => t.replace("**Spec:** `spec.md`\n\n", ""),
964
+ mutatePlan: (t) => t.replace("**Spec:** `doc/specs/spec.md`\n\n", ""),
913
965
  });
914
966
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
915
967
  await h.emit("session_start");
@@ -942,7 +994,7 @@ test("implement-start gate: rejects with no stamp, names plan_check remedy", asy
942
994
  });
943
995
 
944
996
  test("implement-start gate: stale plan bytes rejected, names the stale file", async () => {
945
- const dir = tempCwd();
997
+ const dir = gitPrimary().primary;
946
998
  const { planPath } = writePlanFixture(dir);
947
999
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
948
1000
  await h.emit("session_start");
@@ -958,7 +1010,7 @@ test("implement-start gate: stale plan bytes rejected, names the stale file", as
958
1010
  });
959
1011
 
960
1012
  test("implement-start gate: stale spec bytes rejected, names the stale file", async () => {
961
- const dir = tempCwd();
1013
+ const dir = gitPrimary().primary;
962
1014
  const { planPath, specPath } = writePlanFixture(dir);
963
1015
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
964
1016
  await h.emit("session_start");
@@ -974,7 +1026,7 @@ test("implement-start gate: stale spec bytes rejected, names the stale file", as
974
1026
  });
975
1027
 
976
1028
  test("implement-start gate: missing stamped file rejected, names the missing path", async () => {
977
- const dir = tempCwd();
1029
+ const dir = gitPrimary().primary;
978
1030
  const { planPath } = writePlanFixture(dir);
979
1031
  const h = harness({ cwd: dir, branch: readyForImplementBranch });
980
1032
  await h.emit("session_start");
@@ -1002,7 +1054,7 @@ test("implement-start gate: enforce false skips the gate entirely", async () =>
1002
1054
  });
1003
1055
 
1004
1056
  test("replay: a passing plan_check result restores the stamp without re-running the tool", async () => {
1005
- const dir = tempCwd();
1057
+ const dir = gitPrimary().primary;
1006
1058
  const { planPath, specPath } = writePlanFixture(dir);
1007
1059
  const passDetails = {
1008
1060
  status: "pass",
@@ -1022,7 +1074,7 @@ test("replay: a passing plan_check result restores the stamp without re-running
1022
1074
  });
1023
1075
 
1024
1076
  test("replay: pass then fail clears the stamp", async () => {
1025
- const dir = tempCwd();
1077
+ const dir = gitPrimary().primary;
1026
1078
  const { planPath, specPath } = writePlanFixture(dir);
1027
1079
  const passDetails = {
1028
1080
  status: "pass",
@@ -1045,7 +1097,7 @@ test("replay: pass then fail clears the stamp", async () => {
1045
1097
  });
1046
1098
 
1047
1099
  test("replay: a plan_check pass observed before any flow entry does not arm the stamp, and a live walk with no live plan_check is rejected", async () => {
1048
- const dir = tempCwd();
1100
+ const dir = gitPrimary().primary;
1049
1101
  const { planPath, specPath } = writePlanFixture(dir);
1050
1102
  const passDetails = {
1051
1103
  status: "pass",
@@ -1070,7 +1122,7 @@ test("replay: a plan_check pass observed before any flow entry does not arm the
1070
1122
  });
1071
1123
 
1072
1124
  test("replay: a phase_tracker reset clears the stamp", async () => {
1073
- const dir = tempCwd();
1125
+ const dir = gitPrimary().primary;
1074
1126
  const { planPath, specPath } = writePlanFixture(dir);
1075
1127
  const passDetails = {
1076
1128
  status: "pass",
@@ -1097,7 +1149,7 @@ test("replay: a phase_tracker reset clears the stamp", async () => {
1097
1149
  });
1098
1150
 
1099
1151
  test("replay via session_fork: a passing plan_check result restores the stamp without re-running the tool", async () => {
1100
- const dir = tempCwd();
1152
+ const dir = gitPrimary().primary;
1101
1153
  const { planPath, specPath } = writePlanFixture(dir);
1102
1154
  const passDetails = {
1103
1155
  status: "pass",
@@ -1124,7 +1176,7 @@ test("replay via session_fork: a passing plan_check result restores the stamp wi
1124
1176
  });
1125
1177
 
1126
1178
  test("replay via session_tree: a passing plan_check result restores the stamp without re-running the tool", async () => {
1127
- const dir = tempCwd();
1179
+ const dir = gitPrimary().primary;
1128
1180
  const { planPath, specPath } = writePlanFixture(dir);
1129
1181
  const passDetails = {
1130
1182
  status: "pass",
@@ -1152,7 +1204,7 @@ test("replay via session_tree: a passing plan_check result restores the stamp wi
1152
1204
  });
1153
1205
 
1154
1206
  test("replay via session_switch: pass then fail clears the stamp, rebuilt from replay rather than a live run", async () => {
1155
- const dir = tempCwd();
1207
+ const dir = gitPrimary().primary;
1156
1208
  const { planPath, specPath } = writePlanFixture(dir);
1157
1209
  const passDetails = {
1158
1210
  status: "pass",
@@ -9,7 +9,6 @@
9
9
  * Use phase_tracker for "what stage am I in?", plan_tracker for "which task?".
10
10
  */
11
11
 
12
- import { execSync } from "node:child_process";
13
12
  import { existsSync, globSync, readdirSync, readFileSync, readFileSync as readFileBytes } from "node:fs";
14
13
  import { isAbsolute, join, resolve } from "node:path";
15
14
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -25,6 +24,7 @@ import {
25
24
  settingsErrorWarning,
26
25
  } from "./lib/gauntlet-settings.ts";
27
26
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
27
+ import { checkoutOf, gitSync } from "./lib/checkout.ts";
28
28
  import { checkPlan, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
29
29
  import {
30
30
  checkSubstep,
@@ -34,6 +34,7 @@ import {
34
34
  implementWriteGuardApplies,
35
35
  markerBlockReason,
36
36
  nextGauntletEntered,
37
+ parseGitCommand,
37
38
  parseGitCommit,
38
39
  phaseLabel,
39
40
  recoverableEdge,
@@ -159,8 +160,8 @@ const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
159
160
  // Guard 2 — branch ops in place. `git switch` never targets a file path;
160
161
  // `git checkout -b/-B` is explicit branch creation. Bare `git checkout <x>`
161
162
  // is excluded (ambiguous with file checkout). `git worktree ...` is exempt.
162
- const BRANCH_SWITCH = new RegExp(STMT_START + "git\\s+switch\\b");
163
- const BRANCH_CHECKOUT = new RegExp(STMT_START + "git\\s+checkout\\s+-[bB]\\b");
163
+ const SWITCH_SUB = /switch(?=\s|$)/;
164
+ const CHECKOUT_BRANCH_SUB = /checkout\s+-[bB](?=\s|$)/;
164
165
  const GIT_WORKTREE = /\bgit\s+worktree\b/;
165
166
 
166
167
  // Guard 3 — bash mutation forms during brainstorm.
@@ -394,21 +395,6 @@ export default function (pi: ExtensionAPI) {
394
395
  // implementer's first write would trip a spurious advisory.
395
396
  const isSubagentChild = Number(process.env.PI_SUBAGENT_DEPTH ?? "0") > 0;
396
397
 
397
- const inPrimaryCheckout = (() => {
398
- try {
399
- const lines = execSync("git rev-parse --git-dir --git-common-dir", {
400
- encoding: "utf8",
401
- stdio: ["ignore", "pipe", "ignore"],
402
- timeout: 5000,
403
- })
404
- .trim()
405
- .split("\n");
406
- return lines.length === 2 && lines[0] === lines[1];
407
- } catch {
408
- return false;
409
- }
410
- })();
411
-
412
398
  // Auto-complete the implement phase from plan_tracker once every task is done,
413
399
  // but only when a skill has explicitly started it (TDD, or the SDD
414
400
  // execution preamble). The tracker never *fabricates* implement from task activity:
@@ -630,15 +616,16 @@ export default function (pi: ExtensionAPI) {
630
616
 
631
617
  const warnings: string[] = [];
632
618
 
633
- // Guard 2 — branch op in place (primary checkout only, brainstorm/plan/implement).
619
+ // Guard 2 - branch op in the primary checkout (brainstorm/plan/implement). The target
620
+ // is the command's own -C / leading-cd dir, else the session cwd; a linked worktree
621
+ // target passes, an unresolvable one passes (the guard protects the primary only).
634
622
  const gphase = activeGuardPhase();
635
- if (
636
- inPrimaryCheckout &&
637
- gphase &&
638
- !GIT_WORKTREE.test(command) &&
639
- (BRANCH_SWITCH.test(command) || BRANCH_CHECKOUT.test(command))
640
- ) {
641
- return { block: true, reason: branchBlockReason(gphase) };
623
+ if (gphase && !GIT_WORKTREE.test(command)) {
624
+ const form = parseGitCommand(command, SWITCH_SUB) ?? parseGitCommand(command, CHECKOUT_BRANCH_SUB);
625
+ if (form) {
626
+ const target = resolveRepoDir(form, ctx.cwd);
627
+ if ((await checkoutOf(target, gitSync))?.isPrimary === true) return { block: true, reason: branchBlockReason(gphase) };
628
+ }
642
629
  }
643
630
 
644
631
  // Marker commit guard — the context draft (brainstorming gather step) must be
@@ -805,6 +792,10 @@ export default function (pi: ExtensionAPI) {
805
792
  } catch {
806
793
  return fail([{ check: "input", line: 0, text: "", reason: `plan file unreadable at ${planAbs}` }], planAbs);
807
794
  }
795
+ const root = (await checkoutOf(planAbs, gitSync))?.toplevel;
796
+ if (!root) {
797
+ return fail([{ check: "input", line: 0, text: "", reason: "plan is not inside a git checkout" }], planAbs);
798
+ }
808
799
  const planText = planBytes.toString("utf8");
809
800
  const planLines = planText.split("\n");
810
801
  const planSeparatorIdx = planLines.findIndex((l) => l.trim() === "---");
@@ -820,7 +811,7 @@ export default function (pi: ExtensionAPI) {
820
811
  planAbs,
821
812
  );
822
813
  }
823
- const specAbs = isAbsolute(specPathRaw) ? specPathRaw : resolve(ctx.cwd, specPathRaw);
814
+ const specAbs = isAbsolute(specPathRaw) ? specPathRaw : resolve(root, specPathRaw);
824
815
  let specBytes: Buffer;
825
816
  try {
826
817
  specBytes = readFileBytes(specAbs);
@@ -829,8 +820,8 @@ export default function (pi: ExtensionAPI) {
829
820
  }
830
821
  const specText = specBytes.toString("utf8");
831
822
  const port: FsPort = {
832
- exists: (p) => existsSync(resolve(ctx.cwd, p)),
833
- glob: (pattern) => globSync(pattern, { cwd: ctx.cwd }),
823
+ exists: (p) => existsSync(resolve(root, p)),
824
+ glob: (pattern) => globSync(pattern, { cwd: root }),
834
825
  };
835
826
  const findings = checkPlan(planText, specText, port);
836
827
  if (findings.length > 0) return fail(findings, planAbs);
@@ -20,13 +20,15 @@
20
20
 
21
21
  type Handler = (event: any, ctx: any) => unknown;
22
22
 
23
- function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[]) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
23
+ function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
24
24
  const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
25
25
  tempDirs.push(root);
26
26
  mkdirSync(join(root, "doc/specs"), { recursive: true });
27
27
  mkdirSync(join(root, "doc/plans"), { recursive: true });
28
+ mkdirSync(join(root, ".worktrees/x/doc/specs"), { recursive: true });
28
29
  const handlers = new Map<string, Handler[]>();
29
30
  const gitCalls: string[][] = [];
31
+ const gitCwds: string[] = [];
30
32
  const readFileCalls: string[] = [];
31
33
  let clock = Date.parse("2026-09-17T10:00:00Z");
32
34
  const deps: Deps = {
@@ -34,11 +36,15 @@
34
36
  now: () => new Date((clock += 1000)).toISOString().replace(/\.\d{3}Z$/, "Z"),
35
37
  git: async (args, cwd) => {
36
38
  gitCalls.push(args);
37
- const forced = o.gitFail?.(args);
39
+ gitCwds.push(cwd);
40
+ const forced = o.gitFail?.(args, cwd);
38
41
  if (forced) return forced;
39
- if (args[0] === "rev-parse" && args.includes("--show-toplevel")) {
40
- const toplevel = o.cwdSub && cwd === join(root, o.cwdSub) ? root : cwd;
41
- return { code: 0, stdout: toplevel + "\n", stderr: "" };
42
+ if (args[0] === "rev-parse" && args.includes("--path-format=absolute")) {
43
+ const wt = /^(.*\/\.worktrees\/[^/]+)(\/|$)/.exec(cwd)?.[1];
44
+ const primary = cwd === root || cwd.startsWith(root + "/") ? root : cwd.replace(/\/(?:doc|\.pi)(?:\/.*)?$/, "");
45
+ const toplevel = wt ?? primary;
46
+ const gitDir = wt ? `${primary}/.git/worktrees/${wt.split("/").pop()}` : `${primary}/.git`;
47
+ return { code: 0, stdout: `${toplevel}\n${gitDir}\n${primary}/.git\n`, stderr: "" };
42
48
  }
43
49
  if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "gh-33\n", stderr: "" };
44
50
  if (args[0] === "config") return { code: 0, stdout: (args[1] === "user.name" ? "Tester" : "t@example.com") + "\n", stderr: "" };
@@ -71,7 +77,7 @@
71
77
  await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
72
78
  return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
73
79
  };
74
- return { root, handlers, emit, gitCalls, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
80
+ return { root, handlers, emit, gitCalls, gitCwds, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
75
81
  }
76
82
 
77
83
  const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
@@ -265,12 +271,47 @@
265
271
  fail = false;
266
272
  await h.phaseResult("complete", P({ brainstorm: "complete" }));
267
273
  assert.equal(h.commits().length, 2);
268
- const nogit = harness({ gitFail: (args) => (args[0] === "rev-parse" ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined) });
274
+ const outside = mkdtempSync(join(tmpdir(), "telemetry-outside-"));
275
+ tempDirs.push(outside);
276
+ const outsideSpec = join(outside, "doc/specs/outside.md");
277
+ const nogit = harness({ gitFail: (args, cwd) => (args[0] === "rev-parse" && cwd.startsWith(outside) ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined) });
269
278
  await nogit.phaseResult("start", P({ brainstorm: "in_progress" }));
279
+ await nogit.emit("tool_result", { toolName: "write", toolCallId: "outside", input: { path: outsideSpec }, content: [], isError: false, details: undefined });
270
280
  await nogit.writeSpec("doc/specs/a.md");
271
- rec = nogit.readRecord();
272
- assert.equal(nogit.commits().length, 0);
273
- assert.equal(rec.events.filter((e) => e.kind === "warning").length, 1);
281
+ assert.deepEqual(nogit.readRecord().events.filter((e) => e.kind === "warning").map((e: any) => e.message), [`spec ${outsideSpec} is outside a git checkout; telemetry not recorded`]);
282
+ });
283
+
284
+ test("worktree spec from a primary cwd: record keyed under the worktree, committed there (AC 8)", async () => {
285
+ const h = harness();
286
+ await h.emit("session_start", { type: "session_start", reason: "startup" });
287
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
288
+ await h.writeSpec(".worktrees/x/doc/specs/a.md");
289
+ const wt = join(h.root, ".worktrees/x");
290
+ assert.ok(existsSync(join(wt, ".pi/gauntlet/telemetry/doc/specs/a.yaml")), "record lives under the worktree toplevel");
291
+ assert.equal(existsSync(join(h.root, ".pi/gauntlet")), false, "nothing written under the primary checkout");
292
+ assert.ok(h.gitCwds.every((c) => c === wt || c.startsWith(wt + "/")), "no git call runs in the primary");
293
+ const commitIdx = h.gitCalls.findIndex((a) => a[0] === "commit");
294
+ assert.ok(commitIdx >= 0);
295
+ assert.equal(h.gitCwds[commitIdx], wt);
296
+ assert.deepEqual(h.gitCalls[commitIdx].slice(-1), [".pi/gauntlet/telemetry/doc/specs/a.yaml"]);
297
+ });
298
+
299
+ test("a spec in another checkout is a fresh bind, never a git mv", async () => {
300
+ for (const tool of ["write", "edit"] as const) {
301
+ const h = harness();
302
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
303
+ await h.writeSpec(".worktrees/x/doc/specs/a.md", "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing\n");
304
+ if (tool === "write") {
305
+ await h.writeSpec("doc/specs/a.md");
306
+ } else {
307
+ writeFileSync(join(h.root, "doc/specs/a.md"), "# Spec\n");
308
+ await h.emit("tool_result", { toolName: "edit", toolCallId: "e", input: { path: "doc/specs/a.md", oldText: "# Old\n", newText: "# Spec\n" }, content: [], isError: false, details: undefined });
309
+ }
310
+ assert.ok(existsSync(h.recordPath("doc/specs/a.md")), `primary record created for ${tool}`);
311
+ assert.ok(existsSync(join(h.root, ".worktrees/x/.pi/gauntlet/telemetry/doc/specs/a.yaml")), `worktree record left in place for ${tool}`);
312
+ assert.equal(h.gitCalls.some((a) => a[0] === "mv"), false);
313
+ assert.equal(h.readRecord("doc/specs/a.md").events.some((e) => e.kind === "spec_renamed"), false);
314
+ }
274
315
  });
275
316
 
276
317
  test("second session rehydrates all derived accumulators with exactly total and live blocks", async () => {