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.
- package/CHANGELOG.md +11 -0
- package/README.md +3 -1
- package/agents/spec-council-member.md +4 -2
- package/agents/spec-council-synthesizer.md +3 -1
- package/extensions/lib/checkout.test.ts +59 -0
- package/extensions/lib/checkout.ts +57 -0
- package/extensions/lib/gauntlet-settings-loader.test.ts +32 -0
- package/extensions/lib/gauntlet-settings-loader.ts +11 -7
- package/extensions/lib/phase-tracker-helpers.test.ts +17 -0
- package/extensions/lib/phase-tracker-helpers.ts +16 -17
- package/extensions/lib/telemetry-paths.test.ts +10 -0
- package/extensions/lib/telemetry-paths.ts +9 -5
- package/extensions/phase-tracker.test.ts +75 -23
- package/extensions/phase-tracker.ts +20 -29
- package/extensions/telemetry.test.ts +51 -10
- package/extensions/telemetry.ts +57 -41
- package/extensions/verify-before-ship.test.ts +16 -0
- package/extensions/verify-before-ship.ts +4 -4
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +80 -192
- package/skills/finishing-a-development-branch/SKILL.md +51 -88
- package/skills/gauntlet-resume/SKILL.md +1 -3
- package/skills/gauntlet-resume/reference/brief-contract.md +1 -1
- package/skills/roasting-the-spec/SKILL.md +4 -2
- package/skills/subagent-driven-development/SKILL.md +18 -15
- package/skills/using-git-worktrees/SKILL.md +46 -39
- package/skills/verification-before-completion/reference/conformance-check.md +6 -6
- package/skills/verification-before-completion/reference/settings-precedence.md +8 -6
- package/skills/writing-plans/SKILL.md +5 -3
- 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 {
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
163
|
-
const
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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(
|
|
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(
|
|
833
|
-
glob: (pattern) => globSync(pattern, { 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
|
-
|
|
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("--
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 () => {
|