gsd-pi 2.65.0-dev.d0517ff → 2.66.0-dev.1b4e601

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 (77) hide show
  1. package/dist/resources/extensions/gsd/auto/finalize-timeout.js +2 -0
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -2
  3. package/dist/resources/extensions/gsd/auto/phases.js +48 -5
  4. package/dist/resources/extensions/gsd/auto/types.js +2 -0
  5. package/dist/resources/extensions/gsd/auto-dashboard.js +2 -1
  6. package/dist/resources/extensions/gsd/auto-start.js +134 -2
  7. package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
  8. package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -2
  9. package/dist/resources/extensions/gsd/files.js +17 -0
  10. package/dist/resources/extensions/gsd/notification-overlay.js +1 -1
  11. package/dist/resources/extensions/gsd/notification-widget.js +2 -1
  12. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +1 -1
  13. package/dist/resources/extensions/gsd/pre-execution-checks.js +16 -2
  14. package/dist/resources/extensions/gsd/prompts/system.md +2 -2
  15. package/dist/resources/extensions/subagent/agents.js +19 -5
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +18 -18
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +18 -18
  44. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  45. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  46. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/packages/pi-coding-agent/package.json +1 -1
  49. package/packages/pi-tui/dist/tui.d.ts +1 -0
  50. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  51. package/packages/pi-tui/dist/tui.js +8 -2
  52. package/packages/pi-tui/dist/tui.js.map +1 -1
  53. package/packages/pi-tui/src/tui.ts +8 -2
  54. package/pkg/package.json +1 -1
  55. package/src/resources/extensions/gsd/auto/finalize-timeout.ts +3 -0
  56. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  57. package/src/resources/extensions/gsd/auto/phases.ts +68 -3
  58. package/src/resources/extensions/gsd/auto/types.ts +5 -0
  59. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -1
  60. package/src/resources/extensions/gsd/auto-start.ts +143 -0
  61. package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
  62. package/src/resources/extensions/gsd/commands/handlers/core.ts +3 -2
  63. package/src/resources/extensions/gsd/files.ts +19 -0
  64. package/src/resources/extensions/gsd/notification-overlay.ts +1 -1
  65. package/src/resources/extensions/gsd/notification-widget.ts +2 -1
  66. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +1 -1
  67. package/src/resources/extensions/gsd/pre-execution-checks.ts +19 -2
  68. package/src/resources/extensions/gsd/prompts/system.md +2 -2
  69. package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +125 -0
  70. package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +69 -0
  71. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +11 -10
  72. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +189 -0
  73. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
  74. package/src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts +47 -0
  75. package/src/resources/extensions/subagent/agents.ts +30 -6
  76. /package/dist/web/standalone/.next/static/{JwdBI3y1H8vtBKiYvWfEK → fcV2z87tmOazTEreFWNdG}/_buildManifest.js +0 -0
  77. /package/dist/web/standalone/.next/static/{JwdBI3y1H8vtBKiYvWfEK → fcV2z87tmOazTEreFWNdG}/_ssgManifest.js +0 -0
@@ -239,6 +239,7 @@ export class TUI extends Container {
239
239
  public onDebug?: () => void;
240
240
  private renderRequested = false;
241
241
  private cursorRow = 0; // Logical cursor row (end of rendered content)
242
+ private contentCursorRow = 0; // Cursor row after content rendering, before IME repositioning
242
243
  private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
243
244
  private inputBuffer = ""; // Buffer for parsing terminal responses
244
245
  private cellSizeQueryPending = false;
@@ -498,6 +499,7 @@ export class TUI extends Container {
498
499
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
499
500
  this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
500
501
  this.cursorRow = 0;
502
+ this.contentCursorRow = 0;
501
503
  this.hardwareCursorRow = 0;
502
504
  this.maxLinesRendered = 0;
503
505
  this.previousViewportTop = 0;
@@ -616,7 +618,7 @@ export class TUI extends Container {
616
618
  const height = this.terminal.rows;
617
619
  let viewportTop = Math.max(0, this.maxLinesRendered - height);
618
620
  let prevViewportTop = this.previousViewportTop;
619
- let hardwareCursorRow = this.hardwareCursorRow;
621
+ let hardwareCursorRow = this.contentCursorRow;
620
622
  const computeLineDiff = (targetRow: number): number => {
621
623
  const currentScreenRow = hardwareCursorRow - prevViewportTop;
622
624
  const targetScreenRow = targetRow - viewportTop;
@@ -663,6 +665,7 @@ export class TUI extends Container {
663
665
  buffer += "\x1b[?2026l"; // End synchronized output
664
666
  this.terminal.write(buffer);
665
667
  this.cursorRow = Math.max(0, newLines.length - 1);
668
+ this.contentCursorRow = this.cursorRow;
666
669
  this.hardwareCursorRow = this.cursorRow;
667
670
  // Reset max lines when clearing, otherwise track growth
668
671
  if (clear) {
@@ -770,6 +773,7 @@ export class TUI extends Container {
770
773
  buffer += "\x1b[?2026l";
771
774
  this.terminal.write(buffer);
772
775
  this.cursorRow = targetRow;
776
+ this.contentCursorRow = targetRow;
773
777
  this.hardwareCursorRow = targetRow;
774
778
  }
775
779
  this.positionHardwareCursor(cursorPos, newLines.length);
@@ -887,8 +891,10 @@ export class TUI extends Container {
887
891
 
888
892
  // Track cursor position for next render
889
893
  // cursorRow tracks end of content (for viewport calculation)
890
- // hardwareCursorRow tracks actual terminal cursor position (for movement)
894
+ // contentCursorRow tracks cursor after content rendering (before IME repositioning)
895
+ // hardwareCursorRow tracks actual terminal cursor position (may differ due to IME)
891
896
  this.cursorRow = Math.max(0, newLines.length - 1);
897
+ this.contentCursorRow = finalCursorRow;
892
898
  this.hardwareCursorRow = finalCursorRow;
893
899
  // Track terminal's working area (grows but doesn't shrink unless cleared)
894
900
  this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "2.65.0",
3
+ "version": "2.66.0",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -7,6 +7,9 @@
7
7
  * Leaf module — no imports from auto/ to avoid circular dependencies.
8
8
  */
9
9
 
10
+ /** Timeout for postUnitPreVerification in runFinalize (ms). */
11
+ export const FINALIZE_PRE_TIMEOUT_MS = 60_000;
12
+
10
13
  /** Timeout for postUnitPostVerification in runFinalize (ms). */
11
14
  export const FINALIZE_POST_TIMEOUT_MS = 60_000;
12
15
 
@@ -46,7 +46,7 @@ export async function autoLoop(
46
46
  ): Promise<void> {
47
47
  debugLog("autoLoop", { phase: "enter" });
48
48
  let iteration = 0;
49
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
49
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
50
50
  let consecutiveErrors = 0;
51
51
  const recentErrorMessages: string[] = [];
52
52
 
@@ -247,7 +247,7 @@ export async function autoLoop(
247
247
 
248
248
  // ── Phase 5: Finalize ───────────────────────────────────────────────
249
249
 
250
- const finalizeResult = await runFinalize(ic, iterData, sidecarItem);
250
+ const finalizeResult = await runFinalize(ic, iterData, loopState, sidecarItem);
251
251
  if (finalizeResult.action === "break") break;
252
252
  if (finalizeResult.action === "continue") continue;
253
253
 
@@ -15,6 +15,7 @@ import type { PostUnitContext, PreVerificationOpts } from "../auto-post-unit.js"
15
15
  import {
16
16
  MAX_RECOVERY_CHARS,
17
17
  BUDGET_THRESHOLDS,
18
+ MAX_FINALIZE_TIMEOUTS,
18
19
  type PhaseResult,
19
20
  type IterationContext,
20
21
  type LoopState,
@@ -33,7 +34,7 @@ import { gsdRoot } from "../paths.js";
33
34
  import { atomicWriteSync } from "../atomic-write.js";
34
35
  import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.js";
35
36
  import { writeUnitRuntimeRecord } from "../unit-runtime.js";
36
- import { withTimeout, FINALIZE_POST_TIMEOUT_MS } from "./finalize-timeout.js";
37
+ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "./finalize-timeout.js";
37
38
  import { getEligibleSlices } from "../slice-parallel-eligibility.js";
38
39
  import { startSliceParallel } from "../slice-parallel-orchestrator.js";
39
40
  import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
@@ -1427,6 +1428,7 @@ export async function runUnitPhase(
1427
1428
  export async function runFinalize(
1428
1429
  ic: IterationContext,
1429
1430
  iterData: IterationData,
1431
+ loopState: LoopState,
1430
1432
  sidecarItem?: SidecarItem,
1431
1433
  ): Promise<PhaseResult> {
1432
1434
  const { ctx, pi, s, deps } = ic;
@@ -1450,13 +1452,58 @@ export async function runFinalize(
1450
1452
  };
1451
1453
 
1452
1454
  // Pre-verification processing (commit, doctor, state rebuild, etc.)
1455
+ // Timeout guard: if postUnitPreVerification hangs (e.g., safety harness
1456
+ // deadlock, browser teardown hang, worktree sync stall), force-continue
1457
+ // after timeout so the auto-loop is not permanently frozen (#3757).
1458
+ //
1459
+ // On timeout, null out s.currentUnit so the timed-out task's late async
1460
+ // mutations are harmless — postUnitPreVerification guards all side effects
1461
+ // behind `if (s.currentUnit)`. The next iteration sets a fresh currentUnit.
1453
1462
  // Sidecar items use lightweight pre-verification opts
1454
1463
  const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem
1455
1464
  ? sidecarItem.kind === "hook"
1456
1465
  ? { skipSettleDelay: true, skipWorktreeSync: true }
1457
1466
  : { skipSettleDelay: true }
1458
1467
  : undefined;
1459
- const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts);
1468
+ const preUnitSnapshot = s.currentUnit
1469
+ ? { type: s.currentUnit.type, id: s.currentUnit.id, startedAt: s.currentUnit.startedAt }
1470
+ : null;
1471
+ const preResultGuard = await withTimeout(
1472
+ deps.postUnitPreVerification(postUnitCtx, preVerificationOpts),
1473
+ FINALIZE_PRE_TIMEOUT_MS,
1474
+ "postUnitPreVerification",
1475
+ );
1476
+
1477
+ if (preResultGuard.timedOut) {
1478
+ // Detach session from the timed-out unit so late async completions
1479
+ // cannot mutate state for the next unit (#3757).
1480
+ s.currentUnit = null;
1481
+ loopState.consecutiveFinalizeTimeouts++;
1482
+ debugLog("autoLoop", {
1483
+ phase: "pre-verification-timeout",
1484
+ iteration: ic.iteration,
1485
+ unitType: iterData.unitType,
1486
+ unitId: iterData.unitId,
1487
+ consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts,
1488
+ });
1489
+
1490
+ if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) {
1491
+ ctx.ui.notify(
1492
+ `postUnitPreVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping auto-mode to prevent budget waste`,
1493
+ "error",
1494
+ );
1495
+ await deps.stopAuto(ctx, pi, `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`);
1496
+ return { action: "break", reason: "finalize-timeout-escalation" };
1497
+ }
1498
+
1499
+ ctx.ui.notify(
1500
+ `postUnitPreVerification timed out after ${FINALIZE_PRE_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`,
1501
+ "warning",
1502
+ );
1503
+ return { action: "next", data: undefined as void };
1504
+ }
1505
+
1506
+ const preResult = preResultGuard.value;
1460
1507
  if (preResult === "dispatched") {
1461
1508
  debugLog("autoLoop", {
1462
1509
  phase: "exit",
@@ -1525,14 +1572,29 @@ export async function runFinalize(
1525
1572
  );
1526
1573
 
1527
1574
  if (postResultGuard.timedOut) {
1575
+ // Detach session from the timed-out unit so late async completions
1576
+ // cannot mutate state for the next unit (#3757).
1577
+ s.currentUnit = null;
1578
+ loopState.consecutiveFinalizeTimeouts++;
1528
1579
  debugLog("autoLoop", {
1529
1580
  phase: "post-verification-timeout",
1530
1581
  iteration: ic.iteration,
1531
1582
  unitType: iterData.unitType,
1532
1583
  unitId: iterData.unitId,
1584
+ consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts,
1533
1585
  });
1586
+
1587
+ if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) {
1588
+ ctx.ui.notify(
1589
+ `postUnitPostVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping auto-mode to prevent budget waste`,
1590
+ "error",
1591
+ );
1592
+ await deps.stopAuto(ctx, pi, `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`);
1593
+ return { action: "break", reason: "finalize-timeout-escalation" };
1594
+ }
1595
+
1534
1596
  ctx.ui.notify(
1535
- `postUnitPostVerification timed out after ${FINALIZE_POST_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} — continuing to next iteration`,
1597
+ `postUnitPostVerification timed out after ${FINALIZE_POST_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`,
1536
1598
  "warning",
1537
1599
  );
1538
1600
  return { action: "next", data: undefined as void };
@@ -1554,6 +1616,9 @@ export async function runFinalize(
1554
1616
  return { action: "break", reason: "step-wizard" };
1555
1617
  }
1556
1618
 
1619
+ // Both pre and post verification completed without timeout — reset counter
1620
+ loopState.consecutiveFinalizeTimeouts = 0;
1621
+
1557
1622
  return { action: "next", data: undefined as void };
1558
1623
  }
1559
1624
 
@@ -91,8 +91,13 @@ export interface IterationContext {
91
91
  export interface LoopState {
92
92
  recentUnits: Array<{ key: string; error?: string }>;
93
93
  stuckRecoveryAttempts: number;
94
+ /** Consecutive finalize timeout count — stops auto-mode after threshold. */
95
+ consecutiveFinalizeTimeouts: number;
94
96
  }
95
97
 
98
+ /** Max consecutive finalize timeouts before hard-stopping auto-mode. */
99
+ export const MAX_FINALIZE_TIMEOUTS = 3;
100
+
96
101
  export interface PreDispatchData {
97
102
  state: GSDState;
98
103
  mid: string;
@@ -16,6 +16,7 @@ import {
16
16
  resolveSliceFile,
17
17
  } from "./paths.js";
18
18
  import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js";
19
+ import { formatShortcut } from "./files.js";
19
20
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
20
21
  import { execFileSync } from "node:child_process";
21
22
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@@ -855,7 +856,7 @@ export function updateProgressWidget(
855
856
  // Hints line
856
857
  const hintParts: string[] = [];
857
858
  hintParts.push("esc pause");
858
- hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard");
859
+ hintParts.push(`${formatShortcut("Ctrl+Alt+G")} dashboard`);
859
860
  const hintStr = theme.fg("dim", hintParts.join(" | "));
860
861
  const commitStr = lastCommit
861
862
  ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${commitMsg}`)
@@ -47,6 +47,10 @@ import {
47
47
  nativeGetCurrentBranch,
48
48
  nativeDetectMainBranch,
49
49
  nativeCheckoutBranch,
50
+ nativeBranchList,
51
+ nativeBranchListMerged,
52
+ nativeBranchDelete,
53
+ nativeWorktreeRemove,
50
54
  } from "./native-git-bridge.js";
51
55
  import { GitServiceImpl } from "./git-service.js";
52
56
  import {
@@ -56,6 +60,7 @@ import {
56
60
  } from "./worktree.js";
57
61
  import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js";
58
62
  import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js";
63
+ import { worktreePath as getWorktreeDir, isInsideWorktreesDir } from "./worktree-manager.js";
59
64
  import { initMetrics } from "./metrics.js";
60
65
  import { initRoutingHistory } from "./routing-history.js";
61
66
  import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
@@ -76,6 +81,7 @@ import {
76
81
  existsSync,
77
82
  mkdirSync,
78
83
  readdirSync,
84
+ rmSync,
79
85
  statSync,
80
86
  unlinkSync,
81
87
  } from "node:fs";
@@ -117,6 +123,123 @@ export async function openProjectDbIfPresent(basePath: string): Promise<void> {
117
123
  }
118
124
  }
119
125
 
126
+ /**
127
+ * Audit for orphaned milestone branches at bootstrap.
128
+ *
129
+ * After a milestone completes, the teardown step (merge branch → main,
130
+ * delete branch, remove worktree) runs as a post-completion engine step.
131
+ * If the session ends between completion and teardown, the branch and
132
+ * worktree are orphaned — the DB says "complete" so auto-mode won't
133
+ * re-enter the milestone, and the teardown is never retried.
134
+ *
135
+ * This audit runs on every fresh bootstrap to catch that gap:
136
+ * 1. Lists all local `milestone/*` branches.
137
+ * 2. For each, checks if the milestone's DB status is "complete".
138
+ * 3. If the branch is already merged into main → deletes the branch
139
+ * and cleans up any orphaned worktree directory (safe, no data loss).
140
+ * 4. If the branch is NOT merged → preserves it and warns the user
141
+ * so they can merge manually (data safety first).
142
+ *
143
+ * Returns a summary of actions taken for the caller to surface via notify.
144
+ */
145
+ export function auditOrphanedMilestoneBranches(
146
+ basePath: string,
147
+ isolationMode: "worktree" | "branch" | "none",
148
+ ): { recovered: string[]; warnings: string[] } {
149
+ const recovered: string[] = [];
150
+ const warnings: string[] = [];
151
+
152
+ // Skip in none mode — no milestone branches are created
153
+ if (isolationMode === "none") return { recovered, warnings };
154
+
155
+ // Skip if DB not available — can't determine completion status
156
+ if (!isDbAvailable()) return { recovered, warnings };
157
+
158
+ let milestoneBranches: string[];
159
+ try {
160
+ milestoneBranches = nativeBranchList(basePath, "milestone/*");
161
+ } catch {
162
+ // git branch list failed — skip audit
163
+ return { recovered, warnings };
164
+ }
165
+
166
+ if (milestoneBranches.length === 0) return { recovered, warnings };
167
+
168
+ // Detect main branch for merge-check
169
+ let mainBranch: string;
170
+ try {
171
+ mainBranch = nativeDetectMainBranch(basePath);
172
+ } catch {
173
+ mainBranch = "main";
174
+ }
175
+
176
+ // Get branches already merged into main
177
+ let mergedBranches: Set<string>;
178
+ try {
179
+ mergedBranches = new Set(nativeBranchListMerged(basePath, mainBranch, "milestone/*"));
180
+ } catch {
181
+ mergedBranches = new Set();
182
+ }
183
+
184
+ for (const branch of milestoneBranches) {
185
+ const milestoneId = branch.replace(/^milestone\//, "");
186
+ const milestone = getMilestone(milestoneId);
187
+
188
+ // Only audit completed milestones
189
+ if (!milestone || milestone.status !== "complete") continue;
190
+
191
+ const isMerged = mergedBranches.has(branch);
192
+
193
+ if (isMerged) {
194
+ // Branch is merged — safe to delete branch and clean up worktree dir
195
+ try {
196
+ nativeBranchDelete(basePath, branch, true);
197
+ recovered.push(`Deleted merged branch ${branch} for completed milestone ${milestoneId}.`);
198
+ } catch (err) {
199
+ warnings.push(`Failed to delete merged branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
200
+ }
201
+
202
+ // Clean up orphaned worktree directory if it exists
203
+ const wtDir = getWorktreeDir(basePath, milestoneId);
204
+ if (existsSync(wtDir)) {
205
+ // Try git worktree remove first (handles registered worktrees)
206
+ try {
207
+ nativeWorktreeRemove(basePath, wtDir, true);
208
+ } catch (e) {
209
+ // Not a registered worktree — expected for orphaned dirs
210
+ logWarning("engine", `worktree remove failed (expected for orphaned dirs): ${e instanceof Error ? e.message : String(e)}`);
211
+ }
212
+
213
+ // If the directory still exists after git worktree remove (either it
214
+ // wasn't registered or the remove was a noop), fall back to direct
215
+ // filesystem removal — but only inside .gsd/worktrees/ for safety (#2365).
216
+ if (existsSync(wtDir)) {
217
+ if (isInsideWorktreesDir(basePath, wtDir)) {
218
+ try {
219
+ rmSync(wtDir, { recursive: true, force: true });
220
+ recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`);
221
+ } catch (err2) {
222
+ warnings.push(`Failed to remove worktree directory for ${milestoneId}: ${err2 instanceof Error ? err2.message : String(err2)}`);
223
+ }
224
+ } else {
225
+ warnings.push(`Orphaned worktree directory for ${milestoneId} is outside .gsd/worktrees/ — skipping removal for safety.`);
226
+ }
227
+ } else {
228
+ recovered.push(`Removed orphaned worktree directory for ${milestoneId}.`);
229
+ }
230
+ }
231
+ } else {
232
+ // Branch is NOT merged — preserve for safety, warn the user
233
+ warnings.push(
234
+ `Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` +
235
+ `This may contain unmerged work. Merge manually or run \`/gsd health --fix\` to resolve.`,
236
+ );
237
+ }
238
+ }
239
+
240
+ return { recovered, warnings };
241
+ }
242
+
120
243
  export async function bootstrapAutoSession(
121
244
  s: AutoSession,
122
245
  ctx: ExtensionCommandContext,
@@ -300,6 +423,26 @@ export async function bootstrapAutoSession(
300
423
  // derivation (queue-order, task status) works on a cold start (#2841).
301
424
  await openProjectDbIfPresent(base);
302
425
 
426
+ // ── Orphaned milestone branch audit ──
427
+ // Catches completed milestones whose teardown (merge + branch delete)
428
+ // was lost due to session ending between completion and teardown.
429
+ // Must run after DB open and before worktree entry.
430
+ try {
431
+ const auditResult = auditOrphanedMilestoneBranches(base, getIsolationMode());
432
+ for (const msg of auditResult.recovered) {
433
+ ctx.ui.notify(`Orphan audit: ${msg}`, "info");
434
+ }
435
+ for (const msg of auditResult.warnings) {
436
+ ctx.ui.notify(`Orphan audit: ${msg}`, "warning");
437
+ }
438
+ if (auditResult.recovered.length > 0) {
439
+ debugLog("orphan-audit", { recovered: auditResult.recovered, warnings: auditResult.warnings });
440
+ }
441
+ } catch (err) {
442
+ // Non-fatal — the audit is defensive, never block bootstrap
443
+ logWarning("bootstrap", `orphaned milestone branch audit failed: ${err instanceof Error ? err.message : String(err)}`);
444
+ }
445
+
303
446
  let state = await deriveState(base);
304
447
 
305
448
  // Stale worktree state recovery (#654)
@@ -15,7 +15,7 @@ import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-dis
15
15
  import { getActiveAutoWorktreeContext } from "../auto-worktree.js";
16
16
  import { getActiveWorktreeName, getWorktreeOriginalCwd } from "../worktree-command.js";
17
17
  import { deriveState } from "../state.js";
18
- import { formatOverridesSection, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js";
18
+ import { formatOverridesSection, formatShortcut, loadActiveOverrides, loadFile, parseContinue, parseSummary } from "../files.js";
19
19
  import { toPosixPath } from "../../shared/mod.js";
20
20
  import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../../cmux/index.js";
21
21
 
@@ -72,6 +72,8 @@ export async function buildBeforeAgentStartResult(
72
72
  const systemContent = loadPrompt("system", {
73
73
  bundledSkillsTable: buildBundledSkillsTable(),
74
74
  templatesDir: getTemplatesDir(),
75
+ shortcutDashboard: formatShortcut("Ctrl+Alt+G"),
76
+ shortcutShell: formatShortcut("Ctrl+Alt+B"),
75
77
  });
76
78
  const loadedPreferences = loadEffectiveGSDPreferences();
77
79
  if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) {
@@ -8,6 +8,7 @@ import { runEnvironmentChecks } from "../../doctor-environment.js";
8
8
  import { deriveState } from "../../state.js";
9
9
  import { handleCmux } from "../../commands-cmux.js";
10
10
  import { projectRoot } from "../context.js";
11
+ import { formatShortcut } from "../../files.js";
11
12
 
12
13
  export function showHelp(ctx: ExtensionCommandContext): void {
13
14
  const lines = [
@@ -24,12 +25,12 @@ export function showHelp(ctx: ExtensionCommandContext): void {
24
25
  " /gsd new-milestone Create milestone from headless context (used by gsd headless)",
25
26
  "",
26
27
  "VISIBILITY",
27
- " /gsd status Show progress dashboard (Ctrl+Alt+G)",
28
+ ` /gsd status Show progress dashboard (${formatShortcut("Ctrl+Alt+G")})`,
28
29
  " /gsd visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
29
30
  " /gsd queue Show queued/dispatched units and execution order",
30
31
  " /gsd history View execution history [--cost] [--phase] [--model] [N]",
31
32
  " /gsd changelog Show categorized release notes [version]",
32
- " /gsd notifications View persistent notification history [clear|tail|filter] (Ctrl+Alt+N)",
33
+ ` /gsd notifications View persistent notification history [clear|tail|filter] (${formatShortcut("Ctrl+Alt+N")})`,
33
34
  "",
34
35
  "COURSE CORRECTION",
35
36
  " /gsd steer <desc> Apply user override to active work",
@@ -70,6 +70,25 @@ export function clearParseCache(): void {
70
70
  for (const cb of _cacheClearCallbacks) cb();
71
71
  }
72
72
 
73
+ // ─── Platform shortcuts ───────────────────────────────────────────────────
74
+
75
+ const IS_MAC = process.platform === "darwin";
76
+
77
+ /**
78
+ * Format a keyboard shortcut for the current OS.
79
+ * Input: modifier key combo like "Ctrl+Alt+G"
80
+ * Output: "⌃⌥G" on macOS, "Ctrl+Alt+G" on Windows/Linux.
81
+ */
82
+ export function formatShortcut(combo: string): string {
83
+ if (!IS_MAC) return combo;
84
+ return combo
85
+ .replace(/Ctrl\+Alt\+/i, "⌃⌥")
86
+ .replace(/Ctrl\+/i, "⌃")
87
+ .replace(/Alt\+/i, "⌥")
88
+ .replace(/Shift\+/i, "⇧")
89
+ .replace(/Cmd\+/i, "⌘");
90
+ }
91
+
73
92
  // ─── Helpers ───────────────────────────────────────────────────────────────
74
93
 
75
94
  /** Extract the text after a heading at a given level, up to the next heading of same or higher level. */
@@ -1,6 +1,6 @@
1
1
  // GSD Extension — Notification History Overlay
2
2
  // Scrollable panel showing all persisted notifications with severity filtering.
3
- // Toggled with Ctrl+Alt+N or opened from /gsd notifications.
3
+ // Toggled with Ctrl+Alt+N (⌃⌥N on macOS) or opened from /gsd notifications.
4
4
 
5
5
  import type { Theme } from "@gsd/pi-coding-agent";
6
6
  import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
@@ -6,6 +6,7 @@
6
6
  import type { ExtensionContext } from "@gsd/pi-coding-agent";
7
7
 
8
8
  import { getUnreadCount, readNotifications } from "./notification-store.js";
9
+ import { formatShortcut } from "./files.js";
9
10
 
10
11
  // ─── Pure rendering ──���────────────────────────���─────────────────────────
11
12
 
@@ -24,7 +25,7 @@ export function buildNotificationWidgetLines(): string[] {
24
25
  ? latest.message.slice(0, msgMax - 1) + "…"
25
26
  : latest.message;
26
27
 
27
- return [` ${icon} [${badge}] ${truncated} (Ctrl+Alt+N to view)`];
28
+ return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} to view)`];
28
29
  }
29
30
 
30
31
  // ─── Widget init ────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@
2
2
  * GSD Parallel Monitor Overlay
3
3
  *
4
4
  * Full-screen TUI overlay showing real-time parallel worker progress.
5
- * Opened via `/gsd parallel watch` or Ctrl+Alt+P.
5
+ * Opened via `/gsd parallel watch` or Ctrl+Alt+P (⌃⌥P on macOS).
6
6
  * Reads the same data sources as `scripts/parallel-monitor.mjs` but
7
7
  * renders as a native pi-tui overlay with theme integration.
8
8
  */
@@ -238,8 +238,7 @@ export async function checkPackageExistence(
238
238
  export function normalizeFilePath(filePath: string): string {
239
239
  if (!filePath) return filePath;
240
240
 
241
- // Strip backtick wrapping from LLM-generated paths (#3649)
242
- let normalized = filePath.replace(/`/g, "");
241
+ let normalized = extractPathFromAnnotation(filePath);
243
242
 
244
243
  // Normalize path separators to forward slashes
245
244
  normalized = normalized.replace(/\\/g, "/");
@@ -260,6 +259,24 @@ export function normalizeFilePath(filePath: string): string {
260
259
  return normalized;
261
260
  }
262
261
 
262
+ function extractPathFromAnnotation(raw: string): string {
263
+ const trimmed = raw.trim();
264
+ if (!trimmed) return trimmed;
265
+
266
+ const backtickMatch = trimmed.match(/^`([^`]+)`(?:\s+[—–-]\s+.*)?$/);
267
+ if (backtickMatch) {
268
+ return backtickMatch[1].trim();
269
+ }
270
+
271
+ const annotatedMatch = trimmed.match(/^(.+?)\s+[—–-]\s+.+$/);
272
+ if (annotatedMatch) {
273
+ return annotatedMatch[1].trim();
274
+ }
275
+
276
+ // Fall back to the original behavior for already-plain paths.
277
+ return trimmed.replace(/`/g, "");
278
+ }
279
+
263
280
  /**
264
281
  * Build a set of files that will be created by tasks up to (but not including) taskIndex.
265
282
  * All paths are normalized for consistent comparison.
@@ -131,8 +131,8 @@ Templates showing the expected format for each artifact type are in:
131
131
  - `/gsd status` - progress dashboard overlay
132
132
  - `/gsd queue` - queue future milestones (safe while auto-mode is running)
133
133
  - `/gsd quick <task>` - quick task with GSD guarantees (atomic commits, state tracking) but no milestone ceremony
134
- - `Ctrl+Alt+G` - toggle dashboard overlay
135
- - `Ctrl+Alt+B` - show shell processes
134
+ - `{{shortcutDashboard}}` - toggle dashboard overlay
135
+ - `{{shortcutShell}}` - show shell processes
136
136
 
137
137
  ## Execution Heuristics
138
138