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,88 @@
1
+ /**
2
+ * preferences-git.test.ts — Validates git.isolation and git.merge_to_main preference fields.
3
+ */
4
+
5
+ import { createTestContext } from "./test-helpers.ts";
6
+ import { validatePreferences } from "../preferences.ts";
7
+
8
+ const { assertEq, assertTrue, report } = createTestContext();
9
+
10
+ async function main(): Promise<void> {
11
+ console.log("\n=== git.isolation validation ===");
12
+
13
+ // Valid values
14
+ {
15
+ const { preferences, errors } = validatePreferences({ git: { isolation: "worktree" } });
16
+ assertEq(errors.length, 0, "isolation: worktree — no errors");
17
+ assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved");
18
+ }
19
+ {
20
+ const { preferences, errors } = validatePreferences({ git: { isolation: "branch" } });
21
+ assertEq(errors.length, 0, "isolation: branch — no errors");
22
+ assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved");
23
+ }
24
+
25
+ // Invalid values
26
+ {
27
+ const { errors } = validatePreferences({ git: { isolation: "invalid" } });
28
+ assertTrue(errors.length > 0, "isolation: invalid — produces error");
29
+ assertTrue(errors[0].includes("isolation"), "isolation: invalid — error mentions isolation");
30
+ }
31
+ {
32
+ const { errors } = validatePreferences({ git: { isolation: 42 } });
33
+ assertTrue(errors.length > 0, "isolation: number — produces error");
34
+ }
35
+
36
+ // Undefined passes through
37
+ {
38
+ const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
39
+ assertEq(errors.length, 0, "isolation: undefined — no errors");
40
+ assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set");
41
+ }
42
+
43
+ console.log("\n=== git.merge_to_main validation ===");
44
+
45
+ // Valid values
46
+ {
47
+ const { preferences, errors } = validatePreferences({ git: { merge_to_main: "milestone" } });
48
+ assertEq(errors.length, 0, "merge_to_main: milestone — no errors");
49
+ assertEq(preferences.git?.merge_to_main, "milestone", "merge_to_main: milestone — value preserved");
50
+ }
51
+ {
52
+ const { preferences, errors } = validatePreferences({ git: { merge_to_main: "slice" } });
53
+ assertEq(errors.length, 0, "merge_to_main: slice — no errors");
54
+ assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main: slice — value preserved");
55
+ }
56
+
57
+ // Invalid values
58
+ {
59
+ const { errors } = validatePreferences({ git: { merge_to_main: "invalid" } });
60
+ assertTrue(errors.length > 0, "merge_to_main: invalid — produces error");
61
+ assertTrue(errors[0].includes("merge_to_main"), "merge_to_main: invalid — error mentions merge_to_main");
62
+ }
63
+ {
64
+ const { errors } = validatePreferences({ git: { merge_to_main: false } });
65
+ assertTrue(errors.length > 0, "merge_to_main: boolean — produces error");
66
+ }
67
+
68
+ // Undefined passes through
69
+ {
70
+ const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
71
+ assertEq(errors.length, 0, "merge_to_main: undefined — no errors");
72
+ assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set");
73
+ }
74
+
75
+ console.log("\n=== both fields together ===");
76
+ {
77
+ const { preferences, errors } = validatePreferences({
78
+ git: { isolation: "worktree", merge_to_main: "slice" },
79
+ });
80
+ assertEq(errors.length, 0, "both fields valid — no errors");
81
+ assertEq(preferences.git?.isolation, "worktree", "isolation preserved");
82
+ assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main preserved");
83
+ }
84
+
85
+ report();
86
+ }
87
+
88
+ main();
@@ -0,0 +1,226 @@
1
+ // GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { createTestContext } from "./test-helpers.ts";
5
+
6
+ const { assertEq, assertTrue, report } = createTestContext();
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ // Phase 1: Post-Unit Hook Config Tests
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+
12
+ console.log("\n=== Post-unit hook config validation ===");
13
+
14
+ {
15
+ const validHook = {
16
+ name: "test-hook",
17
+ after: ["execute-task"],
18
+ prompt: "Test prompt",
19
+ max_cycles: 2,
20
+ model: "claude-sonnet-4-6",
21
+ artifact: "TEST-RESULT.md",
22
+ retry_on: "TEST-ISSUES.md",
23
+ enabled: true,
24
+ };
25
+
26
+ assertEq(validHook.name, "test-hook", "valid hook has name");
27
+ assertEq(validHook.after.length, 1, "valid hook has one after entry");
28
+ assertEq(validHook.after[0], "execute-task", "valid hook triggers after execute-task");
29
+ assertTrue(validHook.max_cycles! <= 10, "max_cycles within limit");
30
+ assertTrue(validHook.max_cycles! >= 1, "max_cycles above minimum");
31
+ }
32
+
33
+ console.log("\n=== max_cycles clamping ===");
34
+
35
+ {
36
+ const clampedHigh = Math.max(1, Math.min(10, Math.round(15)));
37
+ assertEq(clampedHigh, 10, "max_cycles above 10 clamped to 10");
38
+
39
+ const clampedLow = Math.max(1, Math.min(10, Math.round(0)));
40
+ assertEq(clampedLow, 1, "max_cycles below 1 clamped to 1");
41
+
42
+ const clampedNeg = Math.max(1, Math.min(10, Math.round(-5)));
43
+ assertEq(clampedNeg, 1, "negative max_cycles clamped to 1");
44
+
45
+ const normal = Math.max(1, Math.min(10, Math.round(3)));
46
+ assertEq(normal, 3, "normal max_cycles passes through");
47
+ }
48
+
49
+ console.log("\n=== Post-unit hook merging ===");
50
+
51
+ {
52
+ const baseHooks = [
53
+ { name: "review", after: ["execute-task"], prompt: "base prompt" },
54
+ { name: "lint", after: ["plan-slice"], prompt: "lint code" },
55
+ ];
56
+
57
+ const overrideHooks = [
58
+ { name: "review", after: ["execute-task", "complete-slice"], prompt: "override prompt" },
59
+ { name: "security", after: ["execute-task"], prompt: "security check" },
60
+ ];
61
+
62
+ const merged = [...baseHooks];
63
+ for (const hook of overrideHooks) {
64
+ const idx = merged.findIndex(h => h.name === hook.name);
65
+ if (idx >= 0) {
66
+ merged[idx] = hook;
67
+ } else {
68
+ merged.push(hook);
69
+ }
70
+ }
71
+
72
+ assertEq(merged.length, 3, "merged has 3 hooks");
73
+ assertEq(merged[0].prompt, "override prompt", "review hook was overridden");
74
+ assertEq(merged[0].after.length, 2, "overridden review has 2 after entries");
75
+ assertEq(merged[1].name, "lint", "lint kept from base");
76
+ assertEq(merged[2].name, "security", "security added from override");
77
+ }
78
+
79
+ // ═══════════════════════════════════════════════════════════════════════════
80
+ // Phase 2: Pre-Dispatch Hook Config Tests
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+
83
+ console.log("\n=== Pre-dispatch hook config shape ===");
84
+
85
+ {
86
+ const modifyHook = {
87
+ name: "inject-context",
88
+ before: ["execute-task"],
89
+ action: "modify" as const,
90
+ prepend: "Remember to follow coding conventions.",
91
+ append: "Run tests after making changes.",
92
+ enabled: true,
93
+ };
94
+
95
+ assertEq(modifyHook.name, "inject-context", "modify hook has name");
96
+ assertEq(modifyHook.action, "modify", "action is modify");
97
+ assertTrue(!!modifyHook.prepend, "has prepend text");
98
+ assertTrue(!!modifyHook.append, "has append text");
99
+ }
100
+
101
+ {
102
+ const skipHook = {
103
+ name: "skip-research",
104
+ before: ["research-slice"],
105
+ action: "skip" as const,
106
+ skip_if: "RESEARCH-DONE.md",
107
+ enabled: true,
108
+ };
109
+
110
+ assertEq(skipHook.action, "skip", "action is skip");
111
+ assertEq(skipHook.skip_if, "RESEARCH-DONE.md", "has skip condition");
112
+ }
113
+
114
+ {
115
+ const replaceHook = {
116
+ name: "custom-planning",
117
+ before: ["plan-slice"],
118
+ action: "replace" as const,
119
+ prompt: "Use custom planning approach for {sliceId}",
120
+ unit_type: "custom-plan",
121
+ model: "claude-opus-4-6",
122
+ enabled: true,
123
+ };
124
+
125
+ assertEq(replaceHook.action, "replace", "action is replace");
126
+ assertTrue(!!replaceHook.prompt, "replace hook has prompt");
127
+ assertEq(replaceHook.unit_type, "custom-plan", "has unit_type override");
128
+ }
129
+
130
+ console.log("\n=== Pre-dispatch action validation ===");
131
+
132
+ {
133
+ const validActions = new Set(["modify", "skip", "replace"]);
134
+ assertTrue(validActions.has("modify"), "modify is valid");
135
+ assertTrue(validActions.has("skip"), "skip is valid");
136
+ assertTrue(validActions.has("replace"), "replace is valid");
137
+ assertTrue(!validActions.has("delete"), "delete is not valid");
138
+ assertTrue(!validActions.has(""), "empty string is not valid");
139
+ }
140
+
141
+ console.log("\n=== Pre-dispatch hook merging ===");
142
+
143
+ {
144
+ const baseHooks = [
145
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "base" },
146
+ ];
147
+
148
+ const overrideHooks = [
149
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "override" },
150
+ { name: "gate", before: ["plan-slice"], action: "skip" as const },
151
+ ];
152
+
153
+ const merged = [...baseHooks];
154
+ for (const hook of overrideHooks) {
155
+ const idx = merged.findIndex(h => h.name === hook.name);
156
+ if (idx >= 0) {
157
+ merged[idx] = hook;
158
+ } else {
159
+ merged.push(hook);
160
+ }
161
+ }
162
+
163
+ assertEq(merged.length, 2, "merged has 2 pre-dispatch hooks");
164
+ assertEq(merged[0].prepend, "override", "inject hook overridden");
165
+ assertEq(merged[1].name, "gate", "gate hook added");
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // Known unit types validation
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ console.log("\n=== Known unit types ===");
173
+
174
+ {
175
+ const knownUnitTypes = new Set([
176
+ "research-milestone", "plan-milestone", "research-slice", "plan-slice",
177
+ "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
178
+ "run-uat", "fix-merge", "complete-milestone",
179
+ ]);
180
+
181
+ assertTrue(knownUnitTypes.has("execute-task"), "execute-task is known");
182
+ assertTrue(knownUnitTypes.has("complete-slice"), "complete-slice is known");
183
+ assertTrue(knownUnitTypes.has("plan-slice"), "plan-slice is known");
184
+ assertTrue(!knownUnitTypes.has("hook/review"), "hook types are not in known set");
185
+ assertTrue(!knownUnitTypes.has("invalid-type"), "invalid types are not in known set");
186
+ }
187
+
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+ // Preferences YAML format verification
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+
192
+ console.log("\n=== Preferences YAML format ===");
193
+
194
+ {
195
+ const prefsContent = [
196
+ "---",
197
+ "version: 1",
198
+ "post_unit_hooks:",
199
+ " - name: code-review",
200
+ " after:",
201
+ " - execute-task",
202
+ " prompt: Review the changes",
203
+ " max_cycles: 3",
204
+ " artifact: REVIEW-PASS.md",
205
+ " retry_on: REVIEW-ISSUES.md",
206
+ "pre_dispatch_hooks:",
207
+ " - name: inject-conventions",
208
+ " before:",
209
+ " - execute-task",
210
+ " action: modify",
211
+ " append: Follow project coding conventions",
212
+ " - name: custom-research",
213
+ " before:",
214
+ " - research-slice",
215
+ " action: replace",
216
+ " prompt: Custom research prompt",
217
+ "---",
218
+ ].join("\n");
219
+
220
+ assertTrue(prefsContent.includes("post_unit_hooks:"), "has post_unit_hooks key");
221
+ assertTrue(prefsContent.includes("pre_dispatch_hooks:"), "has pre_dispatch_hooks key");
222
+ assertTrue(prefsContent.includes("action: modify"), "has modify action");
223
+ assertTrue(prefsContent.includes("action: replace"), "has replace action");
224
+ }
225
+
226
+ report();
@@ -67,6 +67,18 @@ async function main(): Promise<void> {
67
67
  assertEq('M001-abc123: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001-abc123: Title → Title');
68
68
  assertEq('M042-z9a8b7: Dashboard'.replace(TITLE_STRIP_RE, ''), 'Dashboard', 'strips M042-z9a8b7: Dashboard');
69
69
 
70
+ // Em dash in title — current format (M001: Title) correctly preserves em dash in title body
71
+ assertEq(
72
+ 'M001: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
73
+ 'Foundation — Build Core',
74
+ 'strips M001: prefix and preserves em dash in title body',
75
+ );
76
+ assertEq(
77
+ 'M001-abc123: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
78
+ 'Foundation — Build Core',
79
+ 'strips M001-abc123: prefix and preserves em dash in title body (unique format)',
80
+ );
81
+
70
82
  // Edge case: dash-style separator (M001 — Title: Subtitle preserves colon in body)
71
83
  assertEq(
72
84
  'M001 — Unique Milestone IDs: Foo'.replace(TITLE_STRIP_RE, ''),
@@ -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();