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,147 @@
1
+ /**
2
+ * auto-worktree.test.ts — Tests for auto-worktree lifecycle.
3
+ *
4
+ * Covers: create → detect → teardown, re-entry, path helpers.
5
+ * Runs in a real temp git repo.
6
+ */
7
+
8
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+
13
+ import {
14
+ createAutoWorktree,
15
+ teardownAutoWorktree,
16
+ isInAutoWorktree,
17
+ getAutoWorktreePath,
18
+ enterAutoWorktree,
19
+ getAutoWorktreeOriginalBase,
20
+ } from "../auto-worktree.ts";
21
+
22
+ import { createTestContext } from "./test-helpers.ts";
23
+
24
+ const { assertEq, assertTrue, report } = createTestContext();
25
+
26
+ function run(command: string, cwd: string): string {
27
+ return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
28
+ }
29
+
30
+ function createTempRepo(): string {
31
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "auto-wt-test-")));
32
+ run("git init", dir);
33
+ run("git config user.email test@test.com", dir);
34
+ run("git config user.name Test", dir);
35
+ // Create initial commit on main
36
+ writeFileSync(join(dir, "README.md"), "# test\n");
37
+ run("git add .", dir);
38
+ run("git commit -m init", dir);
39
+ // Ensure branch is called main
40
+ run("git branch -M main", dir);
41
+ return dir;
42
+ }
43
+
44
+ async function main(): Promise<void> {
45
+ const savedCwd = process.cwd();
46
+ let tempDir = "";
47
+
48
+ try {
49
+ tempDir = createTempRepo();
50
+
51
+ // Create .gsd/milestones/M003 with a dummy file (simulates planning artifacts)
52
+ const msDir = join(tempDir, ".gsd", "milestones", "M003");
53
+ mkdirSync(msDir, { recursive: true });
54
+ writeFileSync(join(msDir, "CONTEXT.md"), "# M003 Context\n");
55
+ run("git add .", tempDir);
56
+ run("git commit -m 'add milestone'", tempDir);
57
+
58
+ console.log("\n=== auto-worktree lifecycle ===");
59
+
60
+ // ─── createAutoWorktree ──────────────────────────────────────────
61
+ const wtPath = createAutoWorktree(tempDir, "M003");
62
+
63
+ assertTrue(existsSync(wtPath), "worktree directory exists after create");
64
+ assertEq(process.cwd(), wtPath, "process.cwd() is worktree path after create");
65
+
66
+ const branch = run("git branch --show-current", wtPath);
67
+ assertEq(branch, "milestone/M003", "git branch is milestone/M003");
68
+
69
+ assertTrue(
70
+ existsSync(join(wtPath, ".gsd", "milestones", "M003", "CONTEXT.md")),
71
+ "planning files inherited in worktree",
72
+ );
73
+
74
+ // ─── isInAutoWorktree ────────────────────────────────────────────
75
+ assertTrue(isInAutoWorktree(tempDir), "isInAutoWorktree returns true when inside");
76
+
77
+ // ─── getAutoWorktreeOriginalBase ─────────────────────────────────
78
+ assertEq(getAutoWorktreeOriginalBase(), tempDir, "originalBase returns temp dir");
79
+
80
+ // ─── getAutoWorktreePath ─────────────────────────────────────────
81
+ assertEq(getAutoWorktreePath(tempDir, "M003"), wtPath, "getAutoWorktreePath returns correct path");
82
+ assertEq(getAutoWorktreePath(tempDir, "M999"), null, "getAutoWorktreePath returns null for nonexistent");
83
+
84
+ // ─── teardownAutoWorktree ────────────────────────────────────────
85
+ teardownAutoWorktree(tempDir, "M003");
86
+
87
+ assertEq(process.cwd(), tempDir, "process.cwd() back to original after teardown");
88
+ assertTrue(!existsSync(wtPath), "worktree directory removed after teardown");
89
+ assertTrue(!isInAutoWorktree(tempDir), "isInAutoWorktree returns false after teardown");
90
+ assertEq(getAutoWorktreeOriginalBase(), null, "originalBase is null after teardown");
91
+
92
+ // ─── Re-entry: create again, exit without teardown, re-enter ─────
93
+ console.log("\n=== re-entry ===");
94
+
95
+ const wtPath2 = createAutoWorktree(tempDir, "M003");
96
+ assertTrue(existsSync(wtPath2), "worktree re-created");
97
+
98
+ // Manually chdir out (simulates pause/crash)
99
+ process.chdir(tempDir);
100
+
101
+ // enterAutoWorktree should re-enter
102
+ const entered = enterAutoWorktree(tempDir, "M003");
103
+ assertEq(process.cwd(), entered, "re-entered worktree via enterAutoWorktree");
104
+ assertEq(getAutoWorktreeOriginalBase(), tempDir, "originalBase restored on re-entry");
105
+ assertTrue(isInAutoWorktree(tempDir), "isInAutoWorktree true after re-entry");
106
+
107
+ // Cleanup
108
+ teardownAutoWorktree(tempDir, "M003");
109
+
110
+ // ─── Coexistence with manual worktree ─────────────────────────────
111
+ console.log("\n=== coexistence ===");
112
+
113
+ // Import createWorktree directly for manual worktree
114
+ const { createWorktree } = await import("../worktree-manager.ts");
115
+
116
+ // Create manual worktree (uses worktree/<name> branch)
117
+ const manualWt = createWorktree(tempDir, "feature-x");
118
+ assertTrue(existsSync(manualWt.path), "manual worktree exists");
119
+ assertEq(manualWt.branch, "worktree/feature-x", "manual worktree uses worktree/ prefix");
120
+
121
+ // Create auto-worktree alongside
122
+ const autoWtPath = createAutoWorktree(tempDir, "M003");
123
+ assertTrue(existsSync(autoWtPath), "auto-worktree coexists with manual");
124
+ assertTrue(existsSync(manualWt.path), "manual worktree still exists");
125
+
126
+ // Cleanup both
127
+ teardownAutoWorktree(tempDir, "M003");
128
+ const { removeWorktree } = await import("../worktree-manager.ts");
129
+ removeWorktree(tempDir, "feature-x");
130
+
131
+ // ─── Failure: split-brain prevention ──────────────────────────────
132
+ console.log("\n=== split-brain prevention ===");
133
+ // After teardown, originalBase should be null
134
+ assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
135
+
136
+ } finally {
137
+ // Always restore cwd and clean up
138
+ process.chdir(savedCwd);
139
+ if (tempDir && existsSync(tempDir)) {
140
+ rmSync(tempDir, { recursive: true, force: true });
141
+ }
142
+ }
143
+
144
+ report("auto-worktree");
145
+ }
146
+
147
+ main();
@@ -0,0 +1,246 @@
1
+ /**
2
+ * doctor-git.test.ts — Integration tests for doctor git health checks.
3
+ *
4
+ * Creates real temp git repos with deliberate broken state, runs runGSDDoctor,
5
+ * and asserts correct detection and fixing of all 4 git issue codes:
6
+ * orphaned_auto_worktree, stale_milestone_branch,
7
+ * corrupt_merge_state, tracked_runtime_files
8
+ */
9
+
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, 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 { runGSDDoctor } from "../doctor.ts";
16
+ import { createTestContext } from "./test-helpers.ts";
17
+
18
+ const { assertEq, assertTrue, report } = createTestContext();
19
+
20
+ function run(cmd: string, cwd: string): string {
21
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
22
+ }
23
+
24
+ /** Create a temp git repo with a completed milestone M001 in roadmap. */
25
+ function createRepoWithCompletedMilestone(): string {
26
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
27
+ run("git init", dir);
28
+ run("git config user.email test@test.com", dir);
29
+ run("git config user.name Test", dir);
30
+
31
+ // Initial commit
32
+ writeFileSync(join(dir, "README.md"), "# test\n");
33
+ run("git add .", dir);
34
+ run("git commit -m init", dir);
35
+ run("git branch -M main", dir);
36
+
37
+ // Create .gsd structure with milestone M001 — all slices done → complete
38
+ const msDir = join(dir, ".gsd", "milestones", "M001");
39
+ mkdirSync(msDir, { recursive: true });
40
+ writeFileSync(join(msDir, "ROADMAP.md"), `---
41
+ id: M001
42
+ title: "Test Milestone"
43
+ ---
44
+
45
+ # M001: Test Milestone
46
+
47
+ ## Vision
48
+ Test
49
+
50
+ ## Success Criteria
51
+ - Done
52
+
53
+ ## Slices
54
+ - [x] **S01: Test slice** \`risk:low\` \`depends:[]\`
55
+ > After this: done
56
+
57
+ ## Boundary Map
58
+ _None_
59
+ `);
60
+
61
+ // Commit .gsd files
62
+ run("git add -A", dir);
63
+ run("git commit -m 'add milestone'", dir);
64
+
65
+ return dir;
66
+ }
67
+
68
+ /** Create a repo with an in-progress milestone. */
69
+ function createRepoWithActiveMilestone(): string {
70
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
71
+ run("git init", dir);
72
+ run("git config user.email test@test.com", dir);
73
+ run("git config user.name Test", dir);
74
+
75
+ writeFileSync(join(dir, "README.md"), "# test\n");
76
+ run("git add .", dir);
77
+ run("git commit -m init", dir);
78
+ run("git branch -M main", dir);
79
+
80
+ const msDir = join(dir, ".gsd", "milestones", "M001");
81
+ mkdirSync(msDir, { recursive: true });
82
+ writeFileSync(join(msDir, "ROADMAP.md"), `---
83
+ id: M001
84
+ title: "Active Milestone"
85
+ ---
86
+
87
+ # M001: Active Milestone
88
+
89
+ ## Vision
90
+ Test
91
+
92
+ ## Success Criteria
93
+ - Done
94
+
95
+ ## Slices
96
+ - [ ] **S01: Test slice** \`risk:low\` \`depends:[]\`
97
+ > After this: done
98
+
99
+ ## Boundary Map
100
+ _None_
101
+ `);
102
+
103
+ run("git add -A", dir);
104
+ run("git commit -m 'add milestone'", dir);
105
+
106
+ return dir;
107
+ }
108
+
109
+ async function main(): Promise<void> {
110
+ const cleanups: string[] = [];
111
+
112
+ try {
113
+ // ─── Test 1: Orphaned worktree detection & fix ─────────────────────
114
+ console.log("\n=== orphaned_auto_worktree ===");
115
+ {
116
+ const dir = createRepoWithCompletedMilestone();
117
+ cleanups.push(dir);
118
+
119
+ // Create worktree with milestone/M001 branch under .gsd/worktrees/
120
+ mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
121
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
122
+
123
+ const detect = await runGSDDoctor(dir);
124
+ const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
125
+ assertTrue(orphanIssues.length > 0, "detects orphaned worktree");
126
+ assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
127
+
128
+ const fixed = await runGSDDoctor(dir, { fix: true });
129
+ assertTrue(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree");
130
+
131
+ // Verify worktree is gone
132
+ const wtList = run("git worktree list", dir);
133
+ assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
134
+ }
135
+
136
+ // ─── Test 2: Stale milestone branch detection & fix ────────────────
137
+ console.log("\n=== stale_milestone_branch ===");
138
+ {
139
+ const dir = createRepoWithCompletedMilestone();
140
+ cleanups.push(dir);
141
+
142
+ // Create a milestone/M001 branch (no worktree)
143
+ run("git branch milestone/M001", dir);
144
+
145
+ const detect = await runGSDDoctor(dir);
146
+ const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch");
147
+ assertTrue(staleIssues.length > 0, "detects stale milestone branch");
148
+ assertEq(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001");
149
+
150
+ const fixed = await runGSDDoctor(dir, { fix: true });
151
+ assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
152
+
153
+ // Verify branch is gone
154
+ const branches = run("git branch --list 'milestone/*'", dir);
155
+ assertTrue(!branches.includes("milestone/M001"), "branch gone after fix");
156
+ }
157
+
158
+ // ─── Test 3: Corrupt merge state detection & fix ───────────────────
159
+ console.log("\n=== corrupt_merge_state ===");
160
+ {
161
+ const dir = createRepoWithCompletedMilestone();
162
+ cleanups.push(dir);
163
+
164
+ // Inject MERGE_HEAD into .git
165
+ const headHash = run("git rev-parse HEAD", dir);
166
+ writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
167
+
168
+ const detect = await runGSDDoctor(dir);
169
+ const mergeIssues = detect.issues.filter(i => i.code === "corrupt_merge_state");
170
+ assertTrue(mergeIssues.length > 0, "detects corrupt merge state");
171
+
172
+ const fixed = await runGSDDoctor(dir, { fix: true });
173
+ assertTrue(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state");
174
+
175
+ // Verify MERGE_HEAD is gone
176
+ assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix");
177
+ }
178
+
179
+ // ─── Test 4: Tracked runtime files detection & fix ─────────────────
180
+ console.log("\n=== tracked_runtime_files ===");
181
+ {
182
+ const dir = createRepoWithCompletedMilestone();
183
+ cleanups.push(dir);
184
+
185
+ // Force-add a runtime file
186
+ const activityDir = join(dir, ".gsd", "activity");
187
+ mkdirSync(activityDir, { recursive: true });
188
+ writeFileSync(join(activityDir, "test.log"), "log data\n");
189
+ run("git add -f .gsd/activity/test.log", dir);
190
+ run("git commit -m 'track runtime file'", dir);
191
+
192
+ const detect = await runGSDDoctor(dir);
193
+ const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
194
+ assertTrue(trackedIssues.length > 0, "detects tracked runtime files");
195
+
196
+ const fixed = await runGSDDoctor(dir, { fix: true });
197
+ assertTrue(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files");
198
+
199
+ // Verify file is no longer tracked
200
+ const tracked = run("git ls-files .gsd/activity/", dir);
201
+ assertEq(tracked, "", "runtime file untracked after fix");
202
+ }
203
+
204
+ // ─── Test 5: Non-git directory — graceful degradation ──────────────
205
+ console.log("\n=== non-git directory ===");
206
+ {
207
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
208
+ cleanups.push(dir);
209
+
210
+ // Create minimal .gsd structure (no git)
211
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
212
+
213
+ const result = await runGSDDoctor(dir);
214
+ const gitIssues = result.issues.filter(i =>
215
+ ["orphaned_auto_worktree", "stale_milestone_branch", "corrupt_merge_state", "tracked_runtime_files"].includes(i.code)
216
+ );
217
+ assertEq(gitIssues.length, 0, "no git issues in non-git directory");
218
+ // Should not throw — reaching here means no crash
219
+ assertTrue(true, "non-git directory does not crash");
220
+ }
221
+
222
+ // ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
223
+ console.log("\n=== active worktree safety ===");
224
+ {
225
+ const dir = createRepoWithActiveMilestone();
226
+ cleanups.push(dir);
227
+
228
+ // Create worktree for in-progress milestone under .gsd/worktrees/
229
+ mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
230
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
231
+
232
+ const detect = await runGSDDoctor(dir);
233
+ const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
234
+ assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
235
+ }
236
+
237
+ } finally {
238
+ for (const dir of cleanups) {
239
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
240
+ }
241
+ }
242
+
243
+ report();
244
+ }
245
+
246
+ main();
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
 
5
- import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope } from "../doctor.js";
5
+ import { formatDoctorReport, runGSDDoctor, summarizeDoctorIssues, filterDoctorIssues, selectDoctorScope, validateTitle } from "../doctor.js";
6
6
  import { createTestContext } from './test-helpers.ts';
7
7
 
8
8
  const { assertEq, assertTrue, report } = createTestContext();
@@ -471,6 +471,120 @@ Discovered an issue.
471
471
  rmSync(mhBase, { recursive: true, force: true });
472
472
  }
473
473
 
474
+ // ─── validateTitle: em dash and slash detection ────────────────────────
475
+ console.log("\n=== validateTitle: returns null for clean titles ===");
476
+ {
477
+ assertEq(validateTitle("Foundation"), null, "clean title passes");
478
+ assertEq(validateTitle("Build Core Systems"), null, "clean title with spaces passes");
479
+ assertEq(validateTitle("API v2 Integration"), null, "clean title with version passes");
480
+ assertEq(validateTitle(""), null, "empty title passes");
481
+ }
482
+
483
+ console.log("\n=== validateTitle: detects em dash ===");
484
+ {
485
+ const result = validateTitle("Foundation — Build Core");
486
+ assertTrue(result !== null, "detects em dash in title");
487
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
488
+ }
489
+
490
+ console.log("\n=== validateTitle: detects en dash ===");
491
+ {
492
+ const result = validateTitle("Phase 1 – Phase 2");
493
+ assertTrue(result !== null, "detects en dash in title");
494
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash for en dash");
495
+ }
496
+
497
+ console.log("\n=== validateTitle: detects forward slash ===");
498
+ {
499
+ const result = validateTitle("Client/Server");
500
+ assertTrue(result !== null, "detects forward slash in title");
501
+ assertTrue(result!.includes("forward slash"), "message mentions forward slash");
502
+ }
503
+
504
+ console.log("\n=== validateTitle: detects both em dash and slash ===");
505
+ {
506
+ const result = validateTitle("Client — Server/API");
507
+ assertTrue(result !== null, "detects both delimiters");
508
+ assertTrue(result!.includes("em/en dash"), "message mentions em/en dash");
509
+ assertTrue(result!.includes("forward slash"), "message mentions forward slash");
510
+ }
511
+
512
+ // ─── doctor detects delimiter_in_title for milestone ───────────────────
513
+ console.log("\n=== doctor detects em dash in milestone title ===");
514
+ {
515
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-test-"));
516
+ const dtGsd = join(dtBase, ".gsd");
517
+ const dtMDir = join(dtGsd, "milestones", "M001");
518
+ const dtSDir = join(dtMDir, "slices", "S01");
519
+ const dtTDir = join(dtSDir, "tasks");
520
+ mkdirSync(dtTDir, { recursive: true });
521
+
522
+ // Roadmap with em dash in milestone title
523
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation — Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
524
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
525
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
526
+
527
+ const report = await runGSDDoctor(dtBase, { fix: false });
528
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
529
+ assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for milestone with em dash");
530
+ const milestoneIssue = dtIssues.find(i => i.scope === "milestone");
531
+ assertTrue(milestoneIssue !== undefined, "delimiter issue has milestone scope");
532
+ assertEq(milestoneIssue?.severity, "warning", "delimiter issue has warning severity");
533
+ assertEq(milestoneIssue?.unitId, "M001", "delimiter issue unitId is M001");
534
+ assertTrue(milestoneIssue?.message?.includes("em/en dash") ?? false, "issue message mentions em/en dash");
535
+ assertEq(milestoneIssue?.fixable, false, "delimiter issue is not auto-fixable");
536
+
537
+ rmSync(dtBase, { recursive: true, force: true });
538
+ }
539
+
540
+ // ─── doctor detects delimiter_in_title for slice ────────────────────────
541
+ console.log("\n=== doctor detects em dash in slice title ===");
542
+ {
543
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-slice-"));
544
+ const dtGsd = join(dtBase, ".gsd");
545
+ const dtMDir = join(dtGsd, "milestones", "M001");
546
+ const dtSDir = join(dtMDir, "slices", "S01");
547
+ const dtTDir = join(dtSDir, "tasks");
548
+ mkdirSync(dtTDir, { recursive: true });
549
+
550
+ // Roadmap with em dash in slice title (milestone title is clean)
551
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Clean Milestone\n\n## Slices\n- [ ] **S01: Core — Foundation** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
552
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Core — Foundation\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
553
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
554
+
555
+ const report = await runGSDDoctor(dtBase, { fix: false });
556
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
557
+ assertTrue(dtIssues.length >= 1, "detects delimiter_in_title for slice with em dash");
558
+ const sliceIssue = dtIssues.find(i => i.scope === "slice");
559
+ assertTrue(sliceIssue !== undefined, "delimiter issue has slice scope");
560
+ assertEq(sliceIssue?.severity, "warning", "slice delimiter issue has warning severity");
561
+ assertEq(sliceIssue?.unitId, "M001/S01", "slice delimiter issue unitId is M001/S01");
562
+
563
+ rmSync(dtBase, { recursive: true, force: true });
564
+ }
565
+
566
+ // ─── doctor does NOT flag clean titles ──────────────────────────────────
567
+ console.log("\n=== doctor does NOT flag milestone with clean title ===");
568
+ {
569
+ const dtBase = mkdtempSync(join(tmpdir(), "gsd-doctor-dt-clean-"));
570
+ const dtGsd = join(dtBase, ".gsd");
571
+ const dtMDir = join(dtGsd, "milestones", "M001");
572
+ const dtSDir = join(dtMDir, "slices", "S01");
573
+ const dtTDir = join(dtSDir, "tasks");
574
+ mkdirSync(dtTDir, { recursive: true });
575
+
576
+ // Roadmap with clean titles (no delimiters)
577
+ writeFileSync(join(dtMDir, "M001-ROADMAP.md"), `# M001: Foundation Build Core\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`);
578
+ writeFileSync(join(dtSDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Tasks\n- [ ] **T01: Implement** \`est:10m\`\n Task.\n`);
579
+ writeFileSync(join(dtTDir, "T01-PLAN.md"), `# T01: Implement\n\n## Steps\n\n1. Do the thing.\n`);
580
+
581
+ const report = await runGSDDoctor(dtBase, { fix: false });
582
+ const dtIssues = report.issues.filter(i => i.code === "delimiter_in_title");
583
+ assertEq(dtIssues.length, 0, "no delimiter_in_title issues for clean titles");
584
+
585
+ rmSync(dtBase, { recursive: true, force: true });
586
+ }
587
+
474
588
  report();
475
589
  }
476
590