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
package/dist/cli.js CHANGED
@@ -2,12 +2,27 @@ import { AuthStorage, DefaultResourceLoader, ModelRegistry, SettingsManager, Ses
2
2
  import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { agentDir, sessionsDir, authFilePath } from './app-paths.js';
5
- import { initResources, buildResourceLoader } from './resource-loader.js';
5
+ import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js';
6
6
  import { ensureManagedTools } from './tool-bootstrap.js';
7
7
  import { loadStoredEnvKeys } from './wizard.js';
8
8
  import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js';
9
9
  import { shouldRunOnboarding, runOnboarding } from './onboarding.js';
10
10
  import { checkForUpdates } from './update-check.js';
11
+ function exitIfManagedResourcesAreNewer(currentAgentDir) {
12
+ const currentVersion = process.env.GSD_VERSION || '0.0.0';
13
+ const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion);
14
+ if (!managedVersion) {
15
+ return;
16
+ }
17
+ const yellow = '\x1b[33m';
18
+ const dim = '\x1b[2m';
19
+ const reset = '\x1b[0m';
20
+ const bold = '\x1b[1m';
21
+ process.stderr.write(`[gsd] ${yellow}Version mismatch detected${reset}\n` +
22
+ `[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` +
23
+ `[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`);
24
+ process.exit(1);
25
+ }
11
26
  function parseCliArgs(argv) {
12
27
  const flags = { extensions: [], messages: [] };
13
28
  const args = argv.slice(2); // skip node + script
@@ -199,6 +214,7 @@ if (isPrintMode) {
199
214
  appendSystemPrompt = cliFlags.appendSystemPrompt;
200
215
  }
201
216
  }
217
+ exitIfManagedResourcesAreNewer(agentDir);
202
218
  initResources(agentDir);
203
219
  const resourceLoader = new DefaultResourceLoader({
204
220
  agentDir,
@@ -272,6 +288,7 @@ if (existsSync(sessionsDir)) {
272
288
  const sessionManager = cliFlags.continue
273
289
  ? SessionManager.continueRecent(cwd, projectSessionsDir)
274
290
  : SessionManager.create(cwd, projectSessionsDir);
291
+ exitIfManagedResourcesAreNewer(agentDir);
275
292
  initResources(agentDir);
276
293
  const resourceLoader = buildResourceLoader(agentDir);
277
294
  await resourceLoader.reload();
@@ -1,5 +1,7 @@
1
1
  import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
2
2
  export declare function discoverExtensionEntryPaths(extensionsDir: string): string[];
3
+ export declare function readManagedResourceVersion(agentDir: string): string | null;
4
+ export declare function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null;
3
5
  /**
4
6
  * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
5
7
  *
@@ -1,8 +1,9 @@
1
1
  import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
2
2
  import { homedir } from 'node:os';
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, relative, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { compareSemver } from './update-check.js';
6
7
  // Resolve resources directory — prefer dist/resources/ (stable, set at build time)
7
8
  // over src/resources/ (live working tree, changes with git branch).
8
9
  //
@@ -16,6 +17,7 @@ const distResources = join(packageRoot, 'dist', 'resources');
16
17
  const srcResources = join(packageRoot, 'src', 'resources');
17
18
  const resourcesDir = existsSync(distResources) ? distResources : srcResources;
18
19
  const bundledExtensionsDir = join(resourcesDir, 'extensions');
20
+ const resourceVersionManifestName = 'managed-resources.json';
19
21
  function isExtensionFile(name) {
20
22
  return name.endsWith('.ts') || name.endsWith('.js');
21
23
  }
@@ -70,6 +72,38 @@ function getExtensionKey(entryPath, extensionsDir) {
70
72
  const relPath = relative(extensionsDir, entryPath);
71
73
  return relPath.split(/[\\/]/)[0];
72
74
  }
75
+ function getManagedResourceManifestPath(agentDir) {
76
+ return join(agentDir, resourceVersionManifestName);
77
+ }
78
+ function getBundledGsdVersion() {
79
+ try {
80
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
81
+ return typeof pkg?.version === 'string' ? pkg.version : '0.0.0';
82
+ }
83
+ catch {
84
+ return process.env.GSD_VERSION || '0.0.0';
85
+ }
86
+ }
87
+ function writeManagedResourceManifest(agentDir) {
88
+ const manifest = { gsdVersion: getBundledGsdVersion() };
89
+ writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest));
90
+ }
91
+ export function readManagedResourceVersion(agentDir) {
92
+ try {
93
+ const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
94
+ return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export function getNewerManagedResourceVersion(agentDir, currentVersion) {
101
+ const managedVersion = readManagedResourceVersion(agentDir);
102
+ if (!managedVersion) {
103
+ return null;
104
+ }
105
+ return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null;
106
+ }
73
107
  /**
74
108
  * Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
75
109
  *
@@ -101,6 +135,7 @@ export function initResources(agentDir) {
101
135
  if (existsSync(srcSkills)) {
102
136
  cpSync(srcSkills, destSkills, { recursive: true, force: true });
103
137
  }
138
+ writeManagedResourceManifest(agentDir);
104
139
  }
105
140
  /**
106
141
  * Constructs a DefaultResourceLoader that loads extensions from both
@@ -0,0 +1,509 @@
1
+ /**
2
+ * GSD Auto-Worktree -- lifecycle management for auto-mode worktrees.
3
+ *
4
+ * Auto-mode creates worktrees with `milestone/<MID>` branches (distinct from
5
+ * manual `/worktree` which uses `worktree/<name>` branches). This module
6
+ * manages create, enter, detect, and teardown for auto-mode worktrees.
7
+ */
8
+
9
+ import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { execSync } from "node:child_process";
12
+ import {
13
+ createWorktree,
14
+ removeWorktree,
15
+ worktreePath,
16
+ } from "./worktree-manager.js";
17
+ import {
18
+ detectWorktreeName,
19
+ getSliceBranchName,
20
+ } from "./worktree.js";
21
+ import {
22
+ MergeConflictError,
23
+ inferCommitType,
24
+ } from "./git-service.js";
25
+ import type { MergeSliceResult } from "./git-service.js";
26
+ import { recoverCheckout, withMergeHeal } from "./git-self-heal.js";
27
+ import {
28
+ nativeBranchExists,
29
+ nativeCommitCountBetween,
30
+ } from "./native-git-bridge.js";
31
+ import { parseRoadmap } from "./files.js";
32
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
33
+
34
+ // ─── Module State ──────────────────────────────────────────────────────────
35
+
36
+ /** Original project root before chdir into auto-worktree. */
37
+ let originalBase: string | null = null;
38
+
39
+ // ─── Isolation Resolver ────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Determine whether auto-mode should use worktree isolation.
43
+ *
44
+ * Resolution order:
45
+ * 1. Explicit git.isolation preference -> return (isolation === "worktree")
46
+ * 2. Legacy detection: if gsd branches exist -> return false (branch mode)
47
+ * 3. Default: return true (worktree mode for new projects)
48
+ */
49
+ export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean {
50
+ const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git;
51
+ if (prefs?.isolation) {
52
+ return prefs.isolation === "worktree";
53
+ }
54
+
55
+ // Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
56
+ try {
57
+ const output = execSync("git branch --list 'gsd/*/*'", {
58
+ cwd: basePath,
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ encoding: "utf-8",
61
+ }).trim();
62
+ if (output) return false; // Legacy branch-per-slice project
63
+ } catch {
64
+ // If git command fails, default to worktree
65
+ }
66
+
67
+ return true; // New project default
68
+ }
69
+
70
+ /**
71
+ * Resolve the merge_to_main preference value.
72
+ * Returns "milestone" (default) or "slice".
73
+ */
74
+ export function getMergeToMainMode(): "milestone" | "slice" {
75
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
76
+ return prefs?.merge_to_main ?? "milestone";
77
+ }
78
+
79
+ // ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
80
+
81
+ function resolveGitHeadPath(dir: string): string | null {
82
+ const gitPath = join(dir, ".git");
83
+ if (!existsSync(gitPath)) return null;
84
+ try {
85
+ const content = readFileSync(gitPath, "utf8").trim();
86
+ if (content.startsWith("gitdir: ")) {
87
+ const gitDir = resolve(dir, content.slice(8));
88
+ const headPath = join(gitDir, "HEAD");
89
+ return existsSync(headPath) ? headPath : null;
90
+ }
91
+ const headPath = join(dir, ".git", "HEAD");
92
+ return existsSync(headPath) ? headPath : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Nudge pi's FooterDataProvider to re-read the git branch after chdir.
100
+ * Touches HEAD in both old and new cwd to fire the fs watcher.
101
+ */
102
+ function nudgeGitBranchCache(previousCwd: string): void {
103
+ const now = new Date();
104
+ for (const dir of [previousCwd, process.cwd()]) {
105
+ try {
106
+ const headPath = resolveGitHeadPath(dir);
107
+ if (headPath) utimesSync(headPath, now, now);
108
+ } catch {
109
+ // Best-effort
110
+ }
111
+ }
112
+ }
113
+
114
+ function getCurrentBranch(cwd: string): string {
115
+ try {
116
+ return execSync("git branch --show-current", {
117
+ cwd,
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ encoding: "utf-8",
120
+ }).trim();
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+
126
+ // ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
127
+
128
+ export function autoWorktreeBranch(milestoneId: string): string {
129
+ return `milestone/${milestoneId}`;
130
+ }
131
+
132
+ // ─── Public API ────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Create a new auto-worktree for a milestone, chdir into it, and store
136
+ * the original base path for later teardown.
137
+ *
138
+ * Atomic: chdir + originalBase update happen in the same try block
139
+ * to prevent split-brain.
140
+ */
141
+ export function createAutoWorktree(basePath: string, milestoneId: string): string {
142
+ const branch = autoWorktreeBranch(milestoneId);
143
+ const info = createWorktree(basePath, milestoneId, { branch });
144
+ const previousCwd = process.cwd();
145
+
146
+ try {
147
+ process.chdir(info.path);
148
+ originalBase = basePath;
149
+ } catch (err) {
150
+ // If chdir fails, the worktree was created but we couldn't enter it.
151
+ // Don't store originalBase -- caller can retry or clean up.
152
+ throw new Error(
153
+ `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
154
+ );
155
+ }
156
+
157
+ nudgeGitBranchCache(previousCwd);
158
+ return info.path;
159
+ }
160
+
161
+ /**
162
+ * Teardown an auto-worktree: chdir back to original base, then remove
163
+ * the worktree and its branch.
164
+ */
165
+ export function teardownAutoWorktree(originalBasePath: string, milestoneId: string): void {
166
+ const branch = autoWorktreeBranch(milestoneId);
167
+ const previousCwd = process.cwd();
168
+
169
+ try {
170
+ process.chdir(originalBasePath);
171
+ originalBase = null;
172
+ } catch (err) {
173
+ throw new Error(
174
+ `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
175
+ );
176
+ }
177
+
178
+ nudgeGitBranchCache(previousCwd);
179
+ removeWorktree(originalBasePath, milestoneId, { branch });
180
+ }
181
+
182
+ /**
183
+ * Detect if the process is currently inside an auto-worktree.
184
+ * Checks both module state and git branch prefix.
185
+ */
186
+ export function isInAutoWorktree(basePath: string): boolean {
187
+ if (!originalBase) return false;
188
+ const cwd = process.cwd();
189
+ const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
190
+ const wtDir = join(resolvedBase, ".gsd", "worktrees");
191
+ if (!cwd.startsWith(wtDir)) return false;
192
+ const branch = getCurrentBranch(cwd);
193
+ return branch.startsWith("milestone/");
194
+ }
195
+
196
+ /**
197
+ * Get the filesystem path for an auto-worktree, or null if it doesn't exist.
198
+ */
199
+ export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null {
200
+ const p = worktreePath(basePath, milestoneId);
201
+ return existsSync(p) ? p : null;
202
+ }
203
+
204
+ /**
205
+ * Enter an existing auto-worktree (chdir into it, store originalBase).
206
+ * Use for resume -- the worktree already exists from a prior create.
207
+ *
208
+ * Atomic: chdir + originalBase update in same try block.
209
+ */
210
+ export function enterAutoWorktree(basePath: string, milestoneId: string): string {
211
+ const p = worktreePath(basePath, milestoneId);
212
+ if (!existsSync(p)) {
213
+ throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`);
214
+ }
215
+
216
+ const previousCwd = process.cwd();
217
+
218
+ try {
219
+ process.chdir(p);
220
+ originalBase = basePath;
221
+ } catch (err) {
222
+ throw new Error(
223
+ `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
224
+ );
225
+ }
226
+
227
+ nudgeGitBranchCache(previousCwd);
228
+ return p;
229
+ }
230
+
231
+ /**
232
+ * Get the original project root stored when entering an auto-worktree.
233
+ * Returns null if not currently in an auto-worktree.
234
+ */
235
+ export function getAutoWorktreeOriginalBase(): string | null {
236
+ return originalBase;
237
+ }
238
+
239
+ // ─── Merge Slice -> Milestone ───────────────────────────────────────────────
240
+
241
+ /**
242
+ * Merge a completed slice branch into the milestone branch via `--no-ff`.
243
+ *
244
+ * Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
245
+ * git), so there are zero `.gsd/` conflict resolution concerns. No runtime
246
+ * exclusion untracking, no `--theirs` checkout, no snapshot creation.
247
+ *
248
+ * On conflict: throws MergeConflictError with conflicted file list.
249
+ * On success: deletes the slice branch and returns MergeSliceResult.
250
+ */
251
+ export function mergeSliceToMilestone(
252
+ basePath: string,
253
+ milestoneId: string,
254
+ sliceId: string,
255
+ sliceTitle: string,
256
+ ): MergeSliceResult {
257
+ if (!isInAutoWorktree(basePath)) {
258
+ throw new Error("mergeSliceToMilestone called outside auto-worktree");
259
+ }
260
+
261
+ const cwd = process.cwd();
262
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
263
+ const worktreeName = detectWorktreeName(cwd);
264
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
265
+
266
+ // Verify slice branch exists
267
+ if (!nativeBranchExists(cwd, sliceBranch)) {
268
+ throw new Error(`Slice branch "${sliceBranch}" does not exist`);
269
+ }
270
+
271
+ // Verify slice has commits ahead of milestone branch
272
+ const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
273
+ if (commitCount === 0) {
274
+ throw new Error(
275
+ `Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
276
+ );
277
+ }
278
+
279
+ // Checkout milestone branch (with self-healing reset)
280
+ recoverCheckout(cwd, milestoneBranch);
281
+
282
+ // Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
283
+ const commitType = inferCommitType(sliceTitle);
284
+ const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
285
+
286
+ let message = subject;
287
+ try {
288
+ const logOutput = execSync(
289
+ `git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
290
+ { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
291
+ ).trim();
292
+
293
+ if (logOutput) {
294
+ const subjects = logOutput.split("\n").filter(Boolean);
295
+ const MAX_ENTRIES = 20;
296
+ const truncated = subjects.length > MAX_ENTRIES;
297
+ const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
298
+ const taskLines = displayed.map(s => `- ${s}`).join("\n");
299
+ const truncationLine = truncated
300
+ ? `\n- ... and ${subjects.length - MAX_ENTRIES} more`
301
+ : "";
302
+ message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
303
+ }
304
+ } catch {
305
+ // Fall back to subject-only message
306
+ }
307
+
308
+ // Merge --no-ff (with self-healing retry for transient failures)
309
+ try {
310
+ withMergeHeal(cwd, () => {
311
+ execSync(`git merge --no-ff -m "${message.replace(/"/g, '\\"')}" ${sliceBranch}`, {
312
+ cwd,
313
+ stdio: ["ignore", "pipe", "pipe"],
314
+ encoding: "utf-8",
315
+ });
316
+ });
317
+ } catch (err) {
318
+ if (err instanceof MergeConflictError) {
319
+ // Re-throw with correct branch context
320
+ throw new MergeConflictError(
321
+ err.conflictedFiles,
322
+ err.strategy,
323
+ sliceBranch,
324
+ milestoneBranch,
325
+ );
326
+ }
327
+ throw err;
328
+ }
329
+
330
+ // Delete slice branch
331
+ let deletedBranch = false;
332
+ try {
333
+ execSync(`git branch -d ${sliceBranch}`, {
334
+ cwd,
335
+ stdio: ["ignore", "pipe", "pipe"],
336
+ encoding: "utf-8",
337
+ });
338
+ deletedBranch = true;
339
+ } catch {
340
+ // Branch deletion is best-effort
341
+ }
342
+
343
+ return {
344
+ branch: sliceBranch,
345
+ mergedCommitMessage: message,
346
+ deletedBranch,
347
+ };
348
+ }
349
+
350
+ // ─── Merge Milestone -> Main ───────────────────────────────────────────────
351
+
352
+ /**
353
+ * Auto-commit any dirty (uncommitted) state in the given directory.
354
+ * Returns true if a commit was made, false if working tree was clean.
355
+ */
356
+ function autoCommitDirtyState(cwd: string): boolean {
357
+ try {
358
+ const status = execSync("git status --porcelain", {
359
+ cwd,
360
+ stdio: ["ignore", "pipe", "pipe"],
361
+ encoding: "utf-8",
362
+ }).trim();
363
+ if (!status) return false;
364
+ execSync('git add -A && git commit -m "chore: auto-commit before milestone merge"', {
365
+ cwd,
366
+ stdio: ["ignore", "pipe", "pipe"],
367
+ encoding: "utf-8",
368
+ });
369
+ return true;
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Squash-merge the milestone branch into main with a rich commit message
377
+ * listing all completed slices, then tear down the worktree.
378
+ *
379
+ * Sequence:
380
+ * 1. Auto-commit dirty worktree state
381
+ * 2. chdir to originalBasePath
382
+ * 3. git checkout main
383
+ * 4. git merge --squash milestone/<MID>
384
+ * 5. git commit with rich message
385
+ * 6. Auto-push if enabled
386
+ * 7. Delete milestone branch
387
+ * 8. Remove worktree directory
388
+ * 9. Clear originalBase
389
+ *
390
+ * On merge conflict: throws MergeConflictError.
391
+ * On "nothing to commit" after squash: handles gracefully (no error).
392
+ */
393
+ export function mergeMilestoneToMain(
394
+ originalBasePath_: string,
395
+ milestoneId: string,
396
+ roadmapContent: string,
397
+ ): { commitMessage: string; pushed: boolean } {
398
+ const worktreeCwd = process.cwd();
399
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
400
+
401
+ // 1. Auto-commit dirty state in worktree before leaving
402
+ autoCommitDirtyState(worktreeCwd);
403
+
404
+ // 2. Parse roadmap for slice listing
405
+ const roadmap = parseRoadmap(roadmapContent);
406
+ const completedSlices = roadmap.slices.filter(s => s.done);
407
+
408
+ // 3. chdir to original base
409
+ const previousCwd = process.cwd();
410
+ process.chdir(originalBasePath_);
411
+
412
+ // 4. Resolve main branch from preferences
413
+ const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
414
+ const mainBranch = prefs.main_branch || "main";
415
+
416
+ // 5. Checkout main (with self-healing reset)
417
+ recoverCheckout(originalBasePath_, mainBranch);
418
+
419
+ // 6. Build rich commit message
420
+ const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
421
+ const subject = `feat(${milestoneId}): ${milestoneTitle}`;
422
+ let body = "";
423
+ if (completedSlices.length > 0) {
424
+ const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n");
425
+ body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`;
426
+ }
427
+ const commitMessage = subject + body;
428
+
429
+ // 7. Squash merge (with self-healing retry for transient failures)
430
+ try {
431
+ withMergeHeal(originalBasePath_, () => {
432
+ execSync(`git merge --squash ${milestoneBranch}`, {
433
+ cwd: originalBasePath_,
434
+ stdio: ["ignore", "pipe", "pipe"],
435
+ encoding: "utf-8",
436
+ });
437
+ });
438
+ } catch (err) {
439
+ if (err instanceof MergeConflictError) {
440
+ // Re-throw with correct branch context
441
+ throw new MergeConflictError(
442
+ err.conflictedFiles,
443
+ err.strategy,
444
+ milestoneBranch,
445
+ mainBranch,
446
+ );
447
+ }
448
+ // Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
449
+ }
450
+
451
+ // 8. Commit (handle nothing-to-commit gracefully)
452
+ let nothingToCommit = false;
453
+ try {
454
+ execSync(`git commit -m ${JSON.stringify(commitMessage)}`, {
455
+ cwd: originalBasePath_,
456
+ stdio: ["ignore", "pipe", "pipe"],
457
+ encoding: "utf-8",
458
+ });
459
+ } catch (err: unknown) {
460
+ // execSync errors have stdout/stderr as properties -- check those for git's message
461
+ const errObj = err as { stdout?: string; stderr?: string; message?: string };
462
+ const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
463
+ if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
464
+ nothingToCommit = true;
465
+ } else {
466
+ throw err;
467
+ }
468
+ }
469
+
470
+ // 9. Auto-push if enabled
471
+ let pushed = false;
472
+ if (prefs.auto_push === true && !nothingToCommit) {
473
+ const remote = prefs.remote ?? "origin";
474
+ try {
475
+ execSync(`git push ${remote} ${mainBranch}`, {
476
+ cwd: originalBasePath_,
477
+ stdio: ["ignore", "pipe", "pipe"],
478
+ encoding: "utf-8",
479
+ });
480
+ pushed = true;
481
+ } catch {
482
+ // Push failure is non-fatal
483
+ }
484
+ }
485
+
486
+ // 10. Remove worktree directory first (must happen before branch deletion)
487
+ try {
488
+ removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false });
489
+ } catch {
490
+ // Best-effort -- worktree dir may already be gone
491
+ }
492
+
493
+ // 11. Delete milestone branch (after worktree removal so ref is unlocked)
494
+ try {
495
+ execSync(`git branch -D ${milestoneBranch}`, {
496
+ cwd: originalBasePath_,
497
+ stdio: ["ignore", "pipe", "pipe"],
498
+ encoding: "utf-8",
499
+ });
500
+ } catch {
501
+ // Best-effort
502
+ }
503
+
504
+ // 12. Clear module state
505
+ originalBase = null;
506
+ nudgeGitBranchCache(previousCwd);
507
+
508
+ return { commitMessage, pushed };
509
+ }