gsd-pi 2.65.0-dev.6cc5110 → 2.65.0-dev.800ece0

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 (96) 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/register-hooks.js +7 -2
  8. package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
  9. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -1
  10. package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -2
  11. package/dist/resources/extensions/gsd/files.js +17 -0
  12. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  13. package/dist/resources/extensions/gsd/index.js +1 -1
  14. package/dist/resources/extensions/gsd/notification-overlay.js +1 -1
  15. package/dist/resources/extensions/gsd/notification-widget.js +2 -1
  16. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +1 -1
  17. package/dist/resources/extensions/gsd/pre-execution-checks.js +16 -2
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +2 -0
  19. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  20. package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
  21. package/dist/resources/extensions/gsd/prompts/system.md +2 -2
  22. package/dist/resources/extensions/gsd/state.js +3 -6
  23. package/dist/resources/extensions/gsd/workflow-events.js +1 -0
  24. package/dist/resources/extensions/gsd/workflow-projections.js +3 -2
  25. package/dist/resources/extensions/subagent/agents.js +19 -5
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  31. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.html +1 -1
  47. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/package.json +1 -1
  58. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  59. package/packages/pi-tui/dist/tui.js +3 -1
  60. package/packages/pi-tui/dist/tui.js.map +1 -1
  61. package/packages/pi-tui/src/tui.ts +3 -1
  62. package/src/resources/extensions/gsd/auto/finalize-timeout.ts +3 -0
  63. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  64. package/src/resources/extensions/gsd/auto/phases.ts +68 -3
  65. package/src/resources/extensions/gsd/auto/types.ts +5 -0
  66. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -1
  67. package/src/resources/extensions/gsd/auto-start.ts +143 -0
  68. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +7 -2
  69. package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
  70. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -1
  71. package/src/resources/extensions/gsd/commands/handlers/core.ts +3 -2
  72. package/src/resources/extensions/gsd/files.ts +19 -0
  73. package/src/resources/extensions/gsd/gsd-db.ts +33 -2
  74. package/src/resources/extensions/gsd/index.ts +1 -0
  75. package/src/resources/extensions/gsd/notification-overlay.ts +1 -1
  76. package/src/resources/extensions/gsd/notification-widget.ts +2 -1
  77. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +1 -1
  78. package/src/resources/extensions/gsd/pre-execution-checks.ts +19 -2
  79. package/src/resources/extensions/gsd/prompts/discuss.md +2 -0
  80. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  81. package/src/resources/extensions/gsd/prompts/queue.md +2 -0
  82. package/src/resources/extensions/gsd/prompts/system.md +2 -2
  83. package/src/resources/extensions/gsd/state.ts +3 -6
  84. package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +125 -0
  85. package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +69 -0
  86. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +11 -10
  87. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +189 -0
  88. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
  89. package/src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts +47 -0
  90. package/src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
  91. package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
  92. package/src/resources/extensions/gsd/workflow-events.ts +5 -3
  93. package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
  94. package/src/resources/extensions/subagent/agents.ts +30 -6
  95. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_buildManifest.js +0 -0
  96. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_ssgManifest.js +0 -0
@@ -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)
@@ -6,7 +6,7 @@ import { isToolCallEventType } from "@gsd/pi-coding-agent";
6
6
  import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolveSlicePath } from "../paths.js";
7
7
  import { buildBeforeAgentStartResult } from "./system-context.js";
8
8
  import { handleAgentEnd } from "./agent-end-recovery.js";
9
- import { clearDiscussionFlowState, isDepthVerified, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution } from "./write-gate.js";
9
+ import { clearDiscussionFlowState, isDepthVerified, isDepthConfirmationAnswer, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite, shouldBlockQueueExecution } from "./write-gate.js";
10
10
  import { isBlockedStateFile, isBashWriteToStateFile, BLOCKED_WRITE_ERROR } from "../write-intercept.js";
11
11
  import { cleanupQuickBranch } from "../quick.js";
12
12
  import { getDiscussionMilestoneId } from "../guided-flow.js";
@@ -249,7 +249,12 @@ export function registerHooks(pi: ExtensionAPI): void {
249
249
  const questions: any[] = (event.input as any)?.questions ?? [];
250
250
  for (const question of questions) {
251
251
  if (typeof question.id === "string" && question.id.includes("depth_verification")) {
252
- markDepthVerified();
252
+ // Only unlock the gate if the user selected the first option (confirmation).
253
+ // Cross-references against the question's defined options to reject free-form "Other" text.
254
+ const answer = details.response?.answers?.[question.id];
255
+ if (isDepthConfirmationAnswer(answer?.selected, question.options)) {
256
+ markDepthVerified();
257
+ }
253
258
  break;
254
259
  }
255
260
  }
@@ -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)) {
@@ -54,6 +54,35 @@ export function markDepthVerified(): void {
54
54
  depthVerificationDone = true;
55
55
  }
56
56
 
57
+ /**
58
+ * Check whether a depth_verification answer confirms the discussion is complete.
59
+ * Uses structural validation: the selected answer must exactly match the first
60
+ * option label from the question definition (the confirmation option by convention).
61
+ * This rejects free-form "Other" text, decline options, and garbage input without
62
+ * coupling to any specific label substring.
63
+ *
64
+ * @param selected The answer's selected value from details.response.answers[id].selected
65
+ * @param options The question's options array from event.input.questions[n].options
66
+ */
67
+ export function isDepthConfirmationAnswer(
68
+ selected: unknown,
69
+ options?: Array<{ label?: string }>,
70
+ ): boolean {
71
+ const value = Array.isArray(selected) ? selected[0] : selected;
72
+ if (typeof value !== "string" || !value) return false;
73
+
74
+ // If options are available, structurally validate: selected must exactly match
75
+ // the first option (confirmation) label. Rejects free-form "Other" and decline options.
76
+ if (Array.isArray(options) && options.length > 0) {
77
+ const confirmLabel = options[0]?.label;
78
+ return typeof confirmLabel === "string" && value === confirmLabel;
79
+ }
80
+
81
+ // Fallback when options aren't available (e.g., older call sites):
82
+ // accept only if it contains "(Recommended)" — the prompt convention suffix.
83
+ return value.includes("(Recommended)");
84
+ }
85
+
57
86
  export function shouldBlockContextWrite(
58
87
  toolName: string,
59
88
  inputPath: string,
@@ -71,7 +100,13 @@ export function shouldBlockContextWrite(
71
100
 
72
101
  return {
73
102
  block: true,
74
- reason: `Blocked: Cannot write to milestone CONTEXT.md during discussion phase without depth verification. Call ask_user_questions with question id "depth_verification" first to confirm discussion depth before writing context.`,
103
+ reason: [
104
+ `HARD BLOCK: Cannot write to milestone CONTEXT.md without depth verification.`,
105
+ `This is a mechanical gate — you MUST NOT proceed, retry, or rationalize past this block.`,
106
+ `Required action: call ask_user_questions with question id containing "depth_verification".`,
107
+ `The user MUST select the "(Recommended)" confirmation option to unlock this gate.`,
108
+ `If the user declines, cancels, or the tool fails, you must re-ask — not bypass.`,
109
+ ].join(" "),
75
110
  };
76
111
  }
77
112
 
@@ -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. */
@@ -451,6 +451,25 @@ function migrateSchema(db: DbAdapter): void {
451
451
  const currentVersion = row ? (row["v"] as number) : 0;
452
452
  if (currentVersion >= SCHEMA_VERSION) return;
453
453
 
454
+ // Backup database before migration so a mid-migration crash doesn't
455
+ // leave a partially-migrated DB with no recovery path.
456
+ // WAL-safe: checkpoint first to flush WAL into the main DB file, then copy.
457
+ if (currentPath && currentPath !== ":memory:" && existsSync(currentPath)) {
458
+ try {
459
+ const backupPath = `${currentPath}.backup-v${currentVersion}`;
460
+ if (!existsSync(backupPath)) {
461
+ // Flush WAL to main DB file before copying — without this, the backup
462
+ // may be missing committed data that only exists in the -wal file.
463
+ try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch { /* checkpoint is best-effort */ }
464
+ copyFileSync(currentPath, backupPath);
465
+ }
466
+ } catch (backupErr) {
467
+ // Log but proceed — blocking migration leaves the DB stuck at an old
468
+ // schema version permanently on read-only or full filesystems.
469
+ logWarning("db", `Pre-migration backup failed: ${backupErr instanceof Error ? backupErr.message : String(backupErr)}`);
470
+ }
471
+ }
472
+
454
473
  db.exec("BEGIN");
455
474
  try {
456
475
  if (currentVersion < 2) {
@@ -999,9 +1018,21 @@ export function _resetProvider(): void {
999
1018
 
1000
1019
  export function upsertDecision(d: Omit<Decision, "seq">): void {
1001
1020
  if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1021
+ // Use ON CONFLICT DO UPDATE instead of INSERT OR REPLACE to preserve the
1022
+ // seq column. INSERT OR REPLACE deletes then reinserts, resetting seq and
1023
+ // corrupting decision ordering in DECISIONS.md after reconcile replay.
1002
1024
  currentDb.prepare(
1003
- `INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by)
1004
- VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)`,
1025
+ `INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by)
1026
+ VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :made_by, :superseded_by)
1027
+ ON CONFLICT(id) DO UPDATE SET
1028
+ when_context = excluded.when_context,
1029
+ scope = excluded.scope,
1030
+ decision = excluded.decision,
1031
+ choice = excluded.choice,
1032
+ rationale = excluded.rationale,
1033
+ revisable = excluded.revisable,
1034
+ made_by = excluded.made_by,
1035
+ superseded_by = excluded.superseded_by`,
1005
1036
  ).run({
1006
1037
  ":id": d.id,
1007
1038
  ":when_context": d.when_context,
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@gsd/pi-coding-agent";
2
2
 
3
3
  export {
4
+ isDepthConfirmationAnswer,
4
5
  isDepthVerified,
5
6
  isQueuePhaseActive,
6
7
  setQueuePhaseActive,
@@ -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.
@@ -114,6 +114,8 @@ If they clarify, absorb the correction and re-verify.
114
114
 
115
115
  The depth verification is the required write-gate. Do **not** add another meta "ready to proceed?" checkpoint immediately after it unless there is still material ambiguity.
116
116
 
117
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
118
+
117
119
  ## Wrap-up Gate
118
120
 
119
121
  Once the depth checklist is fully satisfied, move directly into requirements and roadmap preview. Do not insert a separate "are you ready to continue?" gate unless the user explicitly wants to keep brainstorming or you still see material ambiguity.
@@ -100,6 +100,8 @@ If they clarify, absorb the correction and re-verify.
100
100
 
101
101
  The depth verification is the only required confirmation gate. Do not add a second "ready to proceed?" gate after it.
102
102
 
103
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
104
+
103
105
  ---
104
106
 
105
107
  ## Output
@@ -103,6 +103,8 @@ The user confirms or corrects before you write. One depth verification per miles
103
103
 
104
104
  **If you skip this step, the system will block the CONTEXT.md write and return an error telling you to complete verification first.**
105
105
 
106
+ **CRITICAL — Non-bypassable gate:** The system mechanically blocks CONTEXT.md writes until the user selects the "(Recommended)" option. If the user declines, cancels, or the tool fails, you MUST re-ask — never rationalize past the block ("tool not responding, I'll proceed" is forbidden). The gate exists to protect the user's work; treat a block as an instruction, not an obstacle to work around.
107
+
106
108
  ## Output Phase
107
109
 
108
110
  Once the user is satisfied, in a single pass for **each** new milestone:
@@ -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
 
@@ -304,12 +304,9 @@ function extractContextTitle(content: string | null, fallback: string): string {
304
304
 
305
305
  // ─── DB-backed State Derivation ────────────────────────────────────────────
306
306
 
307
- /**
308
- * Helper: check if a DB status counts as "done" (handles K002 ambiguity).
309
- */
310
- function isStatusDone(status: string): boolean {
311
- return status === 'complete' || status === 'done' || status === 'skipped';
312
- }
307
+ // isStatusDone replaced by isClosedStatus from status-guards.ts (single source of truth).
308
+ // Alias kept for backward compatibility within this file.
309
+ const isStatusDone = isClosedStatus;
313
310
 
314
311
  /**
315
312
  * Derive GSD state from the milestones/slices/tasks DB tables.
@@ -19,8 +19,10 @@
19
19
  import { createTestContext } from "./test-helpers.ts";
20
20
  import {
21
21
  withTimeout,
22
+ FINALIZE_PRE_TIMEOUT_MS,
22
23
  FINALIZE_POST_TIMEOUT_MS,
23
24
  } from "../auto/finalize-timeout.ts";
25
+ import { MAX_FINALIZE_TIMEOUTS } from "../auto/types.ts";
24
26
 
25
27
  const { assertTrue, assertEq, report } = createTestContext();
26
28
 
@@ -78,6 +80,25 @@ const { assertTrue, assertEq, report } = createTestContext();
78
80
  assertTrue(caught, "rejection should propagate");
79
81
  }
80
82
 
83
+ // ═══ Test: FINALIZE_PRE_TIMEOUT_MS is defined and reasonable ═════════════════
84
+
85
+ {
86
+ console.log("\n=== #3757: pre-verification timeout constant is defined and reasonable ===");
87
+
88
+ assertTrue(
89
+ typeof FINALIZE_PRE_TIMEOUT_MS === "number",
90
+ "FINALIZE_PRE_TIMEOUT_MS should be a number",
91
+ );
92
+ assertTrue(
93
+ FINALIZE_PRE_TIMEOUT_MS >= 30_000,
94
+ `pre timeout should be >= 30s (got ${FINALIZE_PRE_TIMEOUT_MS}ms)`,
95
+ );
96
+ assertTrue(
97
+ FINALIZE_PRE_TIMEOUT_MS <= 120_000,
98
+ `pre timeout should be <= 120s (got ${FINALIZE_PRE_TIMEOUT_MS}ms)`,
99
+ );
100
+ }
101
+
81
102
  // ═══ Test: FINALIZE_POST_TIMEOUT_MS is defined and reasonable ═════════════════
82
103
 
83
104
  {
@@ -113,4 +134,108 @@ const { assertTrue, assertEq, report } = createTestContext();
113
134
  assertEq(result.timedOut, false, "should not time out");
114
135
  }
115
136
 
137
+ // ═══ Test: runFinalize wraps BOTH pre and post verification with withTimeout ═
138
+
139
+ {
140
+ console.log("\n=== #3757: runFinalize wraps preVerification with timeout guard ===");
141
+
142
+ const { readFileSync } = await import("node:fs");
143
+ const phasesSource = readFileSync(
144
+ new URL("../auto/phases.ts", import.meta.url),
145
+ "utf-8",
146
+ );
147
+
148
+ // Find the runFinalize function body
149
+ const fnIdx = phasesSource.indexOf("export async function runFinalize(");
150
+ assertTrue(fnIdx > 0, "runFinalize function should exist in phases.ts");
151
+
152
+ const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
153
+
154
+ // postUnitPreVerification must be wrapped in withTimeout
155
+ const preTimeoutIdx = fnBody.indexOf("withTimeout(");
156
+ assertTrue(preTimeoutIdx > 0, "withTimeout should appear in runFinalize");
157
+
158
+ const preVerIdx = fnBody.indexOf("postUnitPreVerification");
159
+ assertTrue(preVerIdx > 0, "postUnitPreVerification should appear in runFinalize");
160
+
161
+ // The first withTimeout should wrap postUnitPreVerification (not postUnitPostVerification)
162
+ const firstWithTimeout = fnBody.slice(preTimeoutIdx, preTimeoutIdx + 200);
163
+ assertTrue(
164
+ firstWithTimeout.includes("postUnitPreVerification"),
165
+ "first withTimeout in runFinalize should wrap postUnitPreVerification",
166
+ );
167
+
168
+ // postUnitPostVerification must also be wrapped
169
+ const postVerIdx = fnBody.indexOf("postUnitPostVerification");
170
+ assertTrue(postVerIdx > 0, "postUnitPostVerification should appear in runFinalize");
171
+
172
+ // Count withTimeout occurrences — should be at least 2 (pre + post)
173
+ const timeoutCount = (fnBody.match(/withTimeout\(/g) || []).length;
174
+ assertTrue(
175
+ timeoutCount >= 2,
176
+ `runFinalize should have at least 2 withTimeout guards (found ${timeoutCount})`,
177
+ );
178
+ }
179
+
180
+ // ═══ Test: MAX_FINALIZE_TIMEOUTS is defined and reasonable ═══════════════════
181
+
182
+ {
183
+ console.log("\n=== #3757: MAX_FINALIZE_TIMEOUTS is defined and reasonable ===");
184
+
185
+ assertTrue(
186
+ typeof MAX_FINALIZE_TIMEOUTS === "number",
187
+ "MAX_FINALIZE_TIMEOUTS should be a number",
188
+ );
189
+ assertTrue(
190
+ MAX_FINALIZE_TIMEOUTS >= 2,
191
+ `threshold should be >= 2 (got ${MAX_FINALIZE_TIMEOUTS})`,
192
+ );
193
+ assertTrue(
194
+ MAX_FINALIZE_TIMEOUTS <= 10,
195
+ `threshold should be <= 10 (got ${MAX_FINALIZE_TIMEOUTS})`,
196
+ );
197
+ }
198
+
199
+ // ═══ Test: timeout handlers escalate after consecutive timeouts ══════════════
200
+
201
+ {
202
+ console.log("\n=== #3757: timeout handlers escalate and detach currentUnit ===");
203
+
204
+ const { readFileSync } = await import("node:fs");
205
+ const phasesSource = readFileSync(
206
+ new URL("../auto/phases.ts", import.meta.url),
207
+ "utf-8",
208
+ );
209
+
210
+ const fnIdx = phasesSource.indexOf("export async function runFinalize(");
211
+ const fnBody = phasesSource.slice(fnIdx, fnIdx + 8000);
212
+
213
+ // Both timeout handlers should increment consecutiveFinalizeTimeouts
214
+ const incrementCount = (fnBody.match(/consecutiveFinalizeTimeouts\+\+/g) || []).length;
215
+ assertTrue(
216
+ incrementCount >= 2,
217
+ `should increment consecutiveFinalizeTimeouts in both pre and post handlers (found ${incrementCount})`,
218
+ );
219
+
220
+ // Both timeout handlers should check MAX_FINALIZE_TIMEOUTS for escalation
221
+ const escalationCount = (fnBody.match(/MAX_FINALIZE_TIMEOUTS/g) || []).length;
222
+ assertTrue(
223
+ escalationCount >= 2,
224
+ `should check MAX_FINALIZE_TIMEOUTS in both handlers (found ${escalationCount})`,
225
+ );
226
+
227
+ // Both timeout handlers should null out s.currentUnit to prevent late mutations
228
+ const detachCount = (fnBody.match(/s\.currentUnit\s*=\s*null/g) || []).length;
229
+ assertTrue(
230
+ detachCount >= 2,
231
+ `should detach s.currentUnit in both timeout handlers (found ${detachCount})`,
232
+ );
233
+
234
+ // Successful finalize should reset the counter
235
+ assertTrue(
236
+ fnBody.includes("consecutiveFinalizeTimeouts = 0"),
237
+ "should reset consecutiveFinalizeTimeouts on successful finalize",
238
+ );
239
+ }
240
+
116
241
  report();