gsd-pi 2.32.0 → 2.33.0-dev.69bff0f

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 (109) hide show
  1. package/README.md +22 -20
  2. package/dist/resource-loader.js +13 -3
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
  4. package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
  5. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  6. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  7. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  8. package/dist/resources/extensions/gsd/auto-prompts.ts +46 -44
  9. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  10. package/dist/resources/extensions/gsd/auto-start.ts +8 -6
  11. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  12. package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
  13. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  14. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  15. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  16. package/dist/resources/extensions/gsd/auto-worktree.ts +140 -5
  17. package/dist/resources/extensions/gsd/auto.ts +108 -182
  18. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  19. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  20. package/dist/resources/extensions/gsd/commands.ts +14 -2
  21. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  22. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  23. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  24. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  25. package/dist/resources/extensions/gsd/export.ts +2 -1
  26. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  27. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  28. package/dist/resources/extensions/gsd/index.ts +12 -5
  29. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  30. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  31. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  32. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  33. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  34. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  35. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  36. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  37. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  38. package/dist/resources/extensions/gsd/quick.ts +58 -3
  39. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  40. package/dist/resources/extensions/gsd/session-lock.ts +86 -11
  41. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  42. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  43. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  44. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  45. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  46. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  47. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  48. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  49. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  50. package/dist/resources/extensions/gsd/undo.ts +5 -7
  51. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  52. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  53. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  54. package/package.json +3 -2
  55. package/packages/pi-coding-agent/package.json +1 -1
  56. package/pkg/package.json +1 -1
  57. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  58. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  59. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  60. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  61. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  62. package/src/resources/extensions/gsd/auto-prompts.ts +46 -44
  63. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  64. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  65. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  66. package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
  67. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  68. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  69. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  70. package/src/resources/extensions/gsd/auto-worktree.ts +140 -5
  71. package/src/resources/extensions/gsd/auto.ts +108 -182
  72. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  73. package/src/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  74. package/src/resources/extensions/gsd/commands.ts +14 -2
  75. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  76. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  77. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  78. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  79. package/src/resources/extensions/gsd/export.ts +2 -1
  80. package/src/resources/extensions/gsd/git-service.ts +3 -2
  81. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  82. package/src/resources/extensions/gsd/index.ts +12 -5
  83. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  84. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  85. package/src/resources/extensions/gsd/metrics.ts +3 -3
  86. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  87. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  88. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  89. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  90. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  91. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  92. package/src/resources/extensions/gsd/quick.ts +58 -3
  93. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  94. package/src/resources/extensions/gsd/session-lock.ts +86 -11
  95. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  96. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  97. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  98. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  99. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  100. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  101. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  102. package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  103. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  104. package/src/resources/extensions/gsd/undo.ts +5 -7
  105. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  106. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  107. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
  108. package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
  109. package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
@@ -6,7 +6,7 @@
6
6
  * manages create, enter, detect, and teardown for auto-mode worktrees.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
9
+ import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, lstatSync as lstatSyncFn } from "node:fs";
10
10
  import { isAbsolute, join, sep } from "node:path";
11
11
  import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
12
12
  import { execSync, execFileSync } from "node:child_process";
@@ -38,12 +38,129 @@ import {
38
38
  nativeBranchDelete,
39
39
  nativeBranchExists,
40
40
  } from "./native-git-bridge.js";
41
+ import { getErrorMessage } from "./error-utils.js";
41
42
 
42
43
  // ─── Module State ──────────────────────────────────────────────────────────
43
44
 
44
45
  /** Original project root before chdir into auto-worktree. */
45
46
  let originalBase: string | null = null;
46
47
 
48
+ // ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
49
+
50
+ /**
51
+ * Sync .gsd/ state from the main repo into the worktree.
52
+ *
53
+ * When .gsd/ is a symlink to the external state directory, both the main
54
+ * repo and worktree share the same directory — no sync needed.
55
+ *
56
+ * When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
57
+ * the worktree has its own copy that may be stale. This function copies
58
+ * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
59
+ * PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
60
+ *
61
+ * Only adds missing content — never overwrites existing files in the worktree
62
+ * (the worktree's execution state is authoritative for in-progress work).
63
+ */
64
+ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
65
+ const mainGsd = gsdRoot(mainBasePath);
66
+ const wtGsd = gsdRoot(worktreePath_);
67
+ const synced: string[] = [];
68
+
69
+ // If both resolve to the same directory (symlink), no sync needed
70
+ try {
71
+ const mainResolved = realpathSync(mainGsd);
72
+ const wtResolved = realpathSync(wtGsd);
73
+ if (mainResolved === wtResolved) return { synced };
74
+ } catch {
75
+ // Can't resolve — proceed with sync as a safety measure
76
+ }
77
+
78
+ if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
79
+
80
+ // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
81
+ const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
82
+ for (const f of rootFiles) {
83
+ const src = join(mainGsd, f);
84
+ const dst = join(wtGsd, f);
85
+ if (existsSync(src) && !existsSync(dst)) {
86
+ try {
87
+ cpSync(src, dst);
88
+ synced.push(f);
89
+ } catch { /* non-fatal */ }
90
+ }
91
+ }
92
+
93
+ // Sync milestones: copy entire milestone directories that are missing
94
+ const mainMilestonesDir = join(mainGsd, "milestones");
95
+ const wtMilestonesDir = join(wtGsd, "milestones");
96
+ if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
97
+ try {
98
+ const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
99
+ .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
100
+ .map(d => d.name);
101
+
102
+ for (const mid of mainMilestones) {
103
+ const srcDir = join(mainMilestonesDir, mid);
104
+ const dstDir = join(wtMilestonesDir, mid);
105
+
106
+ if (!existsSync(dstDir)) {
107
+ // Entire milestone missing from worktree — copy it
108
+ try {
109
+ cpSync(srcDir, dstDir, { recursive: true });
110
+ synced.push(`milestones/${mid}/`);
111
+ } catch { /* non-fatal */ }
112
+ } else {
113
+ // Milestone directory exists but may be missing files (stale snapshot).
114
+ // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
115
+ try {
116
+ const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
117
+ for (const f of srcFiles) {
118
+ const srcFile = join(srcDir, f);
119
+ const dstFile = join(dstDir, f);
120
+ if (!existsSync(dstFile)) {
121
+ try {
122
+ const srcStat = lstatSyncFn(srcFile);
123
+ if (srcStat.isFile()) {
124
+ cpSync(srcFile, dstFile);
125
+ synced.push(`milestones/${mid}/${f}`);
126
+ }
127
+ } catch { /* non-fatal */ }
128
+ }
129
+ }
130
+
131
+ // Sync slices directory if it exists in main but not in worktree
132
+ const srcSlicesDir = join(srcDir, "slices");
133
+ const dstSlicesDir = join(dstDir, "slices");
134
+ if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
135
+ try {
136
+ cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
137
+ synced.push(`milestones/${mid}/slices/`);
138
+ } catch { /* non-fatal */ }
139
+ } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
140
+ // Both exist — sync missing slice directories
141
+ const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
142
+ .filter(d => d.isDirectory())
143
+ .map(d => d.name);
144
+ for (const sid of srcSlices) {
145
+ const srcSlice = join(srcSlicesDir, sid);
146
+ const dstSlice = join(dstSlicesDir, sid);
147
+ if (!existsSync(dstSlice)) {
148
+ try {
149
+ cpSync(srcSlice, dstSlice, { recursive: true });
150
+ synced.push(`milestones/${mid}/slices/${sid}/`);
151
+ } catch { /* non-fatal */ }
152
+ }
153
+ }
154
+ }
155
+ } catch { /* non-fatal */ }
156
+ }
157
+ }
158
+ } catch { /* non-fatal */ }
159
+ }
160
+
161
+ return { synced };
162
+ }
163
+
47
164
  // ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
48
165
 
49
166
  /**
@@ -81,7 +198,7 @@ export function runWorktreePostCreateHook(sourceDir: string, worktreeDir: string
81
198
  });
82
199
  return null;
83
200
  } catch (err) {
84
- const msg = err instanceof Error ? err.message : String(err);
201
+ const msg = getErrorMessage(err);
85
202
  return `Worktree post-create hook failed: ${msg}`;
86
203
  }
87
204
  }
@@ -124,6 +241,12 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
124
241
  // Ensure worktree shares external state via symlink
125
242
  ensureGsdSymlink(info.path);
126
243
 
244
+ // Sync .gsd/ state from main repo into the worktree (#1311).
245
+ // Even with the symlink, the worktree may have stale git-tracked files
246
+ // if .gsd/ is not gitignored. And on fresh create, the milestone files
247
+ // created on main since the branch point won't be in the worktree.
248
+ syncGsdStateToWorktree(basePath, info.path);
249
+
127
250
  // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
128
251
  const hookError = runWorktreePostCreateHook(basePath, info.path);
129
252
  if (hookError) {
@@ -141,7 +264,7 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
141
264
  // Don't store originalBase -- caller can retry or clean up.
142
265
  throw new GSDError(
143
266
  GSD_IO_ERROR,
144
- `Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
267
+ `Auto-worktree created at ${info.path} but chdir failed: ${getErrorMessage(err)}`,
145
268
  );
146
269
  }
147
270
 
@@ -168,7 +291,7 @@ export function teardownAutoWorktree(
168
291
  } catch (err) {
169
292
  throw new GSDError(
170
293
  GSD_IO_ERROR,
171
- `Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
294
+ `Failed to chdir back to ${originalBasePath} during teardown: ${getErrorMessage(err)}`,
172
295
  );
173
296
  }
174
297
 
@@ -266,6 +389,18 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
266
389
  throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
267
390
  }
268
391
 
392
+ // Ensure worktree shares external state via symlink (#1311).
393
+ // On resume (enterAutoWorktree), the symlink may be missing if it was
394
+ // created before ensureGsdSymlink existed, or the .gsd/ directory may be
395
+ // a stale git-tracked copy instead of a symlink. Refreshing here ensures
396
+ // the worktree sees the same milestone state as the main repo.
397
+ ensureGsdSymlink(p);
398
+
399
+ // Sync .gsd/ state from main repo into worktree (#1311).
400
+ // Covers the case where .gsd/ is a real directory (not symlinked) and
401
+ // milestones were created on main after the worktree was last used.
402
+ syncGsdStateToWorktree(basePath, p);
403
+
269
404
  const previousCwd = process.cwd();
270
405
 
271
406
  try {
@@ -274,7 +409,7 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
274
409
  } catch (err) {
275
410
  throw new GSDError(
276
411
  GSD_IO_ERROR,
277
- `Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
412
+ `Failed to enter auto-worktree at ${p}: ${getErrorMessage(err)}`,
278
413
  );
279
414
  }
280
415
 
@@ -105,6 +105,7 @@ import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.j
105
105
  import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js";
106
106
  import { join } from "node:path";
107
107
  import { sep as pathSep } from "node:path";
108
+ import { parseUnitId } from "./unit-id.js";
108
109
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
109
110
  import { atomicWriteSync } from "./atomic-write.js";
110
111
  import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
@@ -189,6 +190,7 @@ import {
189
190
  NEW_SESSION_TIMEOUT_MS, DISPATCH_HANG_TIMEOUT_MS,
190
191
  } from "./auto/session.js";
191
192
  import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerificationRetry } from "./auto/session.js";
193
+ import { getErrorMessage } from "./error-utils.js";
192
194
 
193
195
  // ── ENCAPSULATION INVARIANT ─────────────────────────────────────────────────
194
196
  // ALL mutable auto-mode state lives in the AutoSession class (auto/session.ts).
@@ -213,52 +215,7 @@ export function shouldUseWorktreeIsolation(): boolean {
213
215
  return true; // default: worktree
214
216
  }
215
217
 
216
- /** Crash recovery prompt set by startAuto, consumed by first dispatchNextUnit */
217
-
218
- /** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */
219
-
220
- /** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */
221
-
222
- /** Session file path captured at pause — used to synthesize recovery briefing on resume */
223
-
224
- /** Dashboard tracking */
225
-
226
- /** Track dynamic routing decision for the current unit (for metrics) */
227
-
228
- /** Queue of quick-task captures awaiting dispatch after triage resolution */
229
-
230
- /**
231
- * Model captured at auto-mode start. Used to prevent model bleed between
232
- * concurrent GSD instances sharing the same global settings.json (#650).
233
- * When preferences don't specify a model for a unit type, this ensures
234
- * the session's original model is re-applied instead of reading from
235
- * the shared global settings (which another instance may have overwritten).
236
- */
237
-
238
- /** Track current milestone to detect transitions */
239
-
240
- /** Model the user had selected before auto-mode started */
241
-
242
- /** Progress-aware timeout supervision */
243
-
244
- /** Context-pressure continue-here monitor — fires once when context usage >= 70% */
245
-
246
- /** Dispatch gap watchdog — detects when the state machine stalls between units.
247
- * After handleAgentEnd completes, if auto-mode is still active but no new unit
248
- * has been dispatched (sendMessage not called), this timer fires to force a
249
- * re-evaluation. Covers the case where dispatchNextUnit silently fails or
250
- * an unhandled error kills the dispatch chain. */
251
-
252
- /** Prompt character measurement for token savings analysis (R051). */
253
-
254
- /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
255
-
256
- /**
257
- * Tool calls currently being executed — prevents false idle detection during long-running tools.
258
- * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
259
- * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
260
- */
261
-
218
+ // All mutable state lives in AutoSession (auto/session.ts) see encapsulation invariant above.
262
219
  /** Wrapper: register SIGTERM handler and store reference. */
263
220
  function registerSigtermHandler(currentBasePath: string): void {
264
221
  s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
@@ -404,6 +361,79 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire
404
361
  };
405
362
  }
406
363
 
364
+ // ─── Extracted Merge Helper ───────────────────────────────────────────────
365
+
366
+ /**
367
+ * Attempt to merge the current milestone branch to main.
368
+ * Handles both worktree and branch isolation modes with a single code path.
369
+ * Returns true if merge succeeded, false on error (non-fatal, logged).
370
+ *
371
+ * Extracted from 4 duplicate merge blocks in dispatchNextUnit to eliminate
372
+ * the bug factory where fixing one copy didn't fix the others (#1308).
373
+ */
374
+ function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "transition" | "complete"): boolean {
375
+ const isolationMode = getIsolationMode();
376
+
377
+ // Worktree merge path
378
+ if (isInAutoWorktree(s.basePath) && s.originalBasePath) {
379
+ try {
380
+ const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP");
381
+ if (!roadmapPath) {
382
+ teardownAutoWorktree(s.originalBasePath, milestoneId);
383
+ ctx.ui.notify(`Exited worktree for ${milestoneId} (no roadmap for merge).`, "info");
384
+ return false;
385
+ }
386
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
387
+ const mergeResult = mergeMilestoneToMain(s.originalBasePath, milestoneId, roadmapContent);
388
+ s.basePath = s.originalBasePath;
389
+ s.gitService = createGitService(s.basePath);
390
+ ctx.ui.notify(
391
+ `Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
392
+ "info",
393
+ );
394
+ return true;
395
+ } catch (err) {
396
+ ctx.ui.notify(
397
+ `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
398
+ "warning",
399
+ );
400
+ if (s.originalBasePath) {
401
+ s.basePath = s.originalBasePath;
402
+ try { process.chdir(s.basePath); } catch { /* best-effort */ }
403
+ }
404
+ return false;
405
+ }
406
+ }
407
+
408
+ // Branch-mode merge path
409
+ if (isolationMode === "branch") {
410
+ try {
411
+ const currentBranch = getCurrentBranch(s.basePath);
412
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
413
+ if (currentBranch === milestoneBranch) {
414
+ const roadmapPath = resolveMilestoneFile(s.basePath, milestoneId, "ROADMAP");
415
+ if (roadmapPath) {
416
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
417
+ const mergeResult = mergeMilestoneToMain(s.basePath, milestoneId, roadmapContent);
418
+ s.gitService = createGitService(s.basePath);
419
+ ctx.ui.notify(
420
+ `Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
421
+ "info",
422
+ );
423
+ return true;
424
+ }
425
+ }
426
+ } catch (err) {
427
+ ctx.ui.notify(
428
+ `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
429
+ "warning",
430
+ );
431
+ }
432
+ }
433
+
434
+ return false;
435
+ }
436
+
407
437
  /**
408
438
  * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
409
439
  * after handleAgentEnd completes. This catches the case where the dispatch chain silently
@@ -428,7 +458,7 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
428
458
  try {
429
459
  await dispatchNextUnit(ctx, pi);
430
460
  } catch (retryErr) {
431
- const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
461
+ const message = getErrorMessage(retryErr);
432
462
  await stopAuto(ctx, pi, `Dispatch gap recovery failed: ${message}`);
433
463
  return;
434
464
  }
@@ -458,14 +488,14 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
458
488
  // ── Auto-worktree: exit worktree and reset s.basePath on stop ──
459
489
  if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) {
460
490
  try {
461
- try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: e instanceof Error ? e.message : String(e) }); }
491
+ try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: getErrorMessage(e) }); }
462
492
  teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true });
463
493
  s.basePath = s.originalBasePath;
464
494
  s.gitService = createGitService(s.basePath);
465
495
  ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info");
466
496
  } catch (err) {
467
497
  ctx?.ui.notify(
468
- `Auto-worktree teardown failed: ${err instanceof Error ? err.message : String(err)}`,
498
+ `Auto-worktree teardown failed: ${getErrorMessage(err)}`,
469
499
  "warning",
470
500
  );
471
501
  }
@@ -476,7 +506,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
476
506
  try {
477
507
  const { closeDatabase } = await import("./gsd-db.js");
478
508
  closeDatabase();
479
- } catch (e) { debugLog("db-close-failed", { error: e instanceof Error ? e.message : String(e) }); }
509
+ } catch (e) { debugLog("db-close-failed", { error: getErrorMessage(e) }); }
480
510
  }
481
511
 
482
512
  if (s.originalBasePath) {
@@ -496,7 +526,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
496
526
  }
497
527
 
498
528
  if (s.basePath) {
499
- try { await rebuildState(s.basePath); } catch (e) { debugLog("stop-rebuild-state-failed", { error: e instanceof Error ? e.message : String(e) }); }
529
+ try { await rebuildState(s.basePath); } catch (e) { debugLog("stop-rebuild-state-failed", { error: getErrorMessage(e) }); }
500
530
  }
501
531
 
502
532
  if (isDebugEnabled()) {
@@ -635,7 +665,7 @@ export async function startAuto(
635
665
  }
636
666
  } catch (err) {
637
667
  ctx.ui.notify(
638
- `Auto-worktree re-entry failed: ${err instanceof Error ? err.message : String(err)}. Continuing at current path.`,
668
+ `Auto-worktree re-entry failed: ${getErrorMessage(err)}. Continuing at current path.`,
639
669
  "warning",
640
670
  );
641
671
  }
@@ -647,13 +677,13 @@ export async function startAuto(
647
677
  ctx.ui.setFooter(hideFooter);
648
678
  ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
649
679
  restoreHookState(s.basePath);
650
- try { await rebuildState(s.basePath); } catch (e) { debugLog("resume-rebuild-state-failed", { error: e instanceof Error ? e.message : String(e) }); }
680
+ try { await rebuildState(s.basePath); } catch (e) { debugLog("resume-rebuild-state-failed", { error: getErrorMessage(e) }); }
651
681
  try {
652
682
  const report = await runGSDDoctor(s.basePath, { fix: true });
653
683
  if (report.fixesApplied.length > 0) {
654
684
  ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
655
685
  }
656
- } catch (e) { debugLog("resume-doctor-failed", { error: e instanceof Error ? e.message : String(e) }); }
686
+ } catch (e) { debugLog("resume-doctor-failed", { error: getErrorMessage(e) }); }
657
687
  await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
658
688
  invalidateAllCaches();
659
689
 
@@ -700,7 +730,7 @@ export async function startAuto(
700
730
  }
701
731
  } catch (err) {
702
732
  ctx.ui.notify(
703
- `Secrets check error: ${err instanceof Error ? err.message : String(err)}. Continuing without secrets.`,
733
+ `Secrets check error: ${getErrorMessage(err)}. Continuing without secrets.`,
704
734
  "warning",
705
735
  );
706
736
  }
@@ -807,7 +837,7 @@ export async function handleAgentEnd(
807
837
  try {
808
838
  await dispatchNextUnit(ctx, pi);
809
839
  } catch (dispatchErr) {
810
- const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
840
+ const message = getErrorMessage(dispatchErr);
811
841
  ctx.ui.notify(
812
842
  `Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`,
813
843
  "error",
@@ -838,7 +868,7 @@ export async function handleAgentEnd(
838
868
  clearDispatchGapWatchdog();
839
869
  setImmediate(() => {
840
870
  handleAgentEnd(ctx, pi).catch((err) => {
841
- const msg = err instanceof Error ? err.message : String(err);
871
+ const msg = getErrorMessage(err);
842
872
  ctx.ui.notify(`Deferred agent_end retry failed: ${msg}`, "error");
843
873
  pauseAuto(ctx, pi).catch(() => {});
844
874
  });
@@ -1086,7 +1116,7 @@ async function dispatchNextUnit(
1086
1116
  );
1087
1117
  } catch (err) {
1088
1118
  ctx.ui.notify(
1089
- `Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
1119
+ `Report generation failed: ${getErrorMessage(err)}`,
1090
1120
  "warning",
1091
1121
  );
1092
1122
  }
@@ -1102,35 +1132,17 @@ async function dispatchNextUnit(
1102
1132
  atomicWriteSync(file, JSON.stringify([]));
1103
1133
  }
1104
1134
  s.completedKeySet.clear();
1105
- } catch (e) { debugLog("completed-keys-reset-failed", { error: e instanceof Error ? e.message : String(e) }); }
1135
+ } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1106
1136
 
1107
1137
  // ── Worktree lifecycle on milestone transition (#616) ──
1108
- if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) {
1109
- try {
1110
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1111
- if (roadmapPath) {
1112
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1113
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1114
- ctx.ui.notify(
1115
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1116
- "info",
1117
- );
1118
- } else {
1119
- teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
1120
- ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
1121
- }
1122
- } catch (err) {
1123
- ctx.ui.notify(
1124
- `Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`,
1125
- "warning",
1126
- );
1127
- if (s.originalBasePath) {
1128
- try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
1129
- }
1130
- }
1138
+ if ((isInAutoWorktree(s.basePath) || getIsolationMode() === "branch") && shouldUseWorktreeIsolation()) {
1139
+ tryMergeMilestone(ctx, s.currentMilestoneId, "transition");
1131
1140
 
1132
- s.basePath = s.originalBasePath;
1133
- s.gitService = createGitService(s.basePath);
1141
+ // Reset to project root and re-derive state for the new milestone
1142
+ if (s.originalBasePath) {
1143
+ s.basePath = s.originalBasePath;
1144
+ s.gitService = createGitService(s.basePath);
1145
+ }
1134
1146
  invalidateAllCaches();
1135
1147
 
1136
1148
  state = await deriveState(s.basePath);
@@ -1146,7 +1158,7 @@ async function dispatchNextUnit(
1146
1158
  ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
1147
1159
  } catch (err) {
1148
1160
  ctx.ui.notify(
1149
- `Auto-worktree creation for ${mid} failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
1161
+ `Auto-worktree creation for ${mid} failed: ${getErrorMessage(err)}. Continuing in project root.`,
1150
1162
  "warning",
1151
1163
  );
1152
1164
  }
@@ -1175,51 +1187,8 @@ async function dispatchNextUnit(
1175
1187
  const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
1176
1188
  if (incomplete.length === 0) {
1177
1189
  // Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
1178
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1179
- try {
1180
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1181
- if (roadmapPath) {
1182
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1183
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1184
- s.basePath = s.originalBasePath;
1185
- s.gitService = createGitService(s.basePath);
1186
- ctx.ui.notify(
1187
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1188
- "info",
1189
- );
1190
- }
1191
- } catch (err) {
1192
- ctx.ui.notify(
1193
- `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
1194
- "warning",
1195
- );
1196
- if (s.originalBasePath) {
1197
- s.basePath = s.originalBasePath;
1198
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1199
- }
1200
- }
1201
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1202
- try {
1203
- const currentBranch = getCurrentBranch(s.basePath);
1204
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1205
- if (currentBranch === milestoneBranch) {
1206
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1207
- if (roadmapPath) {
1208
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1209
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1210
- s.gitService = createGitService(s.basePath);
1211
- ctx.ui.notify(
1212
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1213
- "info",
1214
- );
1215
- }
1216
- }
1217
- } catch (err) {
1218
- ctx.ui.notify(
1219
- `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
1220
- "warning",
1221
- );
1222
- }
1190
+ if (s.currentMilestoneId) {
1191
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1223
1192
  }
1224
1193
  sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
1225
1194
  await stopAuto(ctx, pi, "All milestones complete");
@@ -1276,52 +1245,10 @@ async function dispatchNextUnit(
1276
1245
  atomicWriteSync(file, JSON.stringify([]));
1277
1246
  }
1278
1247
  s.completedKeySet.clear();
1279
- } catch (e) { debugLog("completed-keys-reset-failed", { error: e instanceof Error ? e.message : String(e) }); }
1248
+ } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1280
1249
  // ── Milestone merge ──
1281
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1282
- try {
1283
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1284
- if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
1285
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1286
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1287
- s.basePath = s.originalBasePath;
1288
- s.gitService = createGitService(s.basePath);
1289
- ctx.ui.notify(
1290
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1291
- "info",
1292
- );
1293
- } catch (err) {
1294
- ctx.ui.notify(
1295
- `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
1296
- "warning",
1297
- );
1298
- if (s.originalBasePath) {
1299
- s.basePath = s.originalBasePath;
1300
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1301
- }
1302
- }
1303
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1304
- try {
1305
- const currentBranch = getCurrentBranch(s.basePath);
1306
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1307
- if (currentBranch === milestoneBranch) {
1308
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1309
- if (roadmapPath) {
1310
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1311
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1312
- s.gitService = createGitService(s.basePath);
1313
- ctx.ui.notify(
1314
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1315
- "info",
1316
- );
1317
- }
1318
- }
1319
- } catch (err) {
1320
- ctx.ui.notify(
1321
- `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
1322
- "warning",
1323
- );
1324
- }
1250
+ if (s.currentMilestoneId) {
1251
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1325
1252
  }
1326
1253
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
1327
1254
  await stopAuto(ctx, pi, `Milestone ${mid} complete`);
@@ -1417,7 +1344,7 @@ async function dispatchNextUnit(
1417
1344
  }
1418
1345
  } catch (err) {
1419
1346
  ctx.ui.notify(
1420
- `Secrets collection error: ${err instanceof Error ? err.message : String(err)}. Continuing with next task.`,
1347
+ `Secrets collection error: ${getErrorMessage(err)}. Continuing with next task.`,
1421
1348
  "warning",
1422
1349
  );
1423
1350
  }
@@ -1628,7 +1555,7 @@ async function dispatchNextUnit(
1628
1555
  );
1629
1556
  result = await Promise.race([sessionPromise, timeoutPromise]);
1630
1557
  } catch (sessionErr) {
1631
- const msg = sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
1558
+ const msg = getErrorMessage(sessionErr);
1632
1559
  ctx.ui.notify(`Session creation failed: ${msg}. Retrying via watchdog.`, "error");
1633
1560
  throw new Error(`newSession() failed: ${msg}`);
1634
1561
  }
@@ -1704,7 +1631,7 @@ async function dispatchNextUnit(
1704
1631
  const { reorderForCaching } = await import("./prompt-ordering.js");
1705
1632
  finalPrompt = reorderForCaching(finalPrompt);
1706
1633
  } catch (reorderErr) {
1707
- const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
1634
+ const msg = getErrorMessage(reorderErr);
1708
1635
  process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
1709
1636
  }
1710
1637
 
@@ -1747,8 +1674,7 @@ async function dispatchNextUnit(
1747
1674
  function ensurePreconditions(
1748
1675
  unitType: string, unitId: string, base: string, state: GSDState,
1749
1676
  ): void {
1750
- const parts = unitId.split("/");
1751
- const mid = parts[0]!;
1677
+ const { milestone: mid } = parseUnitId(unitId);
1752
1678
 
1753
1679
  const mDir = resolveMilestonePath(base, mid);
1754
1680
  if (!mDir) {
@@ -1756,8 +1682,8 @@ function ensurePreconditions(
1756
1682
  mkdirSync(join(newDir, "slices"), { recursive: true });
1757
1683
  }
1758
1684
 
1759
- if (parts.length >= 2) {
1760
- const sid = parts[1]!;
1685
+ const sid = parseUnitId(unitId).slice;
1686
+ if (sid) {
1761
1687
 
1762
1688
  const mDirResolved = resolveMilestonePath(base, mid);
1763
1689
  if (mDirResolved) {