gsd-pi 2.12.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 (62) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/resource-loader.d.ts +2 -0
  3. package/dist/resource-loader.js +36 -1
  4. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  5. package/dist/resources/extensions/gsd/auto.ts +222 -11
  6. package/dist/resources/extensions/gsd/doctor.ts +195 -1
  7. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  8. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  9. package/dist/resources/extensions/gsd/preferences.ts +17 -1
  10. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  11. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  12. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  13. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  14. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  15. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  16. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  17. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  18. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  19. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  20. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  21. package/package.json +1 -1
  22. package/packages/pi-coding-agent/dist/cli/args.js +1 -1
  23. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  29. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  32. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  35. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  36. package/packages/pi-coding-agent/src/cli/args.ts +1 -1
  37. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  38. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  39. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  40. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  41. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  42. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  43. package/packages/pi-tui/dist/components/editor.js +64 -6
  44. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  45. package/packages/pi-tui/src/components/editor.ts +71 -6
  46. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  47. package/src/resources/extensions/gsd/auto.ts +222 -11
  48. package/src/resources/extensions/gsd/doctor.ts +195 -1
  49. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  50. package/src/resources/extensions/gsd/git-service.ts +11 -0
  51. package/src/resources/extensions/gsd/preferences.ts +17 -1
  52. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  53. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  54. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  55. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  56. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  57. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  58. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  59. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  60. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  61. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  62. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
@@ -0,0 +1,315 @@
1
+ /**
2
+ * worktree-e2e.test.ts -- End-to-end tests for worktree-isolated git flow.
3
+ *
4
+ * Covers 5 cross-cutting groups not tested by individual slice tests:
5
+ * 1. Full lifecycle chain (create -> slice commits -> merge to milestone -> merge to main)
6
+ * 2. Preference gating (shouldUseWorktreeIsolation with overrides)
7
+ * 3. merge_to_main mode resolution (getMergeToMainMode)
8
+ * 4. Self-heal in merge context (abortAndReset, withMergeHeal)
9
+ * 5. Doctor detection of orphaned worktrees
10
+ */
11
+
12
+ import {
13
+ mkdtempSync, mkdirSync, writeFileSync, rmSync,
14
+ existsSync, realpathSync, readFileSync,
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { tmpdir } from "node:os";
18
+ import { execSync } from "node:child_process";
19
+
20
+ import {
21
+ createAutoWorktree,
22
+ mergeMilestoneToMain,
23
+ mergeSliceToMilestone,
24
+ shouldUseWorktreeIsolation,
25
+ } from "../auto-worktree.ts";
26
+ import { getSliceBranchName } from "../worktree.ts";
27
+ import { abortAndReset, withMergeHeal, MergeConflictError } from "../git-self-heal.ts";
28
+ import { runGSDDoctor } from "../doctor.ts";
29
+ import { createTestContext } from "./test-helpers.ts";
30
+
31
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
32
+
33
+ // ---- Helpers ----
34
+
35
+ function run(cmd: string, cwd: string): string {
36
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
37
+ }
38
+
39
+ function createTempRepo(): string {
40
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-e2e-test-")));
41
+ run("git init", dir);
42
+ run("git config user.email test@test.com", dir);
43
+ run("git config user.name Test", dir);
44
+ writeFileSync(join(dir, "README.md"), "# test\n");
45
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
46
+ writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
47
+ run("git add .", dir);
48
+ run("git commit -m init", dir);
49
+ run("git branch -M main", dir);
50
+ return dir;
51
+ }
52
+
53
+ function makeRoadmap(
54
+ milestoneId: string,
55
+ title: string,
56
+ slices: Array<{ id: string; title: string }>,
57
+ ): string {
58
+ const sliceLines = slices.map(s => `- [x] **${s.id}: ${s.title}**`).join("\n");
59
+ return `# ${milestoneId}: ${title}\n\n## Slices\n${sliceLines}\n`;
60
+ }
61
+
62
+ function addSliceToMilestone(
63
+ repo: string,
64
+ wtPath: string,
65
+ milestoneId: string,
66
+ sliceId: string,
67
+ sliceTitle: string,
68
+ commits: Array<{ file: string; content: string; message: string }>,
69
+ ): void {
70
+ const normalizedPath = wtPath.replaceAll("\\", "/");
71
+ const marker = "/.gsd/worktrees/";
72
+ const idx = normalizedPath.indexOf(marker);
73
+ const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
74
+
75
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
76
+
77
+ run(`git checkout -b ${sliceBranch}`, wtPath);
78
+ for (const c of commits) {
79
+ writeFileSync(join(wtPath, c.file), c.content);
80
+ run("git add .", wtPath);
81
+ run(`git commit -m "${c.message}"`, wtPath);
82
+ }
83
+ run(`git checkout milestone/${milestoneId}`, wtPath);
84
+ mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
85
+ }
86
+
87
+ async function main(): Promise<void> {
88
+ const savedCwd = process.cwd();
89
+ const tempDirs: string[] = [];
90
+
91
+ try {
92
+ // ================================================================
93
+ // Group 1: Full lifecycle chain
94
+ // ================================================================
95
+ console.log("\n=== Full lifecycle: worktree -> slices -> milestone merge -> main ===");
96
+ {
97
+ const repo = createTempRepo();
98
+ tempDirs.push(repo);
99
+
100
+ // Count commits on main before
101
+ const mainLogBefore = run("git log --oneline main", repo);
102
+ const commitCountBefore = mainLogBefore.split("\n").length;
103
+
104
+ // Create worktree for M001
105
+ const wtPath = createAutoWorktree(repo, "M001");
106
+ tempDirs.push(wtPath);
107
+ assertTrue(existsSync(wtPath), "worktree directory created");
108
+
109
+ // Add two slices with commits
110
+ addSliceToMilestone(repo, wtPath, "M001", "S01", "Add auth", [
111
+ { file: "auth.ts", content: "export const auth = true;\n", message: "feat: add auth" },
112
+ ]);
113
+ addSliceToMilestone(repo, wtPath, "M001", "S02", "Add dashboard", [
114
+ { file: "dash.ts", content: "export const dash = true;\n", message: "feat: add dashboard" },
115
+ ]);
116
+
117
+ // Build roadmap content
118
+ const roadmapContent = makeRoadmap("M001", "First milestone", [
119
+ { id: "S01", title: "Add auth" },
120
+ { id: "S02", title: "Add dashboard" },
121
+ ]);
122
+
123
+ // Merge milestone to main
124
+ process.chdir(wtPath);
125
+ const result = mergeMilestoneToMain(repo, "M001", roadmapContent);
126
+ process.chdir(savedCwd);
127
+
128
+ // Assert exactly one new commit on main
129
+ const mainLogAfter = run("git log --oneline main", repo);
130
+ const commitCountAfter = mainLogAfter.split("\n").length;
131
+ assertEq(commitCountAfter, commitCountBefore + 1, "exactly one new commit on main");
132
+
133
+ // Commit message contains both slice titles
134
+ const lastCommitMsg = run("git log -1 --format=%B main", repo);
135
+ assertMatch(lastCommitMsg, /Add auth/, "commit message contains S01 title");
136
+ assertMatch(lastCommitMsg, /Add dashboard/, "commit message contains S02 title");
137
+
138
+ // Worktree directory removed
139
+ assertTrue(!existsSync(wtPath), "worktree directory removed after merge");
140
+
141
+ // Milestone branch deleted
142
+ const branches = run("git branch", repo);
143
+ assertTrue(!branches.includes("milestone/M001"), "milestone branch deleted");
144
+ }
145
+
146
+ // ================================================================
147
+ // Group 2: Preference gating (shouldUseWorktreeIsolation)
148
+ // ================================================================
149
+ console.log("\n=== Preference gating ===");
150
+ {
151
+ const repo = createTempRepo();
152
+ tempDirs.push(repo);
153
+
154
+ // Override to branch mode
155
+ const branchResult = shouldUseWorktreeIsolation(repo, { isolation: "branch" });
156
+ assertEq(branchResult, false, "isolation=branch returns false");
157
+
158
+ // Override to worktree mode
159
+ const wtResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
160
+ assertEq(wtResult, true, "isolation=worktree returns true");
161
+
162
+ // Default (no legacy branches) returns true
163
+ const defaultResult = shouldUseWorktreeIsolation(repo);
164
+ assertEq(defaultResult, true, "new project defaults to worktree (true)");
165
+ }
166
+
167
+ // ================================================================
168
+ // Group 3: merge_to_main mode resolution
169
+ // ================================================================
170
+ console.log("\n=== merge_to_main mode ===");
171
+ {
172
+ // getMergeToMainMode reads from loadEffectiveGSDPreferences — test via legacy branch detection
173
+ // Instead, test that the function returns the default "milestone" when no prefs set
174
+ // (Cannot inject overridePrefs — function signature doesn't accept them)
175
+ // We verify the shouldUseWorktreeIsolation override path handles legacy detection
176
+ const repo = createTempRepo();
177
+ tempDirs.push(repo);
178
+
179
+ // Create a legacy gsd/*/* branch to test legacy detection
180
+ run("git checkout -b gsd/M001/S01", repo);
181
+ writeFileSync(join(repo, "legacy.txt"), "legacy\n");
182
+ run("git add .", repo);
183
+ run("git commit -m legacy", repo);
184
+ run("git checkout main", repo);
185
+
186
+ const legacyResult = shouldUseWorktreeIsolation(repo);
187
+ assertEq(legacyResult, false, "legacy gsd branches detected -> branch mode");
188
+
189
+ // Explicit worktree override wins over legacy detection
190
+ const overrideResult = shouldUseWorktreeIsolation(repo, { isolation: "worktree" });
191
+ assertEq(overrideResult, true, "explicit worktree override wins over legacy");
192
+ }
193
+
194
+ // ================================================================
195
+ // Group 4: Self-heal (abortAndReset, withMergeHeal)
196
+ // ================================================================
197
+ console.log("\n=== Self-heal ===");
198
+ {
199
+ // 4a: abortAndReset cleans up MERGE_HEAD
200
+ const repo = createTempRepo();
201
+ tempDirs.push(repo);
202
+
203
+ // Create conflicting branches
204
+ run("git checkout -b feature", repo);
205
+ writeFileSync(join(repo, "conflict.txt"), "feature content\n");
206
+ run("git add .", repo);
207
+ run("git commit -m feature", repo);
208
+ run("git checkout main", repo);
209
+ writeFileSync(join(repo, "conflict.txt"), "main content\n");
210
+ run("git add .", repo);
211
+ run("git commit -m main-change", repo);
212
+
213
+ // Trigger merge conflict
214
+ try { run("git merge feature", repo); } catch { /* expected */ }
215
+ assertTrue(existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD exists before abort");
216
+
217
+ const abortResult = abortAndReset(repo);
218
+ assertTrue(!existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after abort");
219
+ assertTrue(abortResult.cleaned.length > 0, "abortAndReset reports cleaned items");
220
+ }
221
+ {
222
+ // 4b: withMergeHeal throws MergeConflictError for real conflicts
223
+ const repo = createTempRepo();
224
+ tempDirs.push(repo);
225
+
226
+ run("git checkout -b conflict-branch", repo);
227
+ writeFileSync(join(repo, "file.txt"), "branch version\n");
228
+ run("git add .", repo);
229
+ run("git commit -m branch-ver", repo);
230
+ run("git checkout main", repo);
231
+ writeFileSync(join(repo, "file.txt"), "main version\n");
232
+ run("git add .", repo);
233
+ run("git commit -m main-ver", repo);
234
+
235
+ let caughtError: unknown = null;
236
+ try {
237
+ withMergeHeal(repo, () => {
238
+ execSync("git merge conflict-branch", { cwd: repo, stdio: "pipe" });
239
+ });
240
+ } catch (e) {
241
+ caughtError = e;
242
+ }
243
+ assertTrue(caughtError instanceof MergeConflictError, "withMergeHeal throws MergeConflictError");
244
+ if (caughtError instanceof MergeConflictError) {
245
+ assertTrue(caughtError.conflictedFiles.length > 0, "MergeConflictError has conflictedFiles");
246
+ }
247
+ }
248
+
249
+ // ================================================================
250
+ // Group 5: Doctor detects orphaned worktrees
251
+ // ================================================================
252
+ console.log("\n=== Doctor: orphaned worktree detection ===");
253
+ {
254
+ // Build a repo with a completed milestone
255
+ const repo = createTempRepo();
256
+ tempDirs.push(repo);
257
+
258
+ // Create completed milestone roadmap
259
+ const msDir = join(repo, ".gsd", "milestones", "M001");
260
+ mkdirSync(msDir, { recursive: true });
261
+ writeFileSync(join(msDir, "ROADMAP.md"), `---
262
+ id: M001
263
+ title: "Test Milestone"
264
+ ---
265
+
266
+ # M001: Test Milestone
267
+
268
+ ## Vision
269
+ Test
270
+
271
+ ## Success Criteria
272
+ - Done
273
+
274
+ ## Slices
275
+ - [x] **S01: Test slice** \`risk:low\` \`depends:[]\`
276
+ > After this: done
277
+
278
+ ## Boundary Map
279
+ _None_
280
+ `);
281
+ run("git add -A", repo);
282
+ run("git commit -m 'add milestone'", repo);
283
+
284
+ // Create orphaned worktree
285
+ mkdirSync(join(repo, ".gsd", "worktrees"), { recursive: true });
286
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", repo);
287
+
288
+ // Detect
289
+ const detect = await runGSDDoctor(repo);
290
+ const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
291
+ assertTrue(orphanIssues.length > 0, "doctor detects orphaned worktree");
292
+ assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
293
+
294
+ // Fix
295
+ const fixed = await runGSDDoctor(repo, { fix: true });
296
+ assertTrue(
297
+ fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")),
298
+ "doctor fix removes orphaned worktree",
299
+ );
300
+
301
+ // Verify gone
302
+ const wtList = run("git worktree list", repo);
303
+ assertTrue(!wtList.includes("milestone/M001"), "worktree gone after doctor fix");
304
+ }
305
+ } finally {
306
+ process.chdir(savedCwd);
307
+ for (const d of tempDirs) {
308
+ try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
309
+ }
310
+ }
311
+
312
+ report();
313
+ }
314
+
315
+ main();
@@ -120,15 +120,17 @@ export function worktreeBranchName(name: string): string {
120
120
  /**
121
121
  * Create a new git worktree under .gsd/worktrees/<name>/ with branch worktree/<name>.
122
122
  * The branch is created from the current HEAD of the main branch.
123
+ *
124
+ * @param opts.branch — override the default `worktree/<name>` branch name
123
125
  */
124
- export function createWorktree(basePath: string, name: string): WorktreeInfo {
126
+ export function createWorktree(basePath: string, name: string, opts: { branch?: string } = {}): WorktreeInfo {
125
127
  // Validate name: alphanumeric, hyphens, underscores only
126
128
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
127
129
  throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`);
128
130
  }
129
131
 
130
132
  const wtPath = worktreePath(basePath, name);
131
- const branch = worktreeBranchName(name);
133
+ const branch = opts.branch ?? worktreeBranchName(name);
132
134
 
133
135
  if (existsSync(wtPath)) {
134
136
  throw new Error(`Worktree "${name}" already exists at ${wtPath}`);
@@ -260,11 +262,11 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
260
262
  export function removeWorktree(
261
263
  basePath: string,
262
264
  name: string,
263
- opts: { deleteBranch?: boolean; force?: boolean } = {},
265
+ opts: { deleteBranch?: boolean; force?: boolean; branch?: string } = {},
264
266
  ): void {
265
267
  const wtPath = worktreePath(basePath, name);
266
268
  const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath;
267
- const branch = worktreeBranchName(name);
269
+ const branch = opts.branch ?? worktreeBranchName(name);
268
270
  const { deleteBranch = true, force = false } = opts;
269
271
 
270
272
  // If we're inside the worktree, move out first — git can't remove an in-use directory
@@ -105,16 +105,21 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
105
105
  const payload = event.payload as Record<string, unknown>;
106
106
  if (!payload) return;
107
107
 
108
- // Detect Anthropic provider. Prefer the model_select flag when available,
109
- // but fall back to checking the model name in the payload. model_select
110
- // may not fire when the session restores with the same model already set
111
- // (modelsAreEqual guard in the SDK suppresses the event). When model_select
112
- // HAS fired and said "not Anthropic" (e.g. Copilot serving claude-*),
113
- // respect that don't override with model name heuristic.
114
- const modelName = typeof payload.model === "string" ? payload.model : "";
115
- const isAnthropic = modelSelectFired
116
- ? isAnthropicProvider
117
- : modelName.startsWith("claude-");
108
+ // Detect Anthropic provider. Use the model object from the event (most
109
+ // reliable comes directly from the resolved Model), then fall back to
110
+ // the model_select flag, then to the model name heuristic (last resort).
111
+ // The model name heuristic is needed for session restores where
112
+ // modelsAreEqual suppresses model_select AND the SDK doesn't pass model.
113
+ const eventModel = event.model as { provider: string } | undefined;
114
+ let isAnthropic: boolean;
115
+ if (eventModel?.provider) {
116
+ isAnthropic = eventModel.provider === "anthropic";
117
+ } else if (modelSelectFired) {
118
+ isAnthropic = isAnthropicProvider;
119
+ } else {
120
+ const modelName = typeof payload.model === "string" ? payload.model : "";
121
+ isAnthropic = modelName.startsWith("claude-");
122
+ }
118
123
  if (!isAnthropic) return;
119
124
 
120
125
  // Strip thinking blocks from history to avoid signature validation errors