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
@@ -53,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
53
53
 
54
54
  export function registerGSDCommand(pi: ExtensionAPI): void {
55
55
  pi.registerCommand("gsd", {
56
- description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote",
56
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
57
57
 
58
58
  getArgumentCompletions: (prefix: string) => {
59
- const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"];
59
+ const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
60
60
  const parts = prefix.trim().split(/\s+/);
61
61
 
62
62
  if (parts.length <= 1) {
@@ -151,6 +151,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
151
151
  return;
152
152
  }
153
153
 
154
+ if (trimmed === "hooks") {
155
+ const { formatHookStatus } = await import("./post-unit-hooks.js");
156
+ ctx.ui.notify(formatHookStatus(), "info");
157
+ return;
158
+ }
159
+
154
160
  if (trimmed === "migrate" || trimmed.startsWith("migrate ")) {
155
161
  await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi);
156
162
  return;
@@ -168,7 +174,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
168
174
  }
169
175
 
170
176
  ctx.ui.notify(
171
- `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
177
+ `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
172
178
  "warning",
173
179
  );
174
180
  },
@@ -1,3 +1,4 @@
1
+ import { execSync } from "node:child_process";
1
2
  import { existsSync, mkdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
 
@@ -5,6 +6,9 @@ import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPla
5
6
  import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
6
7
  import { deriveState, isMilestoneComplete } from "./state.js";
7
8
  import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
9
+ import { listWorktrees } from "./worktree-manager.js";
10
+ import { abortAndReset } from "./git-self-heal.js";
11
+ import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
8
12
 
9
13
  export type DoctorSeverity = "info" | "warning" | "error";
10
14
  export type DoctorIssueCode =
@@ -22,7 +26,12 @@ export type DoctorIssueCode =
22
26
  | "task_done_must_haves_not_verified"
23
27
  | "active_requirement_missing_owner"
24
28
  | "blocked_requirement_missing_reason"
25
- | "blocker_discovered_no_replan";
29
+ | "blocker_discovered_no_replan"
30
+ | "delimiter_in_title"
31
+ | "orphaned_auto_worktree"
32
+ | "stale_milestone_branch"
33
+ | "corrupt_merge_state"
34
+ | "tracked_runtime_files";
26
35
 
27
36
  export interface DoctorIssue {
28
37
  severity: DoctorSeverity;
@@ -91,15 +100,43 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] {
91
100
  return issues;
92
101
  }
93
102
 
103
+ /**
104
+ * Characters that are used as delimiters in GSD state management documents
105
+ * and should not appear in milestone or slice titles.
106
+ *
107
+ * - "—" (em dash, U+2014): used as a display separator in STATE.md and other docs.
108
+ * A title containing "—" makes the separator ambiguous, corrupting state display
109
+ * and confusing the LLM agent that reads and writes these files.
110
+ * - "–" (en dash, U+2013): visually similar to em dash; same ambiguity risk.
111
+ * - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01)
112
+ * and git branch names (gsd/M001/S01). A slash in a title can break path resolution.
113
+ */
114
+ const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash
115
+
116
+ /**
117
+ * Check whether a milestone or slice title contains characters that conflict
118
+ * with GSD's state document delimiter conventions.
119
+ * Returns a human-readable description of the problem, or null if the title is safe.
120
+ */
121
+ export function validateTitle(title: string): string | null {
122
+ if (TITLE_DELIMITER_RE.test(title)) {
123
+ const found: string[] = [];
124
+ if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)");
125
+ if (/\//.test(title)) found.push("forward slash (/)");
126
+ return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`;
127
+ }
128
+ return null;
129
+ }
130
+
94
131
  function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
95
132
  const lines: string[] = [];
96
133
  lines.push("# GSD State", "");
97
134
 
98
135
  const activeMilestone = state.activeMilestone
99
- ? `${state.activeMilestone.id} ${state.activeMilestone.title}`
136
+ ? `${state.activeMilestone.id}: ${state.activeMilestone.title}`
100
137
  : "None";
101
138
  const activeSlice = state.activeSlice
102
- ? `${state.activeSlice.id} ${state.activeSlice.title}`
139
+ ? `${state.activeSlice.id}: ${state.activeSlice.title}`
103
140
  : "None";
104
141
 
105
142
  lines.push(`**Active Milestone:** ${activeMilestone}`);
@@ -422,6 +459,189 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
422
459
  }).join("\n");
423
460
  }
424
461
 
462
+ async function checkGitHealth(
463
+ basePath: string,
464
+ issues: DoctorIssue[],
465
+ fixesApplied: string[],
466
+ shouldFix: (code: DoctorIssueCode) => boolean,
467
+ ): Promise<void> {
468
+ // Degrade gracefully if not a git repo
469
+ try {
470
+ execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
471
+ } catch {
472
+ return; // Not a git repo — skip all git health checks
473
+ }
474
+
475
+ const gitDir = join(basePath, ".git");
476
+
477
+ // ── Orphaned auto-worktrees ──────────────────────────────────────────
478
+ try {
479
+ const worktrees = listWorktrees(basePath);
480
+ const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
481
+
482
+ // Load roadmap state once for cross-referencing
483
+ const state = await deriveState(basePath);
484
+
485
+ for (const wt of milestoneWorktrees) {
486
+ // Extract milestone ID from branch name "milestone/M001" → "M001"
487
+ const milestoneId = wt.branch.replace(/^milestone\//, "");
488
+ const milestoneEntry = state.registry.find(m => m.id === milestoneId);
489
+
490
+ // Check if milestone is complete via roadmap
491
+ let isComplete = false;
492
+ if (milestoneEntry) {
493
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
494
+ const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
495
+ if (roadmapContent) {
496
+ const roadmap = parseRoadmap(roadmapContent);
497
+ isComplete = isMilestoneComplete(roadmap);
498
+ }
499
+ }
500
+
501
+ if (isComplete) {
502
+ issues.push({
503
+ severity: "warning",
504
+ code: "orphaned_auto_worktree",
505
+ scope: "milestone",
506
+ unitId: milestoneId,
507
+ message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
508
+ fixable: true,
509
+ });
510
+
511
+ if (shouldFix("orphaned_auto_worktree")) {
512
+ // Never remove a worktree matching current working directory
513
+ const cwd = process.cwd();
514
+ if (wt.path === cwd || cwd.startsWith(wt.path + "/")) {
515
+ fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
516
+ } else {
517
+ try {
518
+ execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
519
+ fixesApplied.push(`removed orphaned worktree ${wt.path}`);
520
+ } catch {
521
+ fixesApplied.push(`failed to remove worktree ${wt.path}`);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ // ── Stale milestone branches ─────────────────────────────────────────
529
+ try {
530
+ const branchOutput = execSync("git branch --list 'milestone/*'", { cwd: basePath, stdio: "pipe" }).toString().trim();
531
+ if (branchOutput) {
532
+ const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
533
+ const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
534
+
535
+ for (const branch of branches) {
536
+ // Skip branches that have a worktree (handled above)
537
+ if (worktreeBranches.has(branch)) continue;
538
+
539
+ const milestoneId = branch.replace(/^milestone\//, "");
540
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
541
+ const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
542
+ if (!roadmapContent) continue;
543
+
544
+ const roadmap = parseRoadmap(roadmapContent);
545
+ if (isMilestoneComplete(roadmap)) {
546
+ issues.push({
547
+ severity: "info",
548
+ code: "stale_milestone_branch",
549
+ scope: "milestone",
550
+ unitId: milestoneId,
551
+ message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
552
+ fixable: true,
553
+ });
554
+
555
+ if (shouldFix("stale_milestone_branch")) {
556
+ try {
557
+ execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
558
+ fixesApplied.push(`deleted stale branch ${branch}`);
559
+ } catch {
560
+ fixesApplied.push(`failed to delete branch ${branch}`);
561
+ }
562
+ }
563
+ }
564
+ }
565
+ }
566
+ } catch {
567
+ // git branch list failed — skip stale branch check
568
+ }
569
+ } catch {
570
+ // listWorktrees or deriveState failed — skip worktree/branch checks
571
+ }
572
+
573
+ // ── Corrupt merge state ────────────────────────────────────────────────
574
+ try {
575
+ const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
576
+ const mergeStateDirs = ["rebase-apply", "rebase-merge"];
577
+ const found: string[] = [];
578
+
579
+ for (const f of mergeStateFiles) {
580
+ if (existsSync(join(gitDir, f))) found.push(f);
581
+ }
582
+ for (const d of mergeStateDirs) {
583
+ if (existsSync(join(gitDir, d))) found.push(d);
584
+ }
585
+
586
+ if (found.length > 0) {
587
+ issues.push({
588
+ severity: "error",
589
+ code: "corrupt_merge_state",
590
+ scope: "project",
591
+ unitId: "project",
592
+ message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
593
+ fixable: true,
594
+ });
595
+
596
+ if (shouldFix("corrupt_merge_state")) {
597
+ const result = abortAndReset(basePath);
598
+ fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
599
+ }
600
+ }
601
+ } catch {
602
+ // Can't check .git dir — skip
603
+ }
604
+
605
+ // ── Tracked runtime files ──────────────────────────────────────────────
606
+ try {
607
+ const trackedPaths: string[] = [];
608
+ for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
609
+ try {
610
+ const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
611
+ if (output) {
612
+ trackedPaths.push(...output.split("\n").filter(Boolean));
613
+ }
614
+ } catch {
615
+ // Individual ls-files can fail — continue
616
+ }
617
+ }
618
+
619
+ if (trackedPaths.length > 0) {
620
+ issues.push({
621
+ severity: "warning",
622
+ code: "tracked_runtime_files",
623
+ scope: "project",
624
+ unitId: "project",
625
+ message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
626
+ fixable: true,
627
+ });
628
+
629
+ if (shouldFix("tracked_runtime_files")) {
630
+ try {
631
+ for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
632
+ execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
633
+ }
634
+ fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
635
+ } catch {
636
+ fixesApplied.push("failed to untrack runtime files");
637
+ }
638
+ }
639
+ }
640
+ } catch {
641
+ // git ls-files failed — skip
642
+ }
643
+ }
644
+
425
645
  export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; scope?: string; fixLevel?: "task" | "all" }): Promise<DoctorReport> {
426
646
  const issues: DoctorIssue[] = [];
427
647
  const fixesApplied: string[] = [];
@@ -462,6 +682,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
462
682
  }
463
683
  }
464
684
 
685
+ // Git health checks (orphaned worktrees, stale branches, corrupt merge state, tracked runtime files)
686
+ await checkGitHealth(basePath, issues, fixesApplied, shouldFix);
687
+
465
688
  const milestonesPath = milestonesDir(basePath);
466
689
  if (!existsSync(milestonesPath)) {
467
690
  return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
@@ -477,6 +700,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
477
700
  const milestonePath = resolveMilestonePath(basePath, milestoneId);
478
701
  if (!milestonePath) continue;
479
702
 
703
+ // Validate milestone title for delimiter characters that break state documents.
704
+ const milestoneTitleIssue = validateTitle(milestone.title);
705
+ if (milestoneTitleIssue) {
706
+ issues.push({
707
+ severity: "warning",
708
+ code: "delimiter_in_title",
709
+ scope: "milestone",
710
+ unitId: milestoneId,
711
+ message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`,
712
+ file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
713
+ fixable: false,
714
+ });
715
+ }
716
+
480
717
  const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
481
718
  const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
482
719
  if (!roadmapContent) continue;
@@ -486,6 +723,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
486
723
  const unitId = `${milestoneId}/${slice.id}`;
487
724
  if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue;
488
725
 
726
+ // Validate slice title for delimiter characters.
727
+ const sliceTitleIssue = validateTitle(slice.title);
728
+ if (sliceTitleIssue) {
729
+ issues.push({
730
+ severity: "warning",
731
+ code: "delimiter_in_title",
732
+ scope: "slice",
733
+ unitId,
734
+ message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`,
735
+ file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
736
+ fixable: false,
737
+ });
738
+ }
739
+
489
740
  const slicePath = resolveSlicePath(basePath, milestoneId, slice.id);
490
741
  if (!slicePath) continue;
491
742
 
@@ -0,0 +1,198 @@
1
+ /**
2
+ * git-self-heal.ts — Automated git state recovery utilities.
3
+ *
4
+ * Four synchronous functions for recovering from broken git state
5
+ * during auto-mode operations. Uses only `git reset --hard HEAD` —
6
+ * never `git clean` (which would delete untracked .gsd/ dirs).
7
+ *
8
+ * Observability: Each function returns structured results describing
9
+ * what actions were taken. `formatGitError` maps raw git errors to
10
+ * user-friendly messages suggesting `/gsd doctor`.
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import { existsSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { MergeConflictError } from "./git-service.js";
17
+
18
+ // Re-export for consumers
19
+ export { MergeConflictError };
20
+
21
+ /** Result from abortAndReset describing what was cleaned up. */
22
+ export interface AbortAndResetResult {
23
+ /** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */
24
+ cleaned: string[];
25
+ }
26
+
27
+ /**
28
+ * Detect and clean up leftover merge/rebase state, then hard-reset.
29
+ *
30
+ * Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply.
31
+ * Aborts in-progress merge or rebase if detected. Always finishes
32
+ * with `git reset --hard HEAD`.
33
+ *
34
+ * @returns Structured result listing what was cleaned. Empty `cleaned`
35
+ * array means repo was already in a clean state.
36
+ */
37
+ export function abortAndReset(cwd: string): AbortAndResetResult {
38
+ const gitDir = join(cwd, ".git");
39
+ const cleaned: string[] = [];
40
+
41
+ // Abort in-progress merge
42
+ if (existsSync(join(gitDir, "MERGE_HEAD"))) {
43
+ try {
44
+ execSync("git merge --abort", { cwd, stdio: "pipe" });
45
+ cleaned.push("aborted merge");
46
+ } catch {
47
+ // merge --abort can fail if state is really broken; continue to reset
48
+ cleaned.push("merge abort attempted (may have failed)");
49
+ }
50
+ }
51
+
52
+ // Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD)
53
+ const squashMsgPath = join(gitDir, "SQUASH_MSG");
54
+ if (existsSync(squashMsgPath)) {
55
+ try {
56
+ unlinkSync(squashMsgPath);
57
+ cleaned.push("removed SQUASH_MSG");
58
+ } catch {
59
+ // Not critical
60
+ }
61
+ }
62
+
63
+ // Abort in-progress rebase
64
+ if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) {
65
+ try {
66
+ execSync("git rebase --abort", { cwd, stdio: "pipe" });
67
+ cleaned.push("aborted rebase");
68
+ } catch {
69
+ cleaned.push("rebase abort attempted (may have failed)");
70
+ }
71
+ }
72
+
73
+ // Always hard-reset to HEAD
74
+ try {
75
+ execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
76
+ if (cleaned.length > 0) {
77
+ cleaned.push("reset to HEAD");
78
+ }
79
+ } catch {
80
+ cleaned.push("reset to HEAD failed");
81
+ }
82
+
83
+ return { cleaned };
84
+ }
85
+
86
+ /**
87
+ * Wrap a merge operation with self-healing retry logic.
88
+ *
89
+ * Calls `mergeFn()`. On failure:
90
+ * - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
91
+ * as MergeConflictError immediately — no retry for real code conflicts.
92
+ * - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
93
+ * - On second failure, throws the error.
94
+ *
95
+ * @param cwd - Working directory for git operations
96
+ * @param mergeFn - Synchronous function that performs the merge
97
+ * @returns The return value of `mergeFn()`
98
+ */
99
+ export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
100
+ try {
101
+ return mergeFn();
102
+ } catch (firstError) {
103
+ // Check for real code conflicts — escalate immediately, no retry
104
+ try {
105
+ const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
106
+ cwd,
107
+ encoding: "utf-8",
108
+ stdio: ["pipe", "pipe", "pipe"],
109
+ }).trim();
110
+
111
+ if (conflictOutput.length > 0) {
112
+ const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
113
+ // If the original error is already a MergeConflictError, re-throw as-is
114
+ if (firstError instanceof MergeConflictError) {
115
+ throw firstError;
116
+ }
117
+ throw new MergeConflictError(
118
+ conflictedFiles,
119
+ "merge",
120
+ "unknown",
121
+ "unknown",
122
+ );
123
+ }
124
+ } catch (diffErr) {
125
+ // If diffErr is a MergeConflictError we just created/re-threw, propagate it
126
+ if (diffErr instanceof MergeConflictError) throw diffErr;
127
+ // Otherwise git diff itself failed — proceed with retry
128
+ }
129
+
130
+ // No real conflict detected — try abort+reset+retry once
131
+ abortAndReset(cwd);
132
+
133
+ // Retry
134
+ return mergeFn();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Recover a failed checkout by resetting first, then checking out.
140
+ *
141
+ * Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
142
+ * If checkout still fails after reset, throws with context.
143
+ */
144
+ export function recoverCheckout(cwd: string, targetBranch: string): void {
145
+ execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
146
+
147
+ try {
148
+ execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
149
+ } catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ throw new Error(
152
+ `recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
153
+ );
154
+ }
155
+ }
156
+
157
+ /** Known git error patterns mapped to user-friendly messages. */
158
+ const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
159
+ {
160
+ pattern: /conflict|CONFLICT|merge conflict/i,
161
+ message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.",
162
+ },
163
+ {
164
+ pattern: /cannot checkout|did not match any|pathspec .* did not match/i,
165
+ message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.",
166
+ },
167
+ {
168
+ pattern: /HEAD detached|detached HEAD/i,
169
+ message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.",
170
+ },
171
+ {
172
+ pattern: /\.lock|Unable to create .* lock|lock file/i,
173
+ message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.",
174
+ },
175
+ {
176
+ pattern: /fatal: not a git repository/i,
177
+ message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.",
178
+ },
179
+ ];
180
+
181
+ /**
182
+ * Translate raw git error strings into user-friendly messages.
183
+ *
184
+ * Pattern-matches against common git error strings and returns
185
+ * a non-technical message suggesting `/gsd doctor`. Returns the
186
+ * original message if no pattern matches.
187
+ */
188
+ export function formatGitError(error: string | Error): string {
189
+ const errorStr = error instanceof Error ? error.message : error;
190
+
191
+ for (const { pattern, message } of ERROR_PATTERNS) {
192
+ if (pattern.test(errorStr)) {
193
+ return message;
194
+ }
195
+ }
196
+
197
+ return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`;
198
+ }
@@ -37,6 +37,8 @@ export interface GitPreferences {
37
37
  commit_type?: string;
38
38
  main_branch?: string;
39
39
  merge_strategy?: "squash" | "merge";
40
+ isolation?: "worktree" | "branch";
41
+ merge_to_main?: "milestone" | "slice";
40
42
  }
41
43
 
42
44
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -764,6 +766,15 @@ export class GitServiceImpl {
764
766
  this.git(mergeArgs);
765
767
  } catch (mergeError) {
766
768
  // Check if conflicts can be auto-resolved (#189, #218)
769
+ //
770
+ // ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
771
+ // The conflict resolution logic below applies ONLY when git.isolation = "branch".
772
+ // In worktree isolation mode, each milestone works in its own worktree directory
773
+ // so merge conflicts between slice branches and main are handled differently
774
+ // (worktree teardown merges via worktree-manager). This block is never reached
775
+ // in worktree mode because mergeSliceToMain is only called from the branch-mode
776
+ // code path. If you're modifying this logic, verify the isolation mode first.
777
+ // ─────────────────────────────────────────────────────────────────────
767
778
  const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
768
779
  if (conflicted) {
769
780
  const conflictedFiles = conflicted.split("\n").filter(Boolean);