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.
- package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/dist/resources/extensions/gsd/auto.ts +276 -19
- package/dist/resources/extensions/gsd/captures.ts +384 -0
- package/dist/resources/extensions/gsd/commands.ts +139 -3
- package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/dist/resources/extensions/gsd/metrics.ts +48 -0
- package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/dist/resources/extensions/gsd/model-router.ts +256 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +73 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/dist/resources/extensions/remote-questions/format.ts +12 -6
- package/dist/resources/extensions/remote-questions/manager.ts +8 -0
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/src/resources/extensions/gsd/auto.ts +276 -19
- package/src/resources/extensions/gsd/captures.ts +384 -0
- package/src/resources/extensions/gsd/commands.ts +139 -3
- package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/src/resources/extensions/gsd/metrics.ts +48 -0
- package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/src/resources/extensions/gsd/model-router.ts +256 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +73 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/src/resources/extensions/gsd/triage-ui.ts +175 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/src/resources/extensions/remote-questions/format.ts +12 -6
- 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);
|