gsd-pi 2.11.0 → 2.13.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 (165) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/onboarding.js +3 -0
  3. package/dist/resource-loader.d.ts +2 -0
  4. package/dist/resource-loader.js +36 -1
  5. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  7. package/dist/resources/extensions/gsd/auto.ts +381 -13
  8. package/dist/resources/extensions/gsd/commands.ts +9 -3
  9. package/dist/resources/extensions/gsd/doctor.ts +254 -3
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  11. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  12. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  13. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  14. package/dist/resources/extensions/gsd/preferences.ts +209 -1
  15. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  16. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  17. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  19. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  20. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  22. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  23. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  25. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  26. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  27. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  28. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  29. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  30. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  31. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  32. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  33. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  34. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  35. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  37. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  38. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  39. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  40. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  41. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  42. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  43. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  44. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  45. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  46. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  47. package/dist/resources/extensions/gsd/types.ts +109 -0
  48. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  49. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  50. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  51. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  52. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  53. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  54. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  55. package/dist/wizard.js +1 -0
  56. package/package.json +1 -1
  57. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  58. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  59. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  60. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  61. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  62. package/packages/pi-agent-core/dist/agent.js +16 -0
  63. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  64. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  65. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  66. package/packages/pi-agent-core/dist/types.js.map +1 -1
  67. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  68. package/packages/pi-agent-core/src/agent.ts +24 -0
  69. package/packages/pi-agent-core/src/types.ts +98 -0
  70. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  71. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  72. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  73. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  74. package/packages/pi-ai/dist/models.generated.js +236 -0
  75. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  76. package/packages/pi-ai/dist/types.d.ts +1 -1
  77. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  78. package/packages/pi-ai/dist/types.js.map +1 -1
  79. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  80. package/packages/pi-ai/src/models.generated.ts +236 -0
  81. package/packages/pi-ai/src/types.ts +2 -1
  82. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  83. package/packages/pi-coding-agent/dist/cli/args.js +2 -1
  84. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  86. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  88. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  90. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  92. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  94. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  97. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  98. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  100. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  103. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  104. package/packages/pi-coding-agent/src/cli/args.ts +2 -1
  105. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  106. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  107. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  108. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  109. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  110. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  111. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  112. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  113. package/packages/pi-tui/dist/components/editor.js +64 -6
  114. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  115. package/packages/pi-tui/src/components/editor.ts +71 -6
  116. package/src/resources/extensions/bg-shell/index.ts +51 -7
  117. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  118. package/src/resources/extensions/gsd/auto.ts +381 -13
  119. package/src/resources/extensions/gsd/commands.ts +9 -3
  120. package/src/resources/extensions/gsd/doctor.ts +254 -3
  121. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  122. package/src/resources/extensions/gsd/git-service.ts +11 -0
  123. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  124. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  125. package/src/resources/extensions/gsd/preferences.ts +209 -1
  126. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  127. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  129. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  130. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  131. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  134. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  135. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  136. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  137. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  138. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  139. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  140. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  141. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  142. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  143. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  144. package/src/resources/extensions/gsd/templates/context.md +1 -1
  145. package/src/resources/extensions/gsd/templates/state.md +3 -3
  146. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  147. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  148. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  149. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  150. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  151. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  152. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  153. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  154. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  155. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  156. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  157. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  158. package/src/resources/extensions/gsd/types.ts +109 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  160. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  161. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
  162. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  163. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  164. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  165. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -0,0 +1,234 @@
1
+ /**
2
+ * git-self-heal.test.ts — Integration tests for git self-healing utilities.
3
+ *
4
+ * Uses real temporary git repos with deliberately broken state.
5
+ * No mocks — exercises actual git operations.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { rmSync } from "node:fs";
13
+ import assert from "node:assert/strict";
14
+ import {
15
+ abortAndReset,
16
+ withMergeHeal,
17
+ recoverCheckout,
18
+ formatGitError,
19
+ MergeConflictError,
20
+ } from "../git-self-heal.js";
21
+
22
+ // ─── Helpers ─────────────────────────────────────────────────────────
23
+
24
+ function makeTempRepo(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "gsd-self-heal-"));
26
+ execSync("git init", { cwd: dir, stdio: "pipe" });
27
+ execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "pipe" });
28
+ execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" });
29
+ writeFileSync(join(dir, "README.md"), "# init\n");
30
+ execSync("git add -A && git commit -m 'init'", { cwd: dir, stdio: "pipe" });
31
+ return dir;
32
+ }
33
+
34
+ function cleanup(dir: string) {
35
+ try {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+
42
+ // ─── abortAndReset ───────────────────────────────────────────────────
43
+
44
+ console.log("── abortAndReset ──");
45
+
46
+ // Test: leftover MERGE_HEAD
47
+ {
48
+ const dir = makeTempRepo();
49
+ try {
50
+ // Create a conflicting branch
51
+ execSync("git checkout -b feature", { cwd: dir, stdio: "pipe" });
52
+ writeFileSync(join(dir, "file.txt"), "feature content\n");
53
+ execSync("git add -A && git commit -m 'feature'", { cwd: dir, stdio: "pipe" });
54
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
55
+ writeFileSync(join(dir, "file.txt"), "main content\n");
56
+ execSync("git add -A && git commit -m 'main change'", { cwd: dir, stdio: "pipe" });
57
+
58
+ // Create a merge conflict → MERGE_HEAD will exist
59
+ try {
60
+ execSync("git merge feature", { cwd: dir, stdio: "pipe" });
61
+ } catch {
62
+ // expected conflict
63
+ }
64
+
65
+ assert.ok(existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should exist before abort");
66
+
67
+ const result = abortAndReset(dir);
68
+ assert.ok(result.cleaned.some((s) => s.includes("aborted merge")), "should report aborted merge");
69
+ assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should be gone after abort");
70
+
71
+ console.log(" ✓ cleans up leftover MERGE_HEAD");
72
+ } finally {
73
+ cleanup(dir);
74
+ }
75
+ }
76
+
77
+ // Test: leftover SQUASH_MSG (no MERGE_HEAD)
78
+ {
79
+ const dir = makeTempRepo();
80
+ try {
81
+ // Manually create a SQUASH_MSG to simulate leftover state
82
+ writeFileSync(join(dir, ".git", "SQUASH_MSG"), "leftover squash message\n");
83
+
84
+ const result = abortAndReset(dir);
85
+ assert.ok(result.cleaned.some((s) => s.includes("SQUASH_MSG")), "should report SQUASH_MSG removal");
86
+ assert.ok(!existsSync(join(dir, ".git", "SQUASH_MSG")), "SQUASH_MSG should be gone");
87
+
88
+ console.log(" ✓ cleans up leftover SQUASH_MSG");
89
+ } finally {
90
+ cleanup(dir);
91
+ }
92
+ }
93
+
94
+ // Test: clean state (no-op)
95
+ {
96
+ const dir = makeTempRepo();
97
+ try {
98
+ const result = abortAndReset(dir);
99
+ assert.deepStrictEqual(result.cleaned, [], "clean repo should produce empty cleaned array");
100
+
101
+ console.log(" ✓ no-op on clean state");
102
+ } finally {
103
+ cleanup(dir);
104
+ }
105
+ }
106
+
107
+ // ─── withMergeHeal ───────────────────────────────────────────────────
108
+
109
+ console.log("── withMergeHeal ──");
110
+
111
+ // Test: transient failure succeeds on retry
112
+ {
113
+ const dir = makeTempRepo();
114
+ try {
115
+ let callCount = 0;
116
+ const result = withMergeHeal(dir, () => {
117
+ callCount++;
118
+ if (callCount === 1) throw new Error("transient git error");
119
+ return "success";
120
+ });
121
+
122
+ assert.strictEqual(result, "success", "should return mergeFn result on retry");
123
+ assert.strictEqual(callCount, 2, "should have called mergeFn twice");
124
+
125
+ console.log(" ✓ transient failure succeeds on retry");
126
+ } finally {
127
+ cleanup(dir);
128
+ }
129
+ }
130
+
131
+ // Test: real conflict escalates immediately (no retry)
132
+ {
133
+ const dir = makeTempRepo();
134
+ try {
135
+ // Set up a real merge conflict
136
+ execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
137
+ writeFileSync(join(dir, "conflict.txt"), "branch A\n");
138
+ execSync("git add -A && git commit -m 'branch A'", { cwd: dir, stdio: "pipe" });
139
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
140
+ writeFileSync(join(dir, "conflict.txt"), "branch B\n");
141
+ execSync("git add -A && git commit -m 'branch B'", { cwd: dir, stdio: "pipe" });
142
+
143
+ let callCount = 0;
144
+ try {
145
+ withMergeHeal(dir, () => {
146
+ callCount++;
147
+ // Actually perform the conflicting merge
148
+ execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" });
149
+ });
150
+ assert.fail("should have thrown MergeConflictError");
151
+ } catch (err) {
152
+ assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`);
153
+ assert.strictEqual(callCount, 1, "should NOT retry on real conflict");
154
+ }
155
+
156
+ console.log(" ✓ real conflict escalates immediately without retry");
157
+ } finally {
158
+ cleanup(dir);
159
+ }
160
+ }
161
+
162
+ // ─── recoverCheckout ─────────────────────────────────────────────────
163
+
164
+ console.log("── recoverCheckout ──");
165
+
166
+ // Test: dirty index recovery
167
+ {
168
+ const dir = makeTempRepo();
169
+ try {
170
+ // Create a branch to checkout to
171
+ execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
172
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
173
+
174
+ // Dirty the index
175
+ writeFileSync(join(dir, "README.md"), "dirty changes\n");
176
+ execSync("git add README.md", { cwd: dir, stdio: "pipe" });
177
+
178
+ // Normal checkout would complain about dirty index
179
+ recoverCheckout(dir, "target-branch");
180
+
181
+ const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim();
182
+ assert.strictEqual(branch, "target-branch", "should be on target branch after recovery");
183
+
184
+ console.log(" ✓ recovers checkout with dirty index");
185
+ } finally {
186
+ cleanup(dir);
187
+ }
188
+ }
189
+
190
+ // Test: non-existent branch throws with context
191
+ {
192
+ const dir = makeTempRepo();
193
+ try {
194
+ try {
195
+ recoverCheckout(dir, "nonexistent-branch");
196
+ assert.fail("should have thrown");
197
+ } catch (err) {
198
+ assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error");
199
+ assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name");
200
+ }
201
+
202
+ console.log(" ✓ throws with context for non-existent branch");
203
+ } finally {
204
+ cleanup(dir);
205
+ }
206
+ }
207
+
208
+ // ─── formatGitError ──────────────────────────────────────────────────
209
+
210
+ console.log("── formatGitError ──");
211
+
212
+ {
213
+ const cases: Array<{ input: string; shouldContain: string; label: string }> = [
214
+ { input: "CONFLICT (content): Merge conflict in file.ts", shouldContain: "/gsd doctor", label: "merge conflict" },
215
+ { input: "error: pathspec 'foo' did not match any file(s)", shouldContain: "/gsd doctor", label: "checkout failure" },
216
+ { input: "HEAD detached at abc123", shouldContain: "/gsd doctor", label: "detached HEAD" },
217
+ { input: "Unable to create '/path/.git/index.lock': File exists", shouldContain: "/gsd doctor", label: "lock file" },
218
+ { input: "fatal: not a git repository", shouldContain: "/gsd doctor", label: "not a repo" },
219
+ { input: "some unknown error", shouldContain: "/gsd doctor", label: "unknown error" },
220
+ ];
221
+
222
+ for (const { input, shouldContain, label } of cases) {
223
+ const result = formatGitError(input);
224
+ assert.ok(result.includes(shouldContain), `${label}: should suggest /gsd doctor`);
225
+ console.log(` ✓ ${label} → suggests /gsd doctor`);
226
+ }
227
+
228
+ // Test with Error object
229
+ const result = formatGitError(new Error("CONFLICT in merge"));
230
+ assert.ok(result.includes("/gsd doctor"), "should handle Error objects");
231
+ console.log(" ✓ handles Error objects");
232
+ }
233
+
234
+ console.log("\n✅ All git-self-heal tests passed");
@@ -0,0 +1,107 @@
1
+ /**
2
+ * isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver.
3
+ *
4
+ * Tests three resolution paths:
5
+ * 1. Explicit git.isolation preference overrides everything
6
+ * 2. Legacy detection: existing gsd/*\/* branches = branch mode
7
+ * 3. Default: new project = worktree mode
8
+ */
9
+
10
+ import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { execSync } from "node:child_process";
14
+
15
+ import { shouldUseWorktreeIsolation } from "../auto-worktree.ts";
16
+ import { createTestContext } from "./test-helpers.ts";
17
+
18
+ const { assertEq, report } = createTestContext();
19
+
20
+ function run(command: string, cwd: string): string {
21
+ return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
22
+ }
23
+
24
+ function createTempRepo(): string {
25
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-test-")));
26
+ run("git init", dir);
27
+ run("git config user.email test@test.com", dir);
28
+ run("git config user.name Test", dir);
29
+ writeFileSync(join(dir, "README.md"), "# test\n");
30
+ run("git add .", dir);
31
+ run("git commit -m init", dir);
32
+ run("git branch -M main", dir);
33
+ return dir;
34
+ }
35
+
36
+ async function main(): Promise<void> {
37
+ const savedCwd = process.cwd();
38
+
39
+ console.log("\n=== shouldUseWorktreeIsolation ===");
40
+
41
+ // Test 1: New project with no gsd branches → defaults to worktree (true)
42
+ {
43
+ const dir = createTempRepo();
44
+ try {
45
+ const result = shouldUseWorktreeIsolation(dir);
46
+ assertEq(result, true, "new project defaults to worktree isolation");
47
+ } finally {
48
+ process.chdir(savedCwd);
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ }
52
+
53
+ // Test 2: Legacy project with gsd/*/* branches → returns false (branch mode)
54
+ {
55
+ const dir = createTempRepo();
56
+ try {
57
+ // Create a legacy gsd/*/* branch
58
+ run("git checkout -b gsd/M001/S01", dir);
59
+ writeFileSync(join(dir, "slice.md"), "# S01\n");
60
+ run("git add .", dir);
61
+ run("git commit -m 'slice work'", dir);
62
+ run("git checkout main", dir);
63
+
64
+ const result = shouldUseWorktreeIsolation(dir);
65
+ assertEq(result, false, "legacy project with gsd branches → branch mode");
66
+ } finally {
67
+ process.chdir(savedCwd);
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ }
71
+
72
+ // Test 3: Explicit preference override -- isolation: "worktree"
73
+ {
74
+ const dir = createTempRepo();
75
+ try {
76
+ // Create legacy branches that would normally trigger branch mode
77
+ run("git checkout -b gsd/M001/S01", dir);
78
+ writeFileSync(join(dir, "slice.md"), "# S01\n");
79
+ run("git add .", dir);
80
+ run("git commit -m 'slice work'", dir);
81
+ run("git checkout main", dir);
82
+
83
+ const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });
84
+ assertEq(result, true, "explicit isolation: worktree overrides legacy detection");
85
+ } finally {
86
+ process.chdir(savedCwd);
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ }
90
+
91
+ // Test 4: Explicit preference override -- isolation: "branch"
92
+ {
93
+ const dir = createTempRepo();
94
+ try {
95
+ // No legacy branches -- would normally default to worktree
96
+ const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" });
97
+ assertEq(result, false, "explicit isolation: branch overrides default");
98
+ } finally {
99
+ process.chdir(savedCwd);
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ }
103
+
104
+ report();
105
+ }
106
+
107
+ main();
@@ -0,0 +1,297 @@
1
+ // GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { createTestContext } from "./test-helpers.ts";
8
+ import {
9
+ checkPostUnitHooks,
10
+ getActiveHook,
11
+ resetHookState,
12
+ isRetryPending,
13
+ consumeRetryTrigger,
14
+ resolveHookArtifactPath,
15
+ runPreDispatchHooks,
16
+ persistHookState,
17
+ restoreHookState,
18
+ clearPersistedHookState,
19
+ getHookStatus,
20
+ formatHookStatus,
21
+ } from "../post-unit-hooks.ts";
22
+
23
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
24
+
25
+ // ─── Fixture Helpers ───────────────────────────────────────────────────────
26
+
27
+ function createFixtureBase(): string {
28
+ const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-"));
29
+ mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true });
30
+ return base;
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // Phase 1: Post-Unit Hook Tests
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ // ─── resolveHookArtifactPath ───────────────────────────────────────────────
38
+
39
+ console.log("\n=== resolveHookArtifactPath ===");
40
+
41
+ {
42
+ const base = "/project";
43
+
44
+ // Task-level
45
+ const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
46
+ assertEq(
47
+ taskPath,
48
+ join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
49
+ "task-level artifact path",
50
+ );
51
+
52
+ // Slice-level
53
+ const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
54
+ assertEq(
55
+ slicePath,
56
+ join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"),
57
+ "slice-level artifact path",
58
+ );
59
+
60
+ // Milestone-level
61
+ const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
62
+ assertEq(
63
+ milestonePath,
64
+ join(base, ".gsd", "M001", "REVIEW-PASS.md"),
65
+ "milestone-level artifact path",
66
+ );
67
+ }
68
+
69
+ // ─── resetHookState ────────────────────────────────────────────────────────
70
+
71
+ console.log("\n=== resetHookState ===");
72
+
73
+ {
74
+ resetHookState();
75
+ assertEq(getActiveHook(), null, "no active hook after reset");
76
+ assertTrue(!isRetryPending(), "no retry pending after reset");
77
+ assertEq(consumeRetryTrigger(), null, "no retry trigger after reset");
78
+ }
79
+
80
+ // ─── checkPostUnitHooks with no hooks configured ───────────────────────────
81
+
82
+ console.log("\n=== No hooks configured ===");
83
+
84
+ {
85
+ resetHookState();
86
+ const base = createFixtureBase();
87
+ try {
88
+ const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
89
+ assertEq(result, null, "returns null when no hooks configured");
90
+ } finally {
91
+ rmSync(base, { recursive: true, force: true });
92
+ }
93
+ }
94
+
95
+ // ─── Hook units don't trigger hooks (no hook-on-hook) ──────────────────────
96
+
97
+ console.log("\n=== Hook-on-hook prevention ===");
98
+
99
+ {
100
+ resetHookState();
101
+ const base = createFixtureBase();
102
+ try {
103
+ const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base);
104
+ assertEq(result, null, "hook units don't trigger other hooks");
105
+ } finally {
106
+ rmSync(base, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ // ─── consumeRetryTrigger clears state ──────────────────────────────────────
111
+
112
+ console.log("\n=== consumeRetryTrigger clears state ===");
113
+
114
+ {
115
+ resetHookState();
116
+ assertEq(consumeRetryTrigger(), null, "no trigger initially");
117
+ assertTrue(!isRetryPending(), "no retry initially");
118
+ }
119
+
120
+ // ─── Variable substitution in prompts ──────────────────────────────────────
121
+
122
+ console.log("\n=== Variable substitution ===");
123
+
124
+ {
125
+ const base = "/project";
126
+
127
+ // 3-part ID
128
+ const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md");
129
+ assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
130
+ assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
131
+ assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
132
+
133
+ // 2-part ID
134
+ const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
135
+ assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
136
+ assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
137
+
138
+ // 1-part ID
139
+ const path1 = resolveHookArtifactPath(base, "M002", "result.md");
140
+ assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // Phase 2: Pre-Dispatch Hook Tests
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ console.log("\n=== Pre-dispatch: no hooks configured ===");
148
+
149
+ {
150
+ const base = createFixtureBase();
151
+ try {
152
+ const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base);
153
+ assertEq(result.action, "proceed", "proceeds when no hooks");
154
+ assertEq(result.prompt, "original prompt", "prompt unchanged");
155
+ assertEq(result.firedHooks.length, 0, "no hooks fired");
156
+ } finally {
157
+ rmSync(base, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ console.log("\n=== Pre-dispatch: hook units bypass ===");
162
+
163
+ {
164
+ const base = createFixtureBase();
165
+ try {
166
+ const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base);
167
+ assertEq(result.action, "proceed", "hook units always proceed");
168
+ assertEq(result.prompt, "hook prompt", "hook prompt unchanged");
169
+ assertEq(result.firedHooks.length, 0, "no hooks fired for hook units");
170
+ } finally {
171
+ rmSync(base, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // Phase 3: State Persistence Tests
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+
179
+ console.log("\n=== State persistence: persist and restore ===");
180
+
181
+ {
182
+ const base = createFixtureBase();
183
+ try {
184
+ resetHookState();
185
+
186
+ // Persist empty state
187
+ persistHookState(base);
188
+ const filePath = join(base, ".gsd", "hook-state.json");
189
+ assertTrue(existsSync(filePath), "hook-state.json created");
190
+
191
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
192
+ assertEq(typeof content.savedAt, "string", "savedAt is a string");
193
+ assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
194
+ } finally {
195
+ rmSync(base, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ console.log("\n=== State persistence: restore from disk ===");
200
+
201
+ {
202
+ const base = createFixtureBase();
203
+ try {
204
+ resetHookState();
205
+
206
+ // Write a state file with some cycle counts
207
+ const stateFile = join(base, ".gsd", "hook-state.json");
208
+ writeFileSync(stateFile, JSON.stringify({
209
+ cycleCounts: {
210
+ "review/execute-task/M001/S01/T01": 2,
211
+ "simplify/execute-task/M001/S01/T02": 1,
212
+ },
213
+ savedAt: new Date().toISOString(),
214
+ }), "utf-8");
215
+
216
+ // Restore
217
+ restoreHookState(base);
218
+
219
+ // Verify by persisting and reading back
220
+ persistHookState(base);
221
+ const restored = JSON.parse(readFileSync(stateFile, "utf-8"));
222
+ assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
223
+ assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
224
+ } finally {
225
+ rmSync(base, { recursive: true, force: true });
226
+ }
227
+ }
228
+
229
+ console.log("\n=== State persistence: clear ===");
230
+
231
+ {
232
+ const base = createFixtureBase();
233
+ try {
234
+ resetHookState();
235
+
236
+ // Write then clear
237
+ const stateFile = join(base, ".gsd", "hook-state.json");
238
+ writeFileSync(stateFile, JSON.stringify({
239
+ cycleCounts: { "review/execute-task/M001/S01/T01": 3 },
240
+ savedAt: new Date().toISOString(),
241
+ }), "utf-8");
242
+
243
+ clearPersistedHookState(base);
244
+
245
+ const cleared = JSON.parse(readFileSync(stateFile, "utf-8"));
246
+ assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
247
+ } finally {
248
+ rmSync(base, { recursive: true, force: true });
249
+ }
250
+ }
251
+
252
+ console.log("\n=== State persistence: restore handles missing file ===");
253
+
254
+ {
255
+ const base = createFixtureBase();
256
+ try {
257
+ resetHookState();
258
+ // Should not throw
259
+ restoreHookState(base);
260
+ assertEq(getActiveHook(), null, "no active hook after restore from missing file");
261
+ } finally {
262
+ rmSync(base, { recursive: true, force: true });
263
+ }
264
+ }
265
+
266
+ console.log("\n=== State persistence: restore handles corrupt file ===");
267
+
268
+ {
269
+ const base = createFixtureBase();
270
+ try {
271
+ resetHookState();
272
+ writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8");
273
+ // Should not throw
274
+ restoreHookState(base);
275
+ assertEq(getActiveHook(), null, "no active hook after corrupt restore");
276
+ } finally {
277
+ rmSync(base, { recursive: true, force: true });
278
+ }
279
+ }
280
+
281
+ // ═══════════════════════════════════════════════════════════════════════════
282
+ // Phase 3: Hook Status Reporting Tests
283
+ // ═══════════════════════════════════════════════════════════════════════════
284
+
285
+ console.log("\n=== Hook status: no hooks ===");
286
+
287
+ {
288
+ resetHookState();
289
+ const entries = getHookStatus();
290
+ // No preferences file = no hooks
291
+ assertEq(entries.length, 0, "no entries when no hooks configured");
292
+
293
+ const formatted = formatHookStatus();
294
+ assertMatch(formatted, /No hooks configured/, "status message says no hooks");
295
+ }
296
+
297
+ report();