gsd-pi 2.13.0 → 2.14.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 (99) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +1 -0
  3. package/dist/loader.js +50 -6
  4. package/dist/resource-loader.d.ts +7 -6
  5. package/dist/resource-loader.js +15 -8
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +29 -183
  7. package/dist/resources/extensions/gsd/auto.ts +252 -370
  8. package/dist/resources/extensions/gsd/commands.ts +118 -34
  9. package/dist/resources/extensions/gsd/doctor.ts +29 -4
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +0 -71
  11. package/dist/resources/extensions/gsd/git-service.ts +8 -431
  12. package/dist/resources/extensions/gsd/gitignore.ts +11 -4
  13. package/dist/resources/extensions/gsd/guided-flow.ts +141 -5
  14. package/dist/resources/extensions/gsd/preferences.ts +18 -17
  15. package/dist/resources/extensions/gsd/prompts/discuss.md +35 -0
  16. package/dist/resources/extensions/gsd/prompts/queue.md +7 -1
  17. package/dist/resources/extensions/gsd/state.ts +26 -8
  18. package/dist/resources/extensions/gsd/templates/state.md +0 -1
  19. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  20. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  21. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  22. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  23. package/dist/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  24. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  25. package/dist/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  26. package/dist/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  27. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  28. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  29. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  30. package/dist/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  31. package/dist/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  32. package/dist/resources/extensions/gsd/types.ts +0 -1
  33. package/dist/resources/extensions/gsd/worktree-manager.ts +7 -3
  34. package/dist/resources/extensions/gsd/worktree.ts +7 -65
  35. package/dist/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  36. package/package.json +1 -1
  37. package/packages/pi-ai/dist/providers/google.d.ts.map +1 -1
  38. package/packages/pi-ai/dist/providers/google.js +12 -4
  39. package/packages/pi-ai/dist/providers/google.js.map +1 -1
  40. package/packages/pi-ai/dist/providers/mistral.d.ts.map +1 -1
  41. package/packages/pi-ai/dist/providers/mistral.js +10 -2
  42. package/packages/pi-ai/dist/providers/mistral.js.map +1 -1
  43. package/packages/pi-ai/src/providers/google.ts +20 -8
  44. package/packages/pi-ai/src/providers/mistral.ts +14 -2
  45. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts +3 -0
  46. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/extensions/loader.js +10 -7
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +4 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +12 -3
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/extensions/loader.ts +13 -9
  57. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +4 -1
  58. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +14 -3
  59. package/packages/pi-tui/dist/components/input.d.ts +1 -0
  60. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  61. package/packages/pi-tui/dist/components/input.js +10 -0
  62. package/packages/pi-tui/dist/components/input.js.map +1 -1
  63. package/packages/pi-tui/src/components/input.ts +11 -0
  64. package/src/resources/extensions/gsd/auto-worktree.ts +29 -183
  65. package/src/resources/extensions/gsd/auto.ts +252 -370
  66. package/src/resources/extensions/gsd/commands.ts +118 -34
  67. package/src/resources/extensions/gsd/doctor.ts +29 -4
  68. package/src/resources/extensions/gsd/git-self-heal.ts +0 -71
  69. package/src/resources/extensions/gsd/git-service.ts +8 -431
  70. package/src/resources/extensions/gsd/gitignore.ts +11 -4
  71. package/src/resources/extensions/gsd/guided-flow.ts +141 -5
  72. package/src/resources/extensions/gsd/preferences.ts +18 -17
  73. package/src/resources/extensions/gsd/prompts/discuss.md +35 -0
  74. package/src/resources/extensions/gsd/prompts/queue.md +7 -1
  75. package/src/resources/extensions/gsd/state.ts +26 -8
  76. package/src/resources/extensions/gsd/templates/state.md +0 -1
  77. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +3 -2
  78. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +1 -1
  79. package/src/resources/extensions/gsd/tests/derive-state.test.ts +35 -0
  80. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +22 -4
  81. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +2 -1
  82. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +8 -111
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +11 -770
  84. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +21 -113
  85. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +16 -82
  86. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +29 -50
  87. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +17 -91
  88. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +28 -55
  89. package/src/resources/extensions/gsd/tests/worktree.test.ts +1 -426
  90. package/src/resources/extensions/gsd/types.ts +0 -1
  91. package/src/resources/extensions/gsd/worktree-manager.ts +7 -3
  92. package/src/resources/extensions/gsd/worktree.ts +7 -65
  93. package/src/resources/extensions/search-the-web/command-search-provider.ts +3 -1
  94. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  95. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  96. package/dist/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
  97. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +0 -282
  98. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +0 -107
  99. package/src/resources/extensions/gsd/tests/orphaned-branch.test.ts +0 -353
@@ -14,16 +14,13 @@ import { join, sep } from "node:path";
14
14
 
15
15
  import {
16
16
  detectWorktreeName,
17
- getSliceBranchName,
18
17
  SLICE_BRANCH_RE,
19
18
  } from "./worktree.js";
20
19
  import {
21
20
  nativeGetCurrentBranch,
22
21
  nativeDetectMainBranch,
23
22
  nativeBranchExists,
24
- nativeHasMergeConflicts,
25
23
  nativeHasChanges,
26
- nativeCommitCountBetween,
27
24
  } from "./native-git-bridge.js";
28
25
 
29
26
  // ─── Types ─────────────────────────────────────────────────────────────────
@@ -37,8 +34,6 @@ export interface GitPreferences {
37
34
  commit_type?: string;
38
35
  main_branch?: string;
39
36
  merge_strategy?: "squash" | "merge";
40
- isolation?: "worktree" | "branch";
41
- merge_to_main?: "milestone" | "slice";
42
37
  }
43
38
 
44
39
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -48,12 +43,6 @@ export interface CommitOptions {
48
43
  allowEmpty?: boolean;
49
44
  }
50
45
 
51
- export interface MergeSliceResult {
52
- branch: string;
53
- mergedCommitMessage: string;
54
- deletedBranch: boolean;
55
- }
56
-
57
46
  /**
58
47
  * Thrown when a slice merge hits code conflicts in non-.gsd files.
59
48
  * The working tree is left in a conflicted state (no reset) so the
@@ -106,22 +95,8 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
106
95
  ".gsd/metrics.json",
107
96
  ".gsd/completed-units.json",
108
97
  ".gsd/STATE.md",
109
- ];
110
-
111
- /**
112
- * GSD planning artifact paths that must be force-added even when .gsd/
113
- * is in .gitignore. These are durable planning files that the agent writes
114
- * and that must survive squash-merges to main.
115
- *
116
- * `git add --force` is a no-op when the path doesn't exist or has no
117
- * changes, so this list is safe to apply unconditionally.
118
- */
119
- const GSD_DURABLE_PATHS: readonly string[] = [
120
- ".gsd/milestones/",
121
- ".gsd/DECISIONS.md",
122
- ".gsd/QUEUE.md",
123
- ".gsd/PROJECT.md",
124
- ".gsd/REQUIREMENTS.md",
98
+ ".gsd/gsd.db",
99
+ ".gsd/DISCUSSION-MANIFEST.json",
125
100
  ];
126
101
 
127
102
  // ─── Integration Branch Metadata ───────────────────────────────────────────
@@ -157,13 +132,11 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
157
132
  * Persist the integration branch for a milestone.
158
133
  *
159
134
  * Called when auto-mode starts on a milestone. Records the branch the user
160
- * was on at that point, so that slice branches merge back to it instead of
161
- * the repo's default branch. Idempotent when the branch matches; updates
162
- * the record when the user starts from a different branch.
135
+ * was on at that point, so the milestone worktree merges back to the correct
136
+ * branch. Idempotent when the branch matches; updates the record when the
137
+ * user starts from a different branch.
163
138
  *
164
- * The file is committed immediately so it survives branch switches the
165
- * pre-switch auto-commit excludes `.gsd/` to avoid merge conflicts, and
166
- * uncommitted `.gsd/` files are discarded during checkout.
139
+ * The file is committed immediately so the metadata is persisted in git.
167
140
  */
168
141
  export function writeIntegrationBranch(basePath: string, milestoneId: string, branch: string): void {
169
142
  // Don't record slice branches as the integration target
@@ -190,12 +163,9 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
190
163
  existing.integrationBranch = branch;
191
164
  writeFileSync(metaFile, JSON.stringify(existing, null, 2) + "\n", "utf-8");
192
165
 
193
- // Commit immediately .gsd/ files are discarded during branch switches
194
- // (ensureSliceBranch excludes .gsd/ from pre-switch auto-commit and runs
195
- // git checkout -- .gsd/ to prevent checkout conflicts). Without this
196
- // commit, the metadata would be lost on the first branch switch.
166
+ // Commit immediately so the metadata is persisted in git.
197
167
  try {
198
- runGit(basePath, ["add", "--force", metaFile]);
168
+ runGit(basePath, ["add", metaFile]);
199
169
  runGit(basePath, ["commit", "--no-verify", "-F", "-"], {
200
170
  input: `chore(${milestoneId}): record integration branch`,
201
171
  });
@@ -332,20 +302,6 @@ export class GitServiceImpl {
332
302
  // error handling is needed per-path.
333
303
  this.git(["add", "-A"]);
334
304
 
335
- // Force-add GSD planning artifacts that live under .gsd/ but may be
336
- // blocked by a .gsd/ gitignore pattern. `git add -A` respects .gitignore,
337
- // so new files (CONTEXT.md, SUMMARY.md, PLAN.md, etc.) in gitignored
338
- // directories are silently skipped. Without this force-add, planning
339
- // artifacts are never committed — they exist on disk but not in git.
340
- // Squash-merges then delete them on main because they appear as "removed
341
- // relative to main" during the merge.
342
- //
343
- // Only force-add durable planning paths — runtime paths are excluded
344
- // by the reset step below.
345
- for (const durablePath of GSD_DURABLE_PATHS) {
346
- this.git(["add", "--force", "--", durablePath], { allowFailure: true });
347
- }
348
-
349
305
  for (const exclusion of allExclusions) {
350
306
  this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
351
307
  }
@@ -446,140 +402,8 @@ export class GitServiceImpl {
446
402
  }
447
403
 
448
404
  /** True if currently on a GSD slice branch. */
449
- isOnSliceBranch(): boolean {
450
- const current = this.getCurrentBranch();
451
- return SLICE_BRANCH_RE.test(current);
452
- }
453
-
454
- /** Returns the slice branch name if on one, null otherwise. */
455
- getActiveSliceBranch(): string | null {
456
- try {
457
- const current = this.getCurrentBranch();
458
- return SLICE_BRANCH_RE.test(current) ? current : null;
459
- } catch {
460
- return null;
461
- }
462
- }
463
-
464
405
  // ─── Branch Lifecycle ──────────────────────────────────────────────────
465
406
 
466
- /**
467
- * Check if a local branch exists. Native libgit2 when available, execSync fallback.
468
- */
469
- private branchExists(branch: string): boolean {
470
- return nativeBranchExists(this.basePath, branch);
471
- }
472
-
473
- /**
474
- * Ensure the slice branch exists and is checked out.
475
- *
476
- * Creates the branch from the current working branch if it's not a slice
477
- * branch (preserves planning artifacts). Falls back to the integration
478
- * branch when on another slice branch (avoids chaining slice branches).
479
- *
480
- * Auto-commits dirty state via smart staging before checkout so runtime
481
- * files are never accidentally committed during branch switches.
482
- *
483
- * Returns true if the branch was newly created.
484
- */
485
- ensureSliceBranch(milestoneId: string, sliceId: string): boolean {
486
- const wtName = detectWorktreeName(this.basePath);
487
- const branch = getSliceBranchName(milestoneId, sliceId, wtName);
488
- const current = this.getCurrentBranch();
489
-
490
- if (current === branch) return false;
491
-
492
- let created = false;
493
-
494
- if (!this.branchExists(branch)) {
495
- // Fetch from remote before creating a new branch (best-effort).
496
- const remotes = this.git(["remote"], { allowFailure: true });
497
- if (remotes) {
498
- const remote = this.prefs.remote ?? "origin";
499
- const fetchResult = this.git(["fetch", "--prune", remote], { allowFailure: true });
500
- if (fetchResult === "" && remotes.split("\n").includes(remote)) {
501
- // Check if local is behind upstream (informational only)
502
- const behind = this.git(
503
- ["rev-list", "--count", "HEAD..@{upstream}"],
504
- { allowFailure: true },
505
- );
506
- if (behind && parseInt(behind, 10) > 0) {
507
- console.error(`GitService: local branch is ${behind} commit(s) behind upstream`);
508
- }
509
- }
510
- }
511
-
512
- // Branch from current when it's a normal working branch (not a slice).
513
- // If already on a slice branch, fall back to the integration branch to avoid chaining.
514
- const mainBranch = this.getMainBranch();
515
- const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
516
- this.git(["branch", branch, base]);
517
- created = true;
518
- } else {
519
- // Branch exists — check it's not checked out in another worktree
520
- const worktreeList = this.git(["worktree", "list", "--porcelain"]);
521
- if (worktreeList.includes(`branch refs/heads/${branch}`)) {
522
- throw new Error(
523
- `Branch "${branch}" is already in use by another worktree. ` +
524
- `Remove that worktree first, or switch it to a different branch.`,
525
- );
526
- }
527
- }
528
-
529
- // Auto-commit dirty state via smart staging before checkout.
530
- // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
531
- this.autoCommit("pre-switch", current, [".gsd/"]);
532
-
533
- // Discard uncommitted .gsd/ changes so checkout doesn't fail.
534
- // Two-step approach handles both tracked and untracked runtime files:
535
- // 1. `checkout --` reverts tracked .gsd/ files to their HEAD versions.
536
- // 2. `clean -fdx` removes untracked runtime files that the target branch has
537
- // tracked — e.g., when a prior cleanup commit removed STATE.md from the
538
- // current branch's HEAD but the target branch still has it committed.
539
- this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
540
- this.discardUntrackedRuntimeFiles();
541
-
542
- this.git(["checkout", branch]);
543
- return created;
544
- }
545
-
546
- /**
547
- * Switch to the integration branch, auto-committing dirty state via smart staging first.
548
- */
549
- switchToMain(): void {
550
- const mainBranch = this.getMainBranch();
551
- const current = this.getCurrentBranch();
552
- if (current === mainBranch) return;
553
-
554
- // Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
555
- this.autoCommit("pre-switch", current, [".gsd/"]);
556
-
557
- // Discard uncommitted .gsd/ changes so checkout doesn't fail.
558
- // Two-step approach handles both tracked and untracked runtime files.
559
- this.git(["checkout", "--", ".gsd/"], { allowFailure: true });
560
- this.discardUntrackedRuntimeFiles();
561
-
562
- this.git(["checkout", mainBranch]);
563
- }
564
-
565
- /**
566
- * Remove untracked runtime files from the working tree.
567
- *
568
- * Complements `git checkout -- .gsd/` (which only handles tracked files).
569
- * Runtime files can end up untracked after a cleanup commit removes them
570
- * from the current branch's HEAD — but the target branch may still have
571
- * them committed. Without this step, `git checkout` fails with:
572
- * "The following untracked working tree files would be overwritten by checkout"
573
- *
574
- * `git clean -fdx` is safe here because:
575
- * - Only removes *untracked* files (tracked files are untouched)
576
- * - Targets only the specific runtime paths listed in RUNTIME_EXCLUSION_PATHS
577
- * - These files are always regenerated by GSD on the next run
578
- */
579
- private discardUntrackedRuntimeFiles(): void {
580
- this.git(["clean", "-fdx", "--", ...RUNTIME_EXCLUSION_PATHS], { allowFailure: true });
581
- }
582
-
583
407
  // ─── S05 Features ─────────────────────────────────────────────────────
584
408
 
585
409
  /**
@@ -644,253 +468,6 @@ export class GitServiceImpl {
644
468
 
645
469
  // ─── Merge ─────────────────────────────────────────────────────────────
646
470
 
647
- /**
648
- * Build a rich squash-commit message with a task list from branch commits.
649
- *
650
- * Format:
651
- * type(scope): title
652
- *
653
- * Tasks:
654
- * - commit subject 1
655
- * - commit subject 2
656
- *
657
- * Branch: gsd/M001/S01
658
- */
659
- private buildRichCommitMessage(
660
- commitType: string,
661
- milestoneId: string,
662
- sliceId: string,
663
- sliceTitle: string,
664
- mainBranch: string,
665
- branch: string,
666
- ): string {
667
- const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
668
-
669
- // Collect branch commit subjects
670
- const logOutput = this.git(
671
- ["log", "--oneline", "--format=%s", `${mainBranch}..${branch}`],
672
- { allowFailure: true },
673
- );
674
-
675
- if (!logOutput) return subject;
676
-
677
- const subjects = logOutput.split("\n").filter(Boolean);
678
- const MAX_ENTRIES = 20;
679
- const truncated = subjects.length > MAX_ENTRIES;
680
- const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
681
-
682
- const taskLines = displayed.map(s => `- ${s}`).join("\n");
683
- const truncationLine = truncated ? `\n- ... and ${subjects.length - MAX_ENTRIES} more` : "";
684
-
685
- return `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${branch}`;
686
- }
687
-
688
- /**
689
- * Squash-merge a slice branch into the integration branch and delete it.
690
- *
691
- * The integration branch is resolved by getMainBranch() — this may be
692
- * `main`, a feature branch, or a worktree branch depending on context.
693
- *
694
- * Flow: snapshot branch HEAD → squash merge → rich commit via stdin →
695
- * auto-push (if enabled) → delete branch.
696
- *
697
- * Must be called from the integration branch. Uses `inferCommitType(sliceTitle)`
698
- * for the conventional commit type instead of hardcoding `feat`.
699
- *
700
- * Throws when:
701
- * - Not currently on the integration branch
702
- * - The slice branch does not exist
703
- * - The slice branch has no commits ahead of the integration branch
704
- */
705
- mergeSliceToMain(milestoneId: string, sliceId: string, sliceTitle: string): MergeSliceResult {
706
- const mainBranch = this.getMainBranch();
707
- const current = this.getCurrentBranch();
708
-
709
- if (current !== mainBranch) {
710
- throw new Error(
711
- `mergeSliceToMain must be called from the main branch ("${mainBranch}"), ` +
712
- `but currently on "${current}"`,
713
- );
714
- }
715
-
716
- const wtName = detectWorktreeName(this.basePath);
717
- const branch = getSliceBranchName(milestoneId, sliceId, wtName);
718
-
719
- if (!this.branchExists(branch)) {
720
- throw new Error(
721
- `Slice branch "${branch}" does not exist. Nothing to merge.`,
722
- );
723
- }
724
-
725
- // Check commits ahead — native libgit2 revwalk when available
726
- const aheadCount = nativeCommitCountBetween(this.basePath, mainBranch, branch);
727
- if (aheadCount === 0) {
728
- throw new Error(
729
- `Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
730
- );
731
- }
732
-
733
- // Snapshot the branch HEAD before merge (gated on prefs)
734
- // We need to save the ref while the branch still exists
735
- this.createSnapshot(branch);
736
-
737
- // Build rich commit message before squash (needs branch history)
738
- const commitType = inferCommitType(sliceTitle);
739
- const message = this.buildRichCommitMessage(
740
- commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch,
741
- );
742
-
743
- // Pull latest main before merging to avoid conflicts from remote changes
744
- this.git(["pull", "--rebase", "origin", mainBranch], { allowFailure: true });
745
-
746
- // Untrack runtime files that may have been manually committed (e.g. via `gsd queue`)
747
- // to prevent merge conflicts on files that belong in .gitignore (#189)
748
- for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
749
- this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
750
- }
751
- const untrackDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
752
- if (untrackDiff && untrackDiff.trim()) {
753
- this.git(["commit", "--no-verify", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true });
754
- }
755
-
756
- // Merge slice branch — strategy is configurable via git.merge_strategy
757
- // preference. Default: "squash" (preserves existing behavior).
758
- // "merge" uses --no-ff which is more resilient to conflicts from
759
- // long-lived branches or frequently-changing .gsd/* artifacts.
760
- const strategy = this.prefs.merge_strategy ?? "squash";
761
- const mergeArgs = strategy === "merge"
762
- ? ["merge", "--no-ff", "-m", message, branch]
763
- : ["merge", "--squash", branch];
764
-
765
- try {
766
- this.git(mergeArgs);
767
- } catch (mergeError) {
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
- // ─────────────────────────────────────────────────────────────────────
778
- const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
779
- if (conflicted) {
780
- const conflictedFiles = conflicted.split("\n").filter(Boolean);
781
- const isRuntimeConflict = (f: string) =>
782
- RUNTIME_EXCLUSION_PATHS.some(excl => f.startsWith(excl.replace(/\/$/, "")));
783
-
784
- const runtimeConflicts = conflictedFiles.filter(isRuntimeConflict);
785
- const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/") && !isRuntimeConflict(f));
786
- const otherConflicts = conflictedFiles.filter(
787
- f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"),
788
- );
789
-
790
- let resolvedAny = false;
791
-
792
- if (runtimeConflicts.length > 0) {
793
- // Runtime conflicts: take theirs and remove from index
794
- for (const f of runtimeConflicts) {
795
- this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
796
- this.git(["rm", "--cached", "--ignore-unmatch", f], { allowFailure: true });
797
- }
798
- resolvedAny = true;
799
- }
800
-
801
- if (gsdConflicts.length > 0) {
802
- // Non-runtime .gsd/ conflicts (DECISIONS.md, REQUIREMENTS.md, ROADMAP.md, etc.):
803
- // The slice branch has the authoritative .gsd/ state since the LLM just finished
804
- // updating these artifacts during complete-slice. Take theirs (the slice branch).
805
- for (const f of gsdConflicts) {
806
- this.git(["checkout", "--theirs", "--", f], { allowFailure: true });
807
- }
808
- resolvedAny = true;
809
- }
810
-
811
- if (resolvedAny) {
812
- this.git(["add", "-A"], { allowFailure: true });
813
-
814
- // Re-check remaining conflicts after auto-resolving runtime and .gsd/ files
815
- const remaining = this.git(["diff", "--name-only", "--diff-filter=U"], {
816
- allowFailure: true,
817
- });
818
- if (remaining) {
819
- const remainingFiles = remaining
820
- .split("\n")
821
- .filter(Boolean)
822
- .filter(f => !isRuntimeConflict(f) && !f.startsWith(".gsd/"));
823
-
824
- if (remainingFiles.length > 0) {
825
- // Non-runtime, non-.gsd/ conflicts: leave working tree in conflicted state and throw
826
- // MergeConflictError so the caller can dispatch a fix-merge session.
827
- throw new MergeConflictError(remainingFiles, strategy, branch, mainBranch);
828
- }
829
- }
830
- // No remaining non-runtime, non-.gsd/ conflicts — let the merge proceed
831
- } else {
832
- // No runtime or .gsd/ conflicts to auto-resolve; throw with original conflicted files
833
- // so the caller can dispatch a fix-merge session.
834
- throw new MergeConflictError(otherConflicts.length ? otherConflicts : conflictedFiles, strategy, branch, mainBranch);
835
- }
836
- } else {
837
- // No conflicted files detected but merge still failed — reset and throw
838
- this.git(["reset", "--hard", "HEAD"], { allowFailure: true });
839
- const msg = mergeError instanceof Error ? mergeError.message : String(mergeError);
840
- throw new Error(
841
- `${strategy === "merge" ? "Merge" : "Squash-merge"} of "${branch}" into "${mainBranch}" failed. ` +
842
- `Working tree has been reset to a clean state. ` +
843
- `Resolve manually: git checkout ${mainBranch} && git merge ${strategy === "merge" ? "--no-ff" : "--squash"} ${branch}\n` +
844
- `Original error: ${msg}`,
845
- );
846
- }
847
- }
848
-
849
- // Strip runtime files from the merge result before committing (#302).
850
- // This replaces the old approach of checking out the slice branch to
851
- // untrack runtime files pre-merge, which failed when the working tree
852
- // had uncommitted .gsd/ changes that blocked the checkout.
853
- for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
854
- this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
855
- }
856
-
857
- if (strategy === "squash") {
858
- // After stripping runtime files, there may be nothing left to commit.
859
- // This happens when the only changes in the slice were runtime artifacts.
860
- const stagedDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
861
- if (stagedDiff?.trim()) {
862
- this.git(["commit", "--no-verify", "-F", "-"], { input: message });
863
- } else {
864
- // Nothing to commit — clean up the squash-merge state
865
- this.git(["reset", "HEAD"], { allowFailure: true });
866
- }
867
- } else {
868
- // --no-ff already committed; amend to include runtime file removal
869
- const runtimeDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
870
- if (runtimeDiff?.trim()) {
871
- this.git(["commit", "--amend", "--no-edit", "--no-verify"]);
872
- }
873
- }
874
-
875
- // Delete the merged branch
876
- this.git(["branch", "-D", branch]);
877
-
878
- // Auto-push to remote if enabled
879
- if (this.prefs.auto_push === true) {
880
- const remote = this.prefs.remote ?? "origin";
881
- const pushResult = this.git(["push", remote, mainBranch], { allowFailure: true });
882
- if (pushResult === "") {
883
- // push succeeded (empty stdout is normal) or failed silently
884
- // Verify by checking if remote is reachable — the allowFailure handles errors
885
- }
886
- }
887
-
888
- return {
889
- branch,
890
- mergedCommitMessage: `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`,
891
- deletedBranch: true,
892
- };
893
- }
894
471
  }
895
472
 
896
473
  // ─── Commit Type Inference ─────────────────────────────────────────────────
@@ -14,8 +14,7 @@ import { execSync } from "node:child_process";
14
14
  * Patterns that are always correct regardless of project type.
15
15
  * No one ever wants these tracked.
16
16
  */
17
- const BASELINE_PATTERNS = [
18
- // ── GSD runtime (not source artifacts) ──
17
+ const GSD_RUNTIME_PATTERNS = [
19
18
  ".gsd/activity/",
20
19
  ".gsd/runtime/",
21
20
  ".gsd/worktrees/",
@@ -23,6 +22,15 @@ const BASELINE_PATTERNS = [
23
22
  ".gsd/metrics.json",
24
23
  ".gsd/completed-units.json",
25
24
  ".gsd/STATE.md",
25
+ ".gsd/gsd.db",
26
+ ".gsd/DISCUSSION-MANIFEST.json",
27
+ ".gsd/milestones/**/*-CONTINUE.md",
28
+ ".gsd/milestones/**/continue.md",
29
+ ] as const;
30
+
31
+ const BASELINE_PATTERNS = [
32
+ // ── GSD runtime (not source artifacts — planning files are tracked) ──
33
+ ...GSD_RUNTIME_PATTERNS,
26
34
 
27
35
  // ── OS junk ──
28
36
  ".DS_Store",
@@ -116,8 +124,7 @@ export function ensureGitignore(basePath: string): boolean {
116
124
  * Only removes from the index (`--cached`), never from disk. Idempotent.
117
125
  */
118
126
  export function untrackRuntimeFiles(basePath: string): void {
119
- // The GSD runtime paths are the first 7 entries in BASELINE_PATTERNS
120
- const runtimePaths = BASELINE_PATTERNS.slice(0, 7);
127
+ const runtimePaths = GSD_RUNTIME_PATTERNS;
121
128
 
122
129
  for (const pattern of runtimePaths) {
123
130
  // Use -r for directory patterns (trailing slash), strip the slash for the command