gsd-pi 2.4.0 → 2.5.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.
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+ import type { GitPreferences } from "./git-service.ts";
5
6
 
6
7
  const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
7
8
  const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
@@ -51,6 +52,7 @@ export interface GSDPreferences {
51
52
  uat_dispatch?: boolean;
52
53
  budget_ceiling?: number;
53
54
  remote_questions?: RemoteQuestionsConfig;
55
+ git?: GitPreferences;
54
56
  }
55
57
 
56
58
  export interface LoadedGSDPreferences {
@@ -511,6 +513,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
511
513
  remote_questions: override.remote_questions
512
514
  ? { ...(base.remote_questions ?? {}), ...override.remote_questions }
513
515
  : base.remote_questions,
516
+ git: (base.git || override.git)
517
+ ? { ...(base.git ?? {}), ...(override.git ?? {}) }
518
+ : undefined,
514
519
  };
515
520
  }
516
521
 
@@ -594,6 +599,52 @@ function validatePreferences(preferences: GSDPreferences): {
594
599
  }
595
600
  }
596
601
 
602
+ // ─── Git Preferences ───────────────────────────────────────────────────
603
+ if (preferences.git && typeof preferences.git === "object") {
604
+ const git: Record<string, unknown> = {};
605
+ const g = preferences.git as Record<string, unknown>;
606
+
607
+ if (g.auto_push !== undefined) {
608
+ if (typeof g.auto_push === "boolean") git.auto_push = g.auto_push;
609
+ else errors.push("git.auto_push must be a boolean");
610
+ }
611
+ if (g.push_branches !== undefined) {
612
+ if (typeof g.push_branches === "boolean") git.push_branches = g.push_branches;
613
+ else errors.push("git.push_branches must be a boolean");
614
+ }
615
+ if (g.remote !== undefined) {
616
+ if (typeof g.remote === "string" && g.remote.trim() !== "") git.remote = g.remote.trim();
617
+ else errors.push("git.remote must be a non-empty string");
618
+ }
619
+ if (g.snapshots !== undefined) {
620
+ if (typeof g.snapshots === "boolean") git.snapshots = g.snapshots;
621
+ else errors.push("git.snapshots must be a boolean");
622
+ }
623
+ if (g.pre_merge_check !== undefined) {
624
+ if (typeof g.pre_merge_check === "boolean") {
625
+ git.pre_merge_check = g.pre_merge_check;
626
+ } else if (typeof g.pre_merge_check === "string" && g.pre_merge_check.trim() !== "") {
627
+ git.pre_merge_check = g.pre_merge_check.trim();
628
+ } else {
629
+ errors.push("git.pre_merge_check must be a boolean or a non-empty string command");
630
+ }
631
+ }
632
+ if (g.commit_type !== undefined) {
633
+ const validCommitTypes = new Set([
634
+ "feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style",
635
+ ]);
636
+ if (typeof g.commit_type === "string" && validCommitTypes.has(g.commit_type)) {
637
+ git.commit_type = g.commit_type;
638
+ } else {
639
+ errors.push(`git.commit_type must be one of: feat, fix, refactor, docs, test, chore, perf, ci, build, style`);
640
+ }
641
+ }
642
+
643
+ if (Object.keys(git).length > 0) {
644
+ validated.git = git as GitPreferences;
645
+ }
646
+ }
647
+
597
648
  return { preferences: validated, errors };
598
649
  }
599
650
 
@@ -15,7 +15,7 @@ Then:
15
15
  6. Write `{{milestoneSummaryAbsPath}}` using the milestone-summary template. Fill all frontmatter fields and narrative sections. The `requirement_outcomes` field must list every requirement that changed status with `from_status`, `to_status`, and `proof`.
16
16
  7. Update `.gsd/REQUIREMENTS.md` if any requirement status transitions were validated in step 5.
17
17
  8. Update `.gsd/PROJECT.md` to reflect milestone completion and current project state.
18
- 9. Commit all changes: `git add -A && git commit -m 'feat(gsd): complete {{milestoneId}}'`
18
+ 9. Do not commit manually the system auto-commits your changes after this unit completes.
19
19
  10. Update `.gsd/STATE.md`
20
20
 
21
21
  **Important:** Do NOT skip the success criteria and definition of done verification (steps 3-4). The milestone summary must reflect actual verified outcomes, not assumed success. If any criterion was not met, document it clearly in the summary and do not mark the milestone as passing verification.
@@ -18,7 +18,7 @@ Then:
18
18
  7. Write `{{sliceUatAbsPath}}`. Fill the new `UAT Type`, `Requirements Proved By This UAT`, and `Not Proven By This UAT` sections explicitly.
19
19
  8. Review task summaries for `key_decisions`. Ensure any significant architectural, pattern, or observability decisions are in `.gsd/DECISIONS.md`. If any are missing, append them now.
20
20
  9. Mark {{sliceId}} done in `{{roadmapPath}}` (change `[ ]` to `[x]`)
21
- 10. Commit all remaining slice changes: `git add -A && git commit -m 'feat(gsd): complete {{sliceId}}'`. Do not squash-merge manually; the extension will merge the slice branch back to main after this unit succeeds.
21
+ 10. Do not commit or squash-merge manually the system auto-commits your changes and handles the merge after this unit succeeds.
22
22
  11. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed.
23
23
  12. Update `.gsd/STATE.md`
24
24
 
@@ -55,7 +55,7 @@ Then:
55
55
  13. Read the template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md`
56
56
  14. Write `{{taskSummaryAbsPath}}`
57
57
  15. Mark {{taskId}} done in `{{planPath}}` (change `[ ]` to `[x]`)
58
- 16. Commit your work: `git add -A && git commit -m 'feat({{sliceId}}/{{taskId}}): <what was built>'`. If `git add` silently fails to stage files (a known git worktree stat-cache bug), use this workaround per file: `git update-index --cacheinfo 100644,$(git hash-object -w <file>),<file>` then commit. If that also fails, move on — the system will auto-commit remaining changes after your session ends.
58
+ 16. Do not commit manually — the system auto-commits your changes after this unit completes.
59
59
  17. Update `.gsd/STATE.md`
60
60
 
61
61
  You are on the slice branch. All work stays here.
@@ -31,7 +31,7 @@ All relevant context has been preloaded below — the roadmap, current slice pla
31
31
  - Ensure the slice Goal and Demo sections are still achievable with the new tasks, or update them if the blocker fundamentally changes what the slice can deliver
32
32
  - Update the Files Likely Touched section if the replan changes which files are affected
33
33
  5. If any incomplete task had a `T0x-PLAN.md`, remove or rewrite it to match the new task description.
34
- 6. Commit all changes: `git add -A && git commit -m 'refactor({{sliceId}}): replan after blocker in {{blockerTaskId}}'`
34
+ 6. Do not commit manually the system auto-commits your changes after this unit completes.
35
35
  7. Update `.gsd/STATE.md`
36
36
 
37
37
  **You MUST write `{{replanAbsPath}}` and the updated slice plan before finishing.**
@@ -8,6 +8,13 @@ custom_instructions: []
8
8
  models: {}
9
9
  skill_discovery:
10
10
  auto_supervisor: {}
11
+ git:
12
+ auto_push:
13
+ push_branches:
14
+ remote:
15
+ snapshots:
16
+ pre_merge_check:
17
+ commit_type:
11
18
  ---
12
19
 
13
20
  # GSD Skill Preferences
@@ -11,6 +11,7 @@ import {
11
11
  type GitPreferences,
12
12
  type CommitOptions,
13
13
  type MergeSliceResult,
14
+ type PreMergeCheckResult,
14
15
  } from "../git-service.ts";
15
16
 
16
17
  let passed = 0;
@@ -881,6 +882,363 @@ async function main(): Promise<void> {
881
882
  rmSync(repo, { recursive: true, force: true });
882
883
  }
883
884
 
885
+ // ═══════════════════════════════════════════════════════════════════════
886
+ // S05: Enhanced features — merge guards, snapshots, auto-push, rich commits
887
+ // ═══════════════════════════════════════════════════════════════════════
888
+
889
+ // ─── createSnapshot: prefs enabled ─────────────────────────────────────
890
+
891
+ console.log("\n=== createSnapshot: enabled ===");
892
+
893
+ {
894
+ const repo = initBranchTestRepo();
895
+ const svc = new GitServiceImpl(repo, { snapshots: true });
896
+
897
+ // Create a slice branch with a commit
898
+ svc.ensureSliceBranch("M001", "S01");
899
+ createFile(repo, "src/snap.ts", "snapshot me");
900
+ svc.commit({ message: "snapshot test commit" });
901
+
902
+ // Create snapshot ref for this slice branch
903
+ svc.createSnapshot("gsd/M001/S01");
904
+
905
+ // Verify ref exists under refs/gsd/snapshots/
906
+ const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
907
+ assert(refs.includes("refs/gsd/snapshots/gsd/M001/S01/"), "snapshot ref created under refs/gsd/snapshots/");
908
+
909
+ rmSync(repo, { recursive: true, force: true });
910
+ }
911
+
912
+ // ─── createSnapshot: prefs disabled ────────────────────────────────────
913
+
914
+ console.log("\n=== createSnapshot: disabled ===");
915
+
916
+ {
917
+ const repo = initBranchTestRepo();
918
+ const svc = new GitServiceImpl(repo, { snapshots: false });
919
+
920
+ svc.ensureSliceBranch("M001", "S01");
921
+ createFile(repo, "src/no-snap.ts", "no snapshot");
922
+ svc.commit({ message: "no snapshot commit" });
923
+
924
+ // createSnapshot should be a no-op when disabled
925
+ svc.createSnapshot("gsd/M001/S01");
926
+
927
+ const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
928
+ assertEq(refs, "", "no snapshot ref created when prefs.snapshots is false");
929
+
930
+ rmSync(repo, { recursive: true, force: true });
931
+ }
932
+
933
+ // ─── runPreMergeCheck: pass ────────────────────────────────────────────
934
+
935
+ console.log("\n=== runPreMergeCheck: pass ===");
936
+
937
+ {
938
+ const repo = initBranchTestRepo();
939
+ // Create package.json with passing test script
940
+ createFile(repo, "package.json", JSON.stringify({
941
+ name: "test-pass",
942
+ scripts: { test: "node -e 'process.exit(0)'" },
943
+ }));
944
+ run("git add -A", repo);
945
+ run("git commit -m 'add package.json'", repo);
946
+
947
+ const svc = new GitServiceImpl(repo, { pre_merge_check: true });
948
+ const result: PreMergeCheckResult = svc.runPreMergeCheck();
949
+
950
+ assertEq(result.passed, true, "runPreMergeCheck returns passed:true when tests pass");
951
+ assert(!result.skipped, "runPreMergeCheck is not skipped when enabled");
952
+
953
+ rmSync(repo, { recursive: true, force: true });
954
+ }
955
+
956
+ // ─── runPreMergeCheck: fail ────────────────────────────────────────────
957
+
958
+ console.log("\n=== runPreMergeCheck: fail ===");
959
+
960
+ {
961
+ const repo = initBranchTestRepo();
962
+ // Create package.json with failing test script
963
+ createFile(repo, "package.json", JSON.stringify({
964
+ name: "test-fail",
965
+ scripts: { test: "node -e 'process.exit(1)'" },
966
+ }));
967
+ run("git add -A", repo);
968
+ run("git commit -m 'add failing package.json'", repo);
969
+
970
+ const svc = new GitServiceImpl(repo, { pre_merge_check: true });
971
+ const result: PreMergeCheckResult = svc.runPreMergeCheck();
972
+
973
+ assertEq(result.passed, false, "runPreMergeCheck returns passed:false when tests fail");
974
+ assert(!result.skipped, "runPreMergeCheck is not skipped when enabled");
975
+
976
+ rmSync(repo, { recursive: true, force: true });
977
+ }
978
+
979
+ // ─── runPreMergeCheck: disabled ────────────────────────────────────────
980
+
981
+ console.log("\n=== runPreMergeCheck: disabled ===");
982
+
983
+ {
984
+ const repo = initBranchTestRepo();
985
+ createFile(repo, "package.json", JSON.stringify({
986
+ name: "test-disabled",
987
+ scripts: { test: "node -e 'process.exit(1)'" },
988
+ }));
989
+ run("git add -A", repo);
990
+ run("git commit -m 'add package.json'", repo);
991
+
992
+ const svc = new GitServiceImpl(repo, { pre_merge_check: false });
993
+ const result: PreMergeCheckResult = svc.runPreMergeCheck();
994
+
995
+ assertEq(result.skipped, true, "runPreMergeCheck skipped when pre_merge_check is false");
996
+ assertEq(result.passed, true, "runPreMergeCheck returns passed:true when skipped (no block)");
997
+
998
+ rmSync(repo, { recursive: true, force: true });
999
+ }
1000
+
1001
+ // ─── runPreMergeCheck: custom command ──────────────────────────────────
1002
+
1003
+ console.log("\n=== runPreMergeCheck: custom command ===");
1004
+
1005
+ {
1006
+ const repo = initBranchTestRepo();
1007
+ // Custom command string overrides auto-detection
1008
+ const svc = new GitServiceImpl(repo, { pre_merge_check: "node -e 'process.exit(0)'" });
1009
+ const result: PreMergeCheckResult = svc.runPreMergeCheck();
1010
+
1011
+ assertEq(result.passed, true, "runPreMergeCheck passes with custom command that exits 0");
1012
+ assert(!result.skipped, "custom command is not skipped");
1013
+
1014
+ rmSync(repo, { recursive: true, force: true });
1015
+ }
1016
+
1017
+ // ─── Rich commit message ──────────────────────────────────────────────
1018
+
1019
+ console.log("\n=== mergeSliceToMain: rich commit message ===");
1020
+
1021
+ {
1022
+ const repo = initBranchTestRepo();
1023
+ const svc = new GitServiceImpl(repo, { pre_merge_check: false });
1024
+
1025
+ svc.ensureSliceBranch("M001", "S01");
1026
+
1027
+ // Make 3 distinct commits on the slice branch
1028
+ createFile(repo, "src/auth.ts", "export const auth = true;");
1029
+ svc.commit({ message: "add auth module" });
1030
+
1031
+ createFile(repo, "src/login.ts", "export const login = true;");
1032
+ svc.commit({ message: "add login page" });
1033
+
1034
+ createFile(repo, "src/session.ts", "export const session = true;");
1035
+ svc.commit({ message: "add session handling" });
1036
+
1037
+ svc.switchToMain();
1038
+ const result = svc.mergeSliceToMain("M001", "S01", "Implement user authentication");
1039
+
1040
+ // Inspect the full commit body on main
1041
+ const commitBody = run("git log -1 --format=%B", repo);
1042
+
1043
+ // Rich commit should have the subject line
1044
+ assert(commitBody.includes("feat(M001/S01): Implement user authentication"),
1045
+ "rich commit has conventional subject line");
1046
+
1047
+ // Rich commit body should include task list with commit subjects
1048
+ assert(commitBody.includes("add auth module"),
1049
+ "rich commit body includes first commit subject");
1050
+ assert(commitBody.includes("add login page"),
1051
+ "rich commit body includes second commit subject");
1052
+ assert(commitBody.includes("add session handling"),
1053
+ "rich commit body includes third commit subject");
1054
+
1055
+ // Rich commit body should include Branch: line for forensics
1056
+ assert(commitBody.includes("Branch:"),
1057
+ "rich commit body includes Branch: line");
1058
+ assert(commitBody.includes("gsd/M001/S01"),
1059
+ "rich commit body Branch: line includes slice branch name");
1060
+
1061
+ rmSync(repo, { recursive: true, force: true });
1062
+ }
1063
+
1064
+ // ─── Auto-push: enabled ───────────────────────────────────────────────
1065
+
1066
+ console.log("\n=== Auto-push: enabled ===");
1067
+
1068
+ {
1069
+ // Create a bare remote repo
1070
+ const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
1071
+ run("git init --bare -b main", bareDir);
1072
+
1073
+ // Create local repo and add the bare as remote
1074
+ const repo = initBranchTestRepo();
1075
+ run(`git remote add origin ${bareDir}`, repo);
1076
+ run("git push -u origin main", repo);
1077
+
1078
+ const svc = new GitServiceImpl(repo, { auto_push: true, pre_merge_check: false });
1079
+
1080
+ svc.ensureSliceBranch("M001", "S01");
1081
+ createFile(repo, "src/pushed.ts", "export const pushed = true;");
1082
+ svc.commit({ message: "work to push" });
1083
+
1084
+ svc.switchToMain();
1085
+ svc.mergeSliceToMain("M001", "S01", "Add pushed feature");
1086
+
1087
+ // Verify the remote has the merge commit
1088
+ const remoteLog = run(`git --git-dir=${bareDir} log --oneline -1`, bareDir);
1089
+ assert(remoteLog.includes("Add pushed feature"),
1090
+ "auto-push: remote has the merge commit when auto_push is true");
1091
+
1092
+ rmSync(repo, { recursive: true, force: true });
1093
+ rmSync(bareDir, { recursive: true, force: true });
1094
+ }
1095
+
1096
+ // ─── Auto-push: disabled ──────────────────────────────────────────────
1097
+
1098
+ console.log("\n=== Auto-push: disabled ===");
1099
+
1100
+ {
1101
+ const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
1102
+ run("git init --bare -b main", bareDir);
1103
+
1104
+ const repo = initBranchTestRepo();
1105
+ run(`git remote add origin ${bareDir}`, repo);
1106
+ run("git push -u origin main", repo);
1107
+
1108
+ // auto_push explicitly false (or omitted — same behavior)
1109
+ const svc = new GitServiceImpl(repo, { auto_push: false, pre_merge_check: false });
1110
+
1111
+ svc.ensureSliceBranch("M001", "S01");
1112
+ createFile(repo, "src/not-pushed.ts", "export const notPushed = true;");
1113
+ svc.commit({ message: "work not pushed" });
1114
+
1115
+ svc.switchToMain();
1116
+ svc.mergeSliceToMain("M001", "S01", "Add unpushed feature");
1117
+
1118
+ // Remote should NOT have the new merge commit — still at the initial push
1119
+ const remoteLog = run(`git --git-dir=${bareDir} log --oneline`, bareDir);
1120
+ assert(!remoteLog.includes("Add unpushed feature"),
1121
+ "auto-push: remote does NOT have merge commit when auto_push is false");
1122
+
1123
+ rmSync(repo, { recursive: true, force: true });
1124
+ rmSync(bareDir, { recursive: true, force: true });
1125
+ }
1126
+
1127
+ // ─── Remote fetch before branching: with remote ────────────────────────
1128
+
1129
+ console.log("\n=== Remote fetch: with remote ===");
1130
+
1131
+ {
1132
+ const bareDir = mkdtempSync(join(tmpdir(), "gsd-git-bare-"));
1133
+ run("git init --bare -b main", bareDir);
1134
+
1135
+ const repo = initBranchTestRepo();
1136
+ run(`git remote add origin ${bareDir}`, repo);
1137
+ run("git push -u origin main", repo);
1138
+
1139
+ // Add a commit to the remote via a temporary clone
1140
+ const cloneDir = mkdtempSync(join(tmpdir(), "gsd-git-clone-"));
1141
+ run(`git clone ${bareDir} ${cloneDir}`, cloneDir);
1142
+ run("git config user.name 'Remote Dev'", cloneDir);
1143
+ run("git config user.email 'remote@example.com'", cloneDir);
1144
+ createFile(cloneDir, "remote-file.txt", "from remote");
1145
+ run("git add -A", cloneDir);
1146
+ run("git commit -m 'remote commit'", cloneDir);
1147
+ run("git push origin main", cloneDir);
1148
+
1149
+ // ensureSliceBranch should fetch before creating the branch — no crash
1150
+ const svc = new GitServiceImpl(repo);
1151
+ let noError = true;
1152
+ try {
1153
+ svc.ensureSliceBranch("M001", "S01");
1154
+ } catch {
1155
+ noError = false;
1156
+ }
1157
+ assert(noError, "ensureSliceBranch succeeds when remote has new commits (fetch runs)");
1158
+
1159
+ rmSync(repo, { recursive: true, force: true });
1160
+ rmSync(bareDir, { recursive: true, force: true });
1161
+ rmSync(cloneDir, { recursive: true, force: true });
1162
+ }
1163
+
1164
+ // ─── Remote fetch before branching: without remote ─────────────────────
1165
+
1166
+ console.log("\n=== Remote fetch: without remote ===");
1167
+
1168
+ {
1169
+ const repo = initBranchTestRepo();
1170
+ // No remote configured — ensureSliceBranch should not crash
1171
+ const svc = new GitServiceImpl(repo);
1172
+
1173
+ let noError = true;
1174
+ try {
1175
+ svc.ensureSliceBranch("M001", "S01");
1176
+ } catch {
1177
+ noError = false;
1178
+ }
1179
+ assert(noError, "ensureSliceBranch succeeds when no remote is configured");
1180
+ assertEq(svc.getCurrentBranch(), "gsd/M001/S01", "branch created even without remote");
1181
+
1182
+ rmSync(repo, { recursive: true, force: true });
1183
+ }
1184
+
1185
+ // ─── Facade prefs: mergeSliceToMain creates snapshot when prefs set ────
1186
+
1187
+ console.log("\n=== Facade prefs: snapshot via merge with prefs ===");
1188
+
1189
+ {
1190
+ const repo = initBranchTestRepo();
1191
+ // Simulate facade behavior: GitServiceImpl with snapshots:true should
1192
+ // create a snapshot ref during mergeSliceToMain
1193
+ const svc = new GitServiceImpl(repo, { snapshots: true, pre_merge_check: false });
1194
+
1195
+ svc.ensureSliceBranch("M001", "S01");
1196
+ createFile(repo, "src/facade-test.ts", "facade");
1197
+ svc.commit({ message: "facade test commit" });
1198
+
1199
+ svc.switchToMain();
1200
+ svc.mergeSliceToMain("M001", "S01", "Facade snapshot test");
1201
+
1202
+ // After merge, a snapshot ref should exist (created before merge)
1203
+ const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
1204
+ assert(refs.includes("refs/gsd/snapshots/"), "mergeSliceToMain creates snapshot when prefs.snapshots is true");
1205
+ assert(refs.includes("gsd/M001/S01"), "snapshot ref references the slice branch name");
1206
+
1207
+ rmSync(repo, { recursive: true, force: true });
1208
+ }
1209
+
1210
+ // ─── Facade prefs: no snapshot when prefs omit snapshots ───────────────
1211
+
1212
+ console.log("\n=== Facade prefs: no snapshot when prefs omit snapshots ===");
1213
+
1214
+ {
1215
+ const repo = initBranchTestRepo();
1216
+ // Default prefs — snapshots not enabled
1217
+ const svc = new GitServiceImpl(repo, { pre_merge_check: false });
1218
+
1219
+ svc.ensureSliceBranch("M001", "S01");
1220
+ createFile(repo, "src/no-facade-snap.ts", "no facade snap");
1221
+ svc.commit({ message: "no facade snapshot" });
1222
+
1223
+ svc.switchToMain();
1224
+ svc.mergeSliceToMain("M001", "S01", "No snapshot test");
1225
+
1226
+ // No snapshot ref should exist
1227
+ const refs = run("git for-each-ref refs/gsd/snapshots/", repo);
1228
+ assertEq(refs, "", "no snapshot ref when snapshots pref is not set");
1229
+
1230
+ rmSync(repo, { recursive: true, force: true });
1231
+ }
1232
+
1233
+ // ─── PreMergeCheckResult type export compile check ─────────────────────
1234
+
1235
+ console.log("\n=== PreMergeCheckResult type export ===");
1236
+
1237
+ {
1238
+ const _checkResult: PreMergeCheckResult = { passed: true, skipped: false };
1239
+ assert(true, "PreMergeCheckResult type exported and usable");
1240
+ }
1241
+
884
1242
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
885
1243
  if (failed > 0) process.exit(1);
886
1244
  console.log("All tests passed ✓");
@@ -385,7 +385,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
385
385
  sliceTitle: 'Test Slice',
386
386
  slicePath: '.gsd/milestones/M001/slices/S01',
387
387
  planPath: '.gsd/milestones/M001/slices/S01/S01-PLAN.md',
388
- blockerTaskId: 'T02',
389
388
  inlinedContext: '## Inlined Context\n\nTest context here.',
390
389
  });
391
390
 
@@ -393,7 +392,6 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
393
392
  assert(prompt.includes('S01'), 'prompt contains sliceId');
394
393
  assert(prompt.includes('Test Slice'), 'prompt contains sliceTitle');
395
394
  assert(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath');
396
- assert(prompt.includes('T02'), 'prompt contains blockerTaskId');
397
395
  assert(prompt.includes('Test context here'), 'prompt contains inlined context');
398
396
  }
399
397
 
@@ -19,6 +19,7 @@ import {
19
19
  createWorktree,
20
20
  listWorktrees,
21
21
  removeWorktree,
22
+ mergeWorktreeToMain,
22
23
  diffWorktreeAll,
23
24
  diffWorktreeNumstat,
24
25
  getMainBranch,
@@ -28,7 +29,9 @@ import {
28
29
  worktreeBranchName,
29
30
  worktreePath,
30
31
  } from "./worktree-manager.js";
32
+ import { inferCommitType } from "./git-service.js";
31
33
  import type { FileLineStat } from "./worktree-manager.js";
34
+ import { execSync } from "node:child_process";
32
35
  import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
33
36
  import { join, resolve, sep } from "node:path";
34
37
 
@@ -349,13 +352,14 @@ async function handleCreate(
349
352
  ctx: ExtensionCommandContext,
350
353
  ): Promise<void> {
351
354
  try {
355
+ // Auto-commit dirty files before leaving current workspace (must happen
356
+ // before createWorktree so the new worktree forks from committed HEAD)
357
+ const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
358
+
352
359
  // Create from the main tree, not from inside another worktree
353
360
  const mainBase = originalCwd ?? basePath;
354
361
  const info = createWorktree(mainBase, name);
355
362
 
356
- // Auto-commit dirty files before leaving current workspace
357
- const commitMsg = autoCommitCurrentBranch(basePath, "worktree-switch", name);
358
-
359
363
  // Track original cwd before switching
360
364
  if (!originalCwd) originalCwd = basePath;
361
365
 
@@ -655,9 +659,8 @@ async function handleMerge(
655
659
  return;
656
660
  }
657
661
 
658
- // Switch to the main tree before dispatching the merge.
659
- // The LLM needs to run git merge --squash from the main branch, and if
660
- // it later removes the worktree, the agent's CWD must not be inside it.
662
+ // Switch to the main tree before merging.
663
+ // Must be on the main branch to run git merge --squash.
661
664
  if (originalCwd) {
662
665
  const prevCwd = process.cwd();
663
666
  process.chdir(basePath);
@@ -665,6 +668,45 @@ async function handleMerge(
665
668
  originalCwd = null;
666
669
  }
667
670
 
671
+ // --- Deterministic merge path (preferred) ---
672
+ // Try a direct squash-merge first. Only fall back to LLM on conflict.
673
+ const commitType = inferCommitType(name);
674
+ const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
675
+ try {
676
+ mergeWorktreeToMain(basePath, name, commitMessage);
677
+ ctx.ui.notify(
678
+ [
679
+ `${CLR.ok("✓")} Merged ${CLR.name(name)} → ${CLR.branch(mainBranch)} ${CLR.muted("(deterministic squash)")}`,
680
+ "",
681
+ ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${CLR.ok(`+${totalAdded}`)} ${RED}-${totalRemoved}${RESET} lines`,
682
+ ` ${CLR.muted("commit:")} ${commitMessage}`,
683
+ ].join("\n"),
684
+ "info",
685
+ );
686
+ return;
687
+ } catch (mergeErr) {
688
+ const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
689
+ const isConflict = /conflict/i.test(mergeMsg);
690
+
691
+ if (isConflict) {
692
+ // Abort the failed merge so the working tree is clean for LLM retry
693
+ try {
694
+ execSync("git merge --abort", { cwd: basePath, stdio: "pipe" });
695
+ } catch { /* already clean */ }
696
+
697
+ ctx.ui.notify(
698
+ `${CLR.muted("Deterministic merge hit conflicts — falling back to LLM-guided merge.")}`,
699
+ "warning",
700
+ );
701
+ // Fall through to LLM dispatch below
702
+ } else {
703
+ // Non-conflict error — surface it directly, don't fall back
704
+ ctx.ui.notify(`Failed to merge: ${mergeMsg}`, "error");
705
+ return;
706
+ }
707
+ }
708
+
709
+ // --- LLM fallback path (conflict resolution) ---
668
710
  // Format file lists for the prompt
669
711
  const formatFiles = (files: string[]) =>
670
712
  files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_";