gsd-pi 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  2. package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
  3. package/dist/resources/extensions/gsd/auto.ts +276 -19
  4. package/dist/resources/extensions/gsd/captures.ts +384 -0
  5. package/dist/resources/extensions/gsd/commands.ts +139 -3
  6. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  7. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  8. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  9. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  10. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  11. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  12. package/dist/resources/extensions/gsd/preferences.ts +73 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  14. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  15. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  16. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  17. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  18. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  19. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  20. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  21. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  22. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  23. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  25. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  26. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  27. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  28. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  29. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  30. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  31. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  32. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  33. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  34. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  35. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  36. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  39. package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
  40. package/src/resources/extensions/gsd/auto.ts +276 -19
  41. package/src/resources/extensions/gsd/captures.ts +384 -0
  42. package/src/resources/extensions/gsd/commands.ts +139 -3
  43. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  44. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  45. package/src/resources/extensions/gsd/metrics.ts +48 -0
  46. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  47. package/src/resources/extensions/gsd/model-router.ts +256 -0
  48. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/src/resources/extensions/gsd/preferences.ts +73 -0
  50. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  51. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  52. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  53. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  54. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  55. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  56. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  57. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  58. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  59. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  60. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  61. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  62. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  63. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  64. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  65. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  66. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  67. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  68. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  69. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  70. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  71. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  72. package/src/resources/extensions/remote-questions/format.ts +12 -6
  73. package/src/resources/extensions/remote-questions/manager.ts +8 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * GSD Captures — Fire-and-forget thought capture with triage classification
3
+ *
4
+ * Append-only capture file at `.gsd/CAPTURES.md`. Each capture is an H3 section
5
+ * with bold metadata fields, parseable by the same patterns used in files.ts.
6
+ *
7
+ * Worktree-aware: captures always resolve to the original project root's
8
+ * `.gsd/CAPTURES.md`, not the worktree's local `.gsd/`.
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
+ import { join, resolve, sep } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import { gsdRoot } from "./paths.js";
15
+
16
+ // ─── Types ────────────────────────────────────────────────────────────────────
17
+
18
+ export type Classification = "quick-task" | "inject" | "defer" | "replan" | "note";
19
+
20
+ export interface CaptureEntry {
21
+ id: string;
22
+ text: string;
23
+ timestamp: string;
24
+ status: "pending" | "triaged" | "resolved";
25
+ classification?: Classification;
26
+ resolution?: string;
27
+ rationale?: string;
28
+ resolvedAt?: string;
29
+ }
30
+
31
+ export interface TriageResult {
32
+ captureId: string;
33
+ classification: Classification;
34
+ rationale: string;
35
+ affectedFiles?: string[];
36
+ targetSlice?: string;
37
+ }
38
+
39
+ // ─── Constants ────────────────────────────────────────────────────────────────
40
+
41
+ const CAPTURES_FILENAME = "CAPTURES.md";
42
+ const VALID_CLASSIFICATIONS: readonly string[] = [
43
+ "quick-task", "inject", "defer", "replan", "note",
44
+ ];
45
+
46
+ // ─── Path Resolution ──────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Resolve the path to CAPTURES.md, aware of worktree context.
50
+ *
51
+ * In worktree-isolated mode, basePath is `.gsd/worktrees/<MID>/`.
52
+ * Captures must resolve to the *original* project root's `.gsd/CAPTURES.md`,
53
+ * not the worktree-local `.gsd/`. This ensures all captures go to one file
54
+ * regardless of which worktree the agent is running in.
55
+ *
56
+ * Detection: if basePath contains `/.gsd/worktrees/`, walk up to the
57
+ * directory that contains `.gsd/worktrees/` — that's the project root.
58
+ */
59
+ export function resolveCapturesPath(basePath: string): string {
60
+ const resolved = resolve(basePath);
61
+ const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
62
+ const idx = resolved.indexOf(worktreeMarker);
63
+ if (idx !== -1) {
64
+ // basePath is inside a worktree — resolve to project root
65
+ const projectRoot = resolved.slice(0, idx);
66
+ return join(projectRoot, ".gsd", CAPTURES_FILENAME);
67
+ }
68
+ return join(gsdRoot(basePath), CAPTURES_FILENAME);
69
+ }
70
+
71
+ // ─── File I/O ─────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Append a new capture entry to CAPTURES.md.
75
+ * Creates `.gsd/` and the file if they don't exist.
76
+ * Returns the generated capture ID.
77
+ */
78
+ export function appendCapture(basePath: string, text: string): string {
79
+ const filePath = resolveCapturesPath(basePath);
80
+ const dir = join(filePath, "..");
81
+ if (!existsSync(dir)) {
82
+ mkdirSync(dir, { recursive: true });
83
+ }
84
+
85
+ const id = `CAP-${randomUUID().slice(0, 8)}`;
86
+ const timestamp = new Date().toISOString();
87
+
88
+ const entry = [
89
+ `### ${id}`,
90
+ `**Text:** ${text}`,
91
+ `**Captured:** ${timestamp}`,
92
+ `**Status:** pending`,
93
+ "",
94
+ ].join("\n");
95
+
96
+ if (existsSync(filePath)) {
97
+ const existing = readFileSync(filePath, "utf-8");
98
+ writeFileSync(filePath, existing.trimEnd() + "\n\n" + entry, "utf-8");
99
+ } else {
100
+ const header = `# Captures\n\n`;
101
+ writeFileSync(filePath, header + entry, "utf-8");
102
+ }
103
+
104
+ return id;
105
+ }
106
+
107
+ /**
108
+ * Parse all capture entries from CAPTURES.md.
109
+ * Returns entries in file order (oldest first).
110
+ */
111
+ export function loadAllCaptures(basePath: string): CaptureEntry[] {
112
+ const filePath = resolveCapturesPath(basePath);
113
+ if (!existsSync(filePath)) return [];
114
+
115
+ const content = readFileSync(filePath, "utf-8");
116
+ return parseCapturesContent(content);
117
+ }
118
+
119
+ /**
120
+ * Load only pending (unresolved) captures.
121
+ */
122
+ export function loadPendingCaptures(basePath: string): CaptureEntry[] {
123
+ return loadAllCaptures(basePath).filter(c => c.status === "pending");
124
+ }
125
+
126
+ /**
127
+ * Fast check for pending captures without full parse.
128
+ * Reads the file and scans for `**Status:** pending` via regex.
129
+ * Returns false if the file doesn't exist.
130
+ */
131
+ export function hasPendingCaptures(basePath: string): boolean {
132
+ const filePath = resolveCapturesPath(basePath);
133
+ if (!existsSync(filePath)) return false;
134
+ try {
135
+ const content = readFileSync(filePath, "utf-8");
136
+ return /\*\*Status:\*\*\s*pending/i.test(content);
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Count pending captures without full parse — single file read.
144
+ * Uses regex to count `**Status:** pending` occurrences.
145
+ * Returns 0 if file doesn't exist or on error.
146
+ */
147
+ export function countPendingCaptures(basePath: string): number {
148
+ const filePath = resolveCapturesPath(basePath);
149
+ if (!existsSync(filePath)) return 0;
150
+ try {
151
+ const content = readFileSync(filePath, "utf-8");
152
+ const matches = content.match(/\*\*Status:\*\*\s*pending/gi);
153
+ return matches ? matches.length : 0;
154
+ } catch {
155
+ return 0;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Mark a capture as resolved with classification and rationale.
161
+ * Rewrites the entry in place, preserving other entries.
162
+ */
163
+ export function markCaptureResolved(
164
+ basePath: string,
165
+ captureId: string,
166
+ classification: Classification,
167
+ resolution: string,
168
+ rationale: string,
169
+ ): void {
170
+ const filePath = resolveCapturesPath(basePath);
171
+ if (!existsSync(filePath)) return;
172
+
173
+ const content = readFileSync(filePath, "utf-8");
174
+ const resolvedAt = new Date().toISOString();
175
+
176
+ // Find the section for this capture ID and rewrite its fields
177
+ const sectionRegex = new RegExp(
178
+ `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`,
179
+ "s",
180
+ );
181
+ const match = sectionRegex.exec(content);
182
+ if (!match) return;
183
+
184
+ let section = match[1];
185
+
186
+ // Update Status field
187
+ section = section.replace(
188
+ /\*\*Status:\*\*\s*.+/,
189
+ `**Status:** resolved`,
190
+ );
191
+
192
+ // Append classification, resolution, rationale, and timestamp if not present
193
+ const newFields = [
194
+ `**Classification:** ${classification}`,
195
+ `**Resolution:** ${resolution}`,
196
+ `**Rationale:** ${rationale}`,
197
+ `**Resolved:** ${resolvedAt}`,
198
+ ];
199
+
200
+ // Remove any existing classification/resolution/rationale/resolved fields
201
+ // (in case of re-triage)
202
+ section = section.replace(/\*\*Classification:\*\*\s*.+\n?/g, "");
203
+ section = section.replace(/\*\*Resolution:\*\*\s*.+\n?/g, "");
204
+ section = section.replace(/\*\*Rationale:\*\*\s*.+\n?/g, "");
205
+ section = section.replace(/\*\*Resolved:\*\*\s*.+\n?/g, "");
206
+
207
+ // Add new fields after Status line
208
+ section = section.trimEnd() + "\n" + newFields.join("\n") + "\n";
209
+
210
+ const updated = content.replace(sectionRegex, section);
211
+ writeFileSync(filePath, updated, "utf-8");
212
+ }
213
+
214
+ // ─── Parser ───────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Parse CAPTURES.md content into CaptureEntry array.
218
+ */
219
+ function parseCapturesContent(content: string): CaptureEntry[] {
220
+ const entries: CaptureEntry[] = [];
221
+
222
+ // Split on H3 headings
223
+ const sections = content.split(/^### /m).slice(1); // skip content before first H3
224
+
225
+ for (const section of sections) {
226
+ const lines = section.split("\n");
227
+ const id = lines[0]?.trim();
228
+ if (!id) continue;
229
+
230
+ const body = lines.slice(1).join("\n");
231
+ const text = extractBoldField(body, "Text");
232
+ const timestamp = extractBoldField(body, "Captured");
233
+ const statusRaw = extractBoldField(body, "Status");
234
+ const classification = extractBoldField(body, "Classification") as Classification | null;
235
+ const resolution = extractBoldField(body, "Resolution");
236
+ const rationale = extractBoldField(body, "Rationale");
237
+ const resolvedAt = extractBoldField(body, "Resolved");
238
+
239
+ if (!text || !timestamp) continue;
240
+
241
+ const status = (statusRaw === "resolved" || statusRaw === "triaged")
242
+ ? statusRaw
243
+ : "pending";
244
+
245
+ entries.push({
246
+ id,
247
+ text,
248
+ timestamp,
249
+ status,
250
+ ...(classification && VALID_CLASSIFICATIONS.includes(classification) ? { classification } : {}),
251
+ ...(resolution ? { resolution } : {}),
252
+ ...(rationale ? { rationale } : {}),
253
+ ...(resolvedAt ? { resolvedAt } : {}),
254
+ });
255
+ }
256
+
257
+ return entries;
258
+ }
259
+
260
+ /**
261
+ * Extract value from a bold-prefixed line like "**Key:** Value".
262
+ * Local copy of the pattern from files.ts to keep this module self-contained.
263
+ */
264
+ function extractBoldField(text: string, key: string): string | null {
265
+ const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, "m");
266
+ const match = regex.exec(text);
267
+ return match ? match[1].trim() : null;
268
+ }
269
+
270
+ function escapeRegex(s: string): string {
271
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
272
+ }
273
+
274
+ // ─── Triage Output Parser ─────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Parse LLM triage output into TriageResult array.
278
+ *
279
+ * Handles:
280
+ * - Clean JSON array
281
+ * - JSON wrapped in fenced code block (```json ... ```)
282
+ * - JSON with leading/trailing prose
283
+ * - Single object (not array) — wraps in array
284
+ * - Malformed JSON — returns empty array (caller should fall back to note)
285
+ * - Partial results — valid entries are kept, invalid skipped
286
+ */
287
+ export function parseTriageOutput(llmResponse: string): TriageResult[] {
288
+ if (!llmResponse || !llmResponse.trim()) return [];
289
+
290
+ // Try to extract JSON from fenced code blocks first
291
+ const fenced = llmResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
292
+ const jsonStr = fenced ? fenced[1] : extractJsonSubstring(llmResponse);
293
+
294
+ if (!jsonStr) return [];
295
+
296
+ try {
297
+ const parsed = JSON.parse(jsonStr);
298
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
299
+ return arr
300
+ .filter(isValidTriageResult)
301
+ .map(normalizeTriageResult);
302
+ } catch {
303
+ return [];
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Try to find a JSON array or object substring in prose text.
309
+ * Looks for the first [ or { and finds its matching bracket.
310
+ */
311
+ function extractJsonSubstring(text: string): string | null {
312
+ // Find first [ or {
313
+ const arrStart = text.indexOf("[");
314
+ const objStart = text.indexOf("{");
315
+
316
+ let start: number;
317
+ let openChar: string;
318
+ let closeChar: string;
319
+
320
+ if (arrStart === -1 && objStart === -1) return null;
321
+ if (arrStart === -1) {
322
+ start = objStart;
323
+ openChar = "{";
324
+ closeChar = "}";
325
+ } else if (objStart === -1) {
326
+ start = arrStart;
327
+ openChar = "[";
328
+ closeChar = "]";
329
+ } else {
330
+ start = Math.min(arrStart, objStart);
331
+ openChar = start === arrStart ? "[" : "{";
332
+ closeChar = start === arrStart ? "]" : "}";
333
+ }
334
+
335
+ // Find matching bracket
336
+ let depth = 0;
337
+ let inString = false;
338
+ let escape = false;
339
+
340
+ for (let i = start; i < text.length; i++) {
341
+ const ch = text[i];
342
+ if (escape) {
343
+ escape = false;
344
+ continue;
345
+ }
346
+ if (ch === "\\") {
347
+ escape = true;
348
+ continue;
349
+ }
350
+ if (ch === '"') {
351
+ inString = !inString;
352
+ continue;
353
+ }
354
+ if (inString) continue;
355
+ if (ch === openChar) depth++;
356
+ if (ch === closeChar) depth--;
357
+ if (depth === 0) {
358
+ return text.slice(start, i + 1);
359
+ }
360
+ }
361
+
362
+ return null;
363
+ }
364
+
365
+ function isValidTriageResult(obj: unknown): boolean {
366
+ if (!obj || typeof obj !== "object") return false;
367
+ const o = obj as Record<string, unknown>;
368
+ return (
369
+ typeof o.captureId === "string" &&
370
+ typeof o.classification === "string" &&
371
+ VALID_CLASSIFICATIONS.includes(o.classification) &&
372
+ typeof o.rationale === "string"
373
+ );
374
+ }
375
+
376
+ function normalizeTriageResult(obj: Record<string, unknown>): TriageResult {
377
+ return {
378
+ captureId: obj.captureId as string,
379
+ classification: obj.classification as Classification,
380
+ rationale: obj.rationale as string,
381
+ ...(Array.isArray(obj.affectedFiles) ? { affectedFiles: obj.affectedFiles as string[] } : {}),
382
+ ...(typeof obj.targetSlice === "string" ? { targetSlice: obj.targetSlice } : {}),
383
+ };
384
+ }
@@ -11,9 +11,11 @@ import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { deriveState } from "./state.js";
13
13
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
14
+ import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
14
15
  import { showQueue, showDiscuss } from "./guided-flow.js";
15
16
  import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
16
17
  import { resolveProjectRoot } from "./worktree.js";
18
+ import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
17
19
  import {
18
20
  getGlobalGSDPreferencesPath,
19
21
  getLegacyGlobalGSDPreferencesPath,
@@ -64,10 +66,11 @@ function projectRoot(): string {
64
66
 
65
67
  export function registerGSDCommand(pi: ExtensionAPI): void {
66
68
  pi.registerCommand("gsd", {
67
- description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
69
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
68
70
  getArgumentCompletions: (prefix: string) => {
69
71
  const subcommands = [
70
- "next", "auto", "stop", "pause", "status", "queue", "discuss",
72
+ "next", "auto", "stop", "pause", "status", "visualize", "queue", "discuss",
73
+ "capture", "triage",
71
74
  "history", "undo", "skip", "export", "cleanup", "prefs",
72
75
  "config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge",
73
76
  ];
@@ -163,6 +166,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
163
166
  return;
164
167
  }
165
168
 
169
+ if (trimmed === "visualize") {
170
+ await handleVisualize(ctx);
171
+ return;
172
+ }
173
+
166
174
  if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
167
175
  await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
168
176
  return;
@@ -259,6 +267,16 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
259
267
  return;
260
268
  }
261
269
 
270
+ if (trimmed.startsWith("capture ") || trimmed === "capture") {
271
+ await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx);
272
+ return;
273
+ }
274
+
275
+ if (trimmed === "triage") {
276
+ await handleTriage(ctx, pi, process.cwd());
277
+ return;
278
+ }
279
+
262
280
  if (trimmed === "config") {
263
281
  await handleConfig(ctx);
264
282
  return;
@@ -306,7 +324,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
306
324
  }
307
325
 
308
326
  ctx.ui.notify(
309
- `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
327
+ `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
310
328
  "warning",
311
329
  );
312
330
  },
@@ -344,6 +362,28 @@ export async function fireStatusViaCommand(
344
362
  await handleStatus(ctx as ExtensionCommandContext);
345
363
  }
346
364
 
365
+ async function handleVisualize(ctx: ExtensionCommandContext): Promise<void> {
366
+ if (!ctx.hasUI) {
367
+ ctx.ui.notify("Visualizer requires an interactive terminal.", "warning");
368
+ return;
369
+ }
370
+
371
+ await ctx.ui.custom<void>(
372
+ (tui, theme, _kb, done) => {
373
+ return new GSDVisualizerOverlay(tui, theme, () => done());
374
+ },
375
+ {
376
+ overlay: true,
377
+ overlayOptions: {
378
+ width: "80%",
379
+ minWidth: 80,
380
+ maxHeight: "90%",
381
+ anchor: "center",
382
+ },
383
+ },
384
+ );
385
+ }
386
+
347
387
  async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
348
388
  const trimmed = args.trim();
349
389
 
@@ -1195,6 +1235,102 @@ async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Prom
1195
1235
  ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success");
1196
1236
  }
1197
1237
 
1238
+ // ─── Capture Command ──────────────────────────────────────────────────────────
1239
+
1240
+ /**
1241
+ * Handle `/gsd capture "..."` — fire-and-forget thought capture.
1242
+ * Appends to `.gsd/CAPTURES.md` without interrupting auto-mode.
1243
+ * Works in all modes: auto running, paused, stopped, no project.
1244
+ */
1245
+ async function handleCapture(args: string, ctx: ExtensionCommandContext): Promise<void> {
1246
+ // Strip surrounding quotes from the argument
1247
+ let text = args.trim();
1248
+ if (!text) {
1249
+ ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning");
1250
+ return;
1251
+ }
1252
+ // Remove wrapping quotes (single or double)
1253
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
1254
+ text = text.slice(1, -1);
1255
+ }
1256
+ if (!text) {
1257
+ ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning");
1258
+ return;
1259
+ }
1260
+
1261
+ const basePath = process.cwd();
1262
+
1263
+ // Ensure .gsd/ exists — capture should work even without a milestone
1264
+ const gsdDir = join(basePath, ".gsd");
1265
+ if (!existsSync(gsdDir)) {
1266
+ mkdirSync(gsdDir, { recursive: true });
1267
+ }
1268
+
1269
+ const id = appendCapture(basePath, text);
1270
+ ctx.ui.notify(`Captured: ${id} — "${text.length > 60 ? text.slice(0, 57) + "..." : text}"`, "info");
1271
+ }
1272
+
1273
+ // ─── Triage Command ───────────────────────────────────────────────────────────
1274
+
1275
+ /**
1276
+ * Handle `/gsd triage` — manually trigger triage of pending captures.
1277
+ * Dispatches the triage prompt to the LLM for classification.
1278
+ * Triage result handling (confirmation UI) is wired in T03.
1279
+ */
1280
+ async function handleTriage(ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string): Promise<void> {
1281
+ if (!hasPendingCaptures(basePath)) {
1282
+ ctx.ui.notify("No pending captures to triage.", "info");
1283
+ return;
1284
+ }
1285
+
1286
+ const pending = loadPendingCaptures(basePath);
1287
+ ctx.ui.notify(`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, "info");
1288
+
1289
+ // Build context for the triage prompt
1290
+ const state = await deriveState(basePath);
1291
+ let currentPlan = "";
1292
+ let roadmapContext = "";
1293
+
1294
+ if (state.activeMilestone && state.activeSlice) {
1295
+ const { resolveSliceFile, resolveMilestoneFile } = await import("./paths.js");
1296
+ const planFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "PLAN");
1297
+ if (planFile) {
1298
+ const { loadFile: load } = await import("./files.js");
1299
+ currentPlan = (await load(planFile)) ?? "";
1300
+ }
1301
+ const roadmapFile = resolveMilestoneFile(basePath, state.activeMilestone.id, "ROADMAP");
1302
+ if (roadmapFile) {
1303
+ const { loadFile: load } = await import("./files.js");
1304
+ roadmapContext = (await load(roadmapFile)) ?? "";
1305
+ }
1306
+ }
1307
+
1308
+ // Format pending captures for the prompt
1309
+ const capturesList = pending.map(c =>
1310
+ `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`
1311
+ ).join("\n");
1312
+
1313
+ // Dispatch triage prompt
1314
+ const { loadPrompt } = await import("./prompt-loader.js");
1315
+ const prompt = loadPrompt("triage-captures", {
1316
+ pendingCaptures: capturesList,
1317
+ currentPlan: currentPlan || "(no active slice plan)",
1318
+ roadmapContext: roadmapContext || "(no active roadmap)",
1319
+ });
1320
+
1321
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
1322
+ const workflow = readFileSync(workflowPath, "utf-8");
1323
+
1324
+ pi.sendMessage(
1325
+ {
1326
+ customType: "gsd-triage",
1327
+ content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`,
1328
+ display: false,
1329
+ },
1330
+ { triggerTurn: true },
1331
+ );
1332
+ }
1333
+
1198
1334
  async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
1199
1335
  const basePath = process.cwd();
1200
1336
  const state = await deriveState(basePath);