gsd-pi 2.32.0 → 2.33.0-dev.bafba33

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 (91) hide show
  1. package/README.md +27 -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-timeout-recovery.ts +2 -1
  13. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  14. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  15. package/dist/resources/extensions/gsd/auto-worktree.ts +5 -4
  16. package/dist/resources/extensions/gsd/auto.ts +108 -182
  17. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  18. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  19. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  20. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  21. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  22. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  23. package/dist/resources/extensions/gsd/export.ts +2 -1
  24. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  25. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  26. package/dist/resources/extensions/gsd/index.ts +12 -5
  27. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  28. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  29. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  30. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  31. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  33. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  35. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  36. package/dist/resources/extensions/gsd/quick.ts +58 -3
  37. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  38. package/dist/resources/extensions/gsd/session-lock.ts +12 -1
  39. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  40. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  41. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  42. package/dist/resources/extensions/gsd/undo.ts +5 -7
  43. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  44. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  45. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  46. package/package.json +3 -2
  47. package/packages/pi-coding-agent/package.json +1 -1
  48. package/pkg/package.json +1 -1
  49. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  50. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  51. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  52. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  53. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  54. package/src/resources/extensions/gsd/auto-prompts.ts +46 -44
  55. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  56. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  57. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  58. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  59. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  60. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  61. package/src/resources/extensions/gsd/auto-worktree.ts +5 -4
  62. package/src/resources/extensions/gsd/auto.ts +108 -182
  63. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  64. package/src/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  65. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  66. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  67. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  68. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  69. package/src/resources/extensions/gsd/export.ts +2 -1
  70. package/src/resources/extensions/gsd/git-service.ts +3 -2
  71. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  72. package/src/resources/extensions/gsd/index.ts +12 -5
  73. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  74. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  75. package/src/resources/extensions/gsd/metrics.ts +3 -3
  76. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  77. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  78. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  79. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  80. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  81. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  82. package/src/resources/extensions/gsd/quick.ts +58 -3
  83. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  84. package/src/resources/extensions/gsd/session-lock.ts +12 -1
  85. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  86. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  87. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  88. package/src/resources/extensions/gsd/undo.ts +5 -7
  89. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  90. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  91. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
@@ -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) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
8
+ import { getErrorMessage } from "./error-utils.js";
8
9
 
9
10
  export interface InspectData {
10
11
  schemaVersion: number | null;
@@ -84,7 +85,7 @@ export async function handleInspect(ctx: ExtensionCommandContext): Promise<void>
84
85
 
85
86
  ctx.ui.notify(formatInspectOutput(data), "info");
86
87
  } catch (err) {
87
- process.stderr.write(`gsd-db: /gsd inspect failed: ${err instanceof Error ? err.message : String(err)}\n`);
88
+ process.stderr.write(`gsd-db: /gsd inspect failed: ${getErrorMessage(err)}\n`);
88
89
  ctx.ui.notify("Failed to inspect GSD database. Check stderr for details.", "error");
89
90
  }
90
91
  }
@@ -21,6 +21,7 @@ import { loadPrompt } from "./prompt-loader.js";
21
21
  import { gsdRoot } from "./paths.js";
22
22
  import { createGitService, runGit } from "./git-service.js";
23
23
  import { isAutoActive, isAutoPaused } from "./auto.js";
24
+ import { getErrorMessage } from "./error-utils.js";
24
25
 
25
26
  // ─── Helpers ─────────────────────────────────────────────────────────────────
26
27
 
@@ -439,7 +440,7 @@ export async function handleStart(
439
440
  branchCreated = true;
440
441
  }
441
442
  } catch (err) {
442
- const message = err instanceof Error ? err.message : String(err);
443
+ const message = getErrorMessage(err);
443
444
  ctx.ui.notify(
444
445
  `Could not create branch ${branchName}: ${message}. Working on current branch.`,
445
446
  "warning",
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { gsdRoot } from "./paths.js";
8
8
  import { getAdaptiveTierAdjustment } from "./routing-history.js";
9
+ import { parseUnitId } from "./unit-id.js";
9
10
 
10
11
  // ─── Types ───────────────────────────────────────────────────────────────────
11
12
 
@@ -180,15 +181,14 @@ function analyzePlanComplexity(
180
181
  basePath: string,
181
182
  ): TaskAnalysis | null {
182
183
  // Check if this is a milestone-level plan (more complex) vs single slice
183
- const parts = unitId.split("/");
184
- if (parts.length === 1) {
184
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
185
+ if (!sid) {
185
186
  // Milestone-level planning is always at least standard
186
187
  return { tier: "standard", reason: "milestone-level planning" };
187
188
  }
188
189
 
189
190
  // For slice planning, try to read the context/research to gauge complexity
190
191
  // If research exists and is large, bump to heavy
191
- const [mid, sid] = parts;
192
192
  const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md");
193
193
  try {
194
194
  if (existsSync(researchPath)) {
@@ -210,10 +210,8 @@ function analyzePlanComplexity(
210
210
  */
211
211
  function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
212
212
  const meta: TaskMetadata = {};
213
- const parts = unitId.split("/");
214
- if (parts.length !== 3) return meta;
215
-
216
- const [mid, sid, tid] = parts;
213
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
214
+ if (!mid || !sid || !tid) return meta;
217
215
  const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
218
216
 
219
217
  try {
@@ -98,11 +98,24 @@ export function isLockProcessAlive(lock: LockData): boolean {
98
98
 
99
99
  /** Format crash info for display or injection into a prompt. */
100
100
  export function formatCrashInfo(lock: LockData): string {
101
- return [
101
+ const lines = [
102
102
  `Previous auto-mode session was interrupted.`,
103
103
  ` Was executing: ${lock.unitType} (${lock.unitId})`,
104
104
  ` Started at: ${lock.unitStartedAt}`,
105
105
  ` Units completed before crash: ${lock.completedUnits}`,
106
106
  ` PID: ${lock.pid}`,
107
- ].join("\n");
107
+ ];
108
+
109
+ // Add recovery guidance based on what was happening when it crashed
110
+ if (lock.unitType === "starting" && lock.unitId === "bootstrap" && lock.completedUnits === 0) {
111
+ lines.push(`No work was lost. Run /gsd auto to restart.`);
112
+ } else if (lock.unitType.includes("research") || lock.unitType.includes("plan")) {
113
+ lines.push(`The ${lock.unitType} unit may be incomplete. Run /gsd auto to re-run it.`);
114
+ } else if (lock.unitType.includes("execute")) {
115
+ lines.push(`Task execution was interrupted. Run /gsd auto to resume — completed work is preserved.`);
116
+ } else if (lock.unitType.includes("complete")) {
117
+ lines.push(`Slice/milestone completion was interrupted. Run /gsd auto to finish.`);
118
+ }
119
+
120
+ return lines.join("\n");
108
121
  }
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { resolveMilestoneFile, milestonesDir } from "./paths.js";
6
6
  import { parseRoadmapSlices } from "./roadmap-slices.js";
7
7
  import { findMilestoneIds } from "./guided-flow.js";
8
+ import { parseUnitId } from "./unit-id.js";
8
9
 
9
10
  const SLICE_DISPATCH_TYPES = new Set([
10
11
  "research-slice",
@@ -39,7 +40,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
39
40
  export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
40
41
  if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
41
42
 
42
- const [targetMid, targetSid] = unitId.split("/");
43
+ const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
43
44
  if (!targetMid || !targetSid) return null;
44
45
 
45
46
  // Use findMilestoneIds to respect custom queue order.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Extract a human-readable message from an unknown caught value.
3
+ */
4
+ export function getErrorMessage(err: unknown): string {
5
+ return err instanceof Error ? err.message : String(err);
6
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import type { UnitMetrics } from "./metrics.js";
13
13
  import { gsdRoot } from "./paths.js";
14
14
  import { formatDuration, fileLink } from "../shared/mod.js";
15
+ import { getErrorMessage } from "./error-utils.js";
15
16
 
16
17
  /**
17
18
  * Open a file in the user's default browser.
@@ -226,7 +227,7 @@ export async function handleExport(args: string, ctx: ExtensionCommandContext, b
226
227
  }
227
228
  } catch (err) {
228
229
  ctx.ui.notify(
229
- `HTML export failed: ${err instanceof Error ? err.message : String(err)}`,
230
+ `HTML export failed: ${getErrorMessage(err)}`,
230
231
  "error",
231
232
  );
232
233
  }
@@ -33,6 +33,7 @@ import {
33
33
  nativeAddPaths,
34
34
  } from "./native-git-bridge.js";
35
35
  import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
36
+ import { getErrorMessage } from "./error-utils.js";
36
37
 
37
38
  // ─── Types ─────────────────────────────────────────────────────────────────
38
39
 
@@ -281,7 +282,7 @@ export function runGit(basePath: string, args: string[], options: { allowFailure
281
282
  }).trim();
282
283
  } catch (error) {
283
284
  if (options.allowFailure) return "";
284
- const message = error instanceof Error ? error.message : String(error);
285
+ const message = getErrorMessage(error);
285
286
  throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`);
286
287
  }
287
288
  }
@@ -533,7 +534,7 @@ export class GitServiceImpl {
533
534
  execSync(command, { cwd: this.basePath, stdio: "pipe", encoding: "utf-8" });
534
535
  return { passed: true, skipped: false, command };
535
536
  } catch (err) {
536
- const msg = err instanceof Error ? err.message : String(err);
537
+ const msg = getErrorMessage(err);
537
538
  return { passed: false, skipped: false, command, error: msg };
538
539
  }
539
540
  }
@@ -44,6 +44,7 @@ export {
44
44
  showQueue, handleQueueReorder, showQueueAdd,
45
45
  buildExistingMilestonesContext,
46
46
  } from "./guided-flow-queue.js";
47
+ import { getErrorMessage } from "./error-utils.js";
47
48
 
48
49
  // ─── Commit Instruction Helpers ──────────────────────────────────────────────
49
50
 
@@ -158,9 +159,9 @@ export function checkAutoStartAfterDiscuss(): boolean {
158
159
 
159
160
  pendingAutoStart = null;
160
161
  startAuto(ctx, pi, basePath, false, { step }).catch((err) => {
161
- ctx.ui.notify(`Auto-start failed: ${err instanceof Error ? err.message : String(err)}`, "error");
162
+ ctx.ui.notify(`Auto-start failed: ${getErrorMessage(err)}`, "error");
162
163
  if (process.env.GSD_DEBUG) console.error('[gsd] auto start error:', err);
163
- debugLog("auto-start-failed", { error: err instanceof Error ? err.message : String(err) });
164
+ debugLog("auto-start-failed", { error: getErrorMessage(err) });
164
165
  });
165
166
  return true;
166
167
  }