From e0a309f5b569c4d93d6ebd60189b2a02a39abae7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 09:54:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(M004):=20mid-execution=20flexibility=20?= =?UTF-8?q?=E2=80=94=20capture,=20triage,=20and=20redirect=20(#512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solo developers can fire-and-forget thoughts during auto-mode execution via /gsd capture. The system triages accumulated captures at natural seams between tasks, classifies their impact into five types (quick-task, inject, defer, replan, note), and proposes appropriate action with user confirmation for plan-modifying resolutions. Pipeline: capture → triage → confirm → resolve → resume - /gsd capture appends to .gsd/CAPTURES.md (worktree-aware) - Triage fires automatically between tasks in handleAgentEnd - Five resolution types: inline quick task, inject task into plan, defer for reassess, trigger replan with context, acknowledge as note - Dashboard overlay shows pending capture count badge - Capture context injected into replan-slice and reassess-roadmap prompts - Parse failure falls back to note — pipeline never blocks New modules: captures.ts, triage-ui.ts, triage-resolution.ts New prompt: triage-captures.md 52 tests across 3 test files, all passing Requirements R045-R051 validated Closes #505 chore: pre-merge cleanup — remove dead code, single-read dashboard optimization - Remove processTriageResults() and associated types (dead code, superseded by inline resolution in auto.ts dispatch loop) - Add countPendingCaptures() for single-read regex count on dashboard hot path (replaces two-phase hasPendingCaptures + loadPendingCaptures) - Update triage-dispatch tests to match new implementation --- .../extensions/gsd/auto-dashboard.ts | 2 + src/resources/extensions/gsd/auto-prompts.ts | 30 ++ src/resources/extensions/gsd/auto.ts | 114 +++++ src/resources/extensions/gsd/captures.ts | 384 +++++++++++++++ src/resources/extensions/gsd/commands.ts | 112 ++++- .../extensions/gsd/dashboard-overlay.ts | 10 + .../extensions/gsd/post-unit-hooks.ts | 3 +- .../gsd/prompts/reassess-roadmap.md | 6 + .../extensions/gsd/prompts/replan-slice.md | 8 + .../extensions/gsd/prompts/triage-captures.md | 62 +++ .../extensions/gsd/tests/captures.test.ts | 438 ++++++++++++++++++ .../gsd/tests/triage-dispatch.test.ts | 224 +++++++++ .../gsd/tests/triage-resolution.test.ts | 215 +++++++++ .../extensions/gsd/triage-resolution.ts | 200 ++++++++ src/resources/extensions/gsd/triage-ui.ts | 175 +++++++ 15 files changed, 1980 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/gsd/captures.ts create mode 100644 src/resources/extensions/gsd/prompts/triage-captures.md create mode 100644 src/resources/extensions/gsd/tests/captures.test.ts create mode 100644 src/resources/extensions/gsd/tests/triage-dispatch.test.ts create mode 100644 src/resources/extensions/gsd/tests/triage-resolution.test.ts create mode 100644 src/resources/extensions/gsd/triage-resolution.ts create mode 100644 src/resources/extensions/gsd/triage-ui.ts diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index c0031ff13..18ad2aa35 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -39,6 +39,8 @@ export interface AutoDashboardData { projectedRemainingCost?: number; /** Whether token profile has been auto-downgraded due to budget prediction */ profileDowngraded?: boolean; + /** Number of pending captures awaiting triage (0 if none or file missing) */ + pendingCaptureCount: number; } // ─── Unit Description Helpers ───────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 8b5a46da2..7baa56541 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -777,6 +777,20 @@ export async function buildReplanSlicePrompt( const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`; + // Build capture context for replan prompt (captures that triggered this replan) + let captureContext = "(none)"; + try { + const { loadReplanCaptures } = await import("./triage-resolution.js"); + const replanCaptures = loadReplanCaptures(base); + if (replanCaptures.length > 0) { + captureContext = replanCaptures.map(c => + `- **${c.id}**: "${c.text}" — ${c.rationale ?? "no rationale"}` + ).join("\n"); + } + } catch { + // Non-fatal — captures module may not be available + } + return loadPrompt("replan-slice", { workingDirectory: base, milestoneId: mid, @@ -787,6 +801,7 @@ export async function buildReplanSlicePrompt( blockerTaskId, inlinedContext, replanPath, + captureContext, }); } @@ -849,6 +864,20 @@ export async function buildReassessRoadmapPrompt( const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); + // Build deferred captures context for reassess prompt + let deferredCaptures = "(none)"; + try { + const { loadDeferredCaptures } = await import("./triage-resolution.js"); + const deferred = loadDeferredCaptures(base); + if (deferred.length > 0) { + deferredCaptures = deferred.map(c => + `- **${c.id}**: "${c.text}" — ${c.rationale ?? "deferred during triage"}` + ).join("\n"); + } + } catch { + // Non-fatal — captures module may not be available + } + return loadPrompt("reassess-roadmap", { workingDirectory: base, milestoneId: mid, @@ -858,6 +887,7 @@ export async function buildReassessRoadmapPrompt( completedSliceSummaryPath: summaryRel, assessmentPath, inlinedContext, + deferredCaptures, }); } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index fc51a7c19..1964a215c 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -19,6 +19,7 @@ import type { import { deriveState, invalidateStateCache } from "./state.js"; import type { BudgetEnforcementMode, GSDState } from "./types.js"; import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js"; +import { loadPrompt } from "./prompt-loader.js"; export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { @@ -132,6 +133,7 @@ import { deregisterSigtermHandler as _deregisterSigtermHandler, detectWorkingTreeActivity, } from "./auto-supervisor.js"; +import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js"; // ─── State ──────────────────────────────────────────────────────────────────── @@ -307,6 +309,15 @@ export { type AutoDashboardData } from "./auto-dashboard.js"; export function getAutoDashboardData(): AutoDashboardData { const ledger = getLedger(); const totals = ledger ? getProjectTotals(ledger.units) : null; + // Pending capture count — lazy check, non-fatal + let pendingCaptureCount = 0; + try { + if (basePath) { + pendingCaptureCount = countPendingCaptures(basePath); + } + } catch { + // Non-fatal — captures module may not be loaded + } return { active, paused, @@ -318,6 +329,7 @@ export function getAutoDashboardData(): AutoDashboardData { basePath, totalCost: totals?.cost ?? 0, totalTokens: totals?.tokens.total ?? 0, + pendingCaptureCount, }; } @@ -1116,6 +1128,108 @@ export async function handleAgentEnd( } } + // ── Triage check: dispatch triage unit if pending captures exist ────────── + // Fires after hooks complete, before normal dispatch. Follows the same + // early-dispatch-and-return pattern as hooks and fix-merge. + // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage), + // hook units (hooks run before triage conceptually). + if ( + !stepMode && + currentUnit && + !currentUnit.type.startsWith("hook/") && + currentUnit.type !== "triage-captures" && + currentUnit.type !== "quick-task" + ) { + try { + if (hasPendingCaptures(basePath)) { + const pending = loadPendingCaptures(basePath); + if (pending.length > 0) { + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id; + const sid = state.activeSlice?.id; + + if (mid && sid) { + // Build triage prompt with current context + let currentPlan = ""; + let roadmapContext = ""; + const planFile = resolveSliceFile(basePath, mid, sid, "PLAN"); + if (planFile) currentPlan = (await loadFile(planFile)) ?? ""; + const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); + if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? ""; + + const capturesList = pending.map(c => + `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` + ).join("\n"); + + const prompt = loadPrompt("triage-captures", { + pendingCaptures: capturesList, + currentPlan: currentPlan || "(no active slice plan)", + roadmapContext: roadmapContext || "(no active roadmap)", + }); + + ctx.ui.notify( + `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, + "info", + ); + + // Close out previous unit metrics + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + + // Dispatch triage as a new unit (early-dispatch-and-return) + const triageUnitType = "triage-captures"; + const triageUnitId = `${mid}/${sid}/triage`; + const triageStartedAt = Date.now(); + currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt }; + writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: triageStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + updateProgressWidget(ctx, triageUnitType, triageUnitId, state); + + const result = await cmdCtx!.newSession(); + if (result.cancelled) { + await stopAuto(ctx, pi); + return; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile); + + // Start unit timeout for triage (use same supervisor config as hooks) + clearUnitTimeout(); + const supervisor = resolveAutoSupervisorConfig(); + const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000; + unitTimeoutHandle = setTimeout(async () => { + unitTimeoutHandle = null; + if (!active) return; + ctx.ui.notify( + `Triage unit exceeded timeout. Pausing auto-mode.`, + "warning", + ); + await pauseAuto(ctx, pi); + }, triageTimeoutMs); + + if (!active) return; + pi.sendMessage( + { customType: "gsd-auto", content: prompt, display: verbose }, + { triggerTurn: true }, + ); + return; // handleAgentEnd will fire again when triage session completes + } + } + } + } catch { + // Triage check failure is non-fatal — proceed to normal dispatch + } + } + // In step mode, pause and show a wizard instead of immediately dispatching if (stepMode) { await showStepWizard(ctx, pi); diff --git a/src/resources/extensions/gsd/captures.ts b/src/resources/extensions/gsd/captures.ts new file mode 100644 index 000000000..1c49adce5 --- /dev/null +++ b/src/resources/extensions/gsd/captures.ts @@ -0,0 +1,384 @@ +/** + * GSD Captures — Fire-and-forget thought capture with triage classification + * + * Append-only capture file at `.gsd/CAPTURES.md`. Each capture is an H3 section + * with bold metadata fields, parseable by the same patterns used in files.ts. + * + * Worktree-aware: captures always resolve to the original project root's + * `.gsd/CAPTURES.md`, not the worktree's local `.gsd/`. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; +import { randomUUID } from "node:crypto"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type Classification = "quick-task" | "inject" | "defer" | "replan" | "note"; + +export interface CaptureEntry { + id: string; + text: string; + timestamp: string; + status: "pending" | "triaged" | "resolved"; + classification?: Classification; + resolution?: string; + rationale?: string; + resolvedAt?: string; +} + +export interface TriageResult { + captureId: string; + classification: Classification; + rationale: string; + affectedFiles?: string[]; + targetSlice?: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const CAPTURES_FILENAME = "CAPTURES.md"; +const VALID_CLASSIFICATIONS: readonly string[] = [ + "quick-task", "inject", "defer", "replan", "note", +]; + +// ─── Path Resolution ────────────────────────────────────────────────────────── + +/** + * Resolve the path to CAPTURES.md, aware of worktree context. + * + * In worktree-isolated mode, basePath is `.gsd/worktrees//`. + * Captures must resolve to the *original* project root's `.gsd/CAPTURES.md`, + * not the worktree-local `.gsd/`. This ensures all captures go to one file + * regardless of which worktree the agent is running in. + * + * Detection: if basePath contains `/.gsd/worktrees/`, walk up to the + * directory that contains `.gsd/worktrees/` — that's the project root. + */ +export function resolveCapturesPath(basePath: string): string { + const resolved = resolve(basePath); + const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`; + const idx = resolved.indexOf(worktreeMarker); + if (idx !== -1) { + // basePath is inside a worktree — resolve to project root + const projectRoot = resolved.slice(0, idx); + return join(projectRoot, ".gsd", CAPTURES_FILENAME); + } + return join(gsdRoot(basePath), CAPTURES_FILENAME); +} + +// ─── File I/O ───────────────────────────────────────────────────────────────── + +/** + * Append a new capture entry to CAPTURES.md. + * Creates `.gsd/` and the file if they don't exist. + * Returns the generated capture ID. + */ +export function appendCapture(basePath: string, text: string): string { + const filePath = resolveCapturesPath(basePath); + const dir = join(filePath, ".."); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const id = `CAP-${randomUUID().slice(0, 8)}`; + const timestamp = new Date().toISOString(); + + const entry = [ + `### ${id}`, + `**Text:** ${text}`, + `**Captured:** ${timestamp}`, + `**Status:** pending`, + "", + ].join("\n"); + + if (existsSync(filePath)) { + const existing = readFileSync(filePath, "utf-8"); + writeFileSync(filePath, existing.trimEnd() + "\n\n" + entry, "utf-8"); + } else { + const header = `# Captures\n\n`; + writeFileSync(filePath, header + entry, "utf-8"); + } + + return id; +} + +/** + * Parse all capture entries from CAPTURES.md. + * Returns entries in file order (oldest first). + */ +export function loadAllCaptures(basePath: string): CaptureEntry[] { + const filePath = resolveCapturesPath(basePath); + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, "utf-8"); + return parseCapturesContent(content); +} + +/** + * Load only pending (unresolved) captures. + */ +export function loadPendingCaptures(basePath: string): CaptureEntry[] { + return loadAllCaptures(basePath).filter(c => c.status === "pending"); +} + +/** + * Fast check for pending captures without full parse. + * Reads the file and scans for `**Status:** pending` via regex. + * Returns false if the file doesn't exist. + */ +export function hasPendingCaptures(basePath: string): boolean { + const filePath = resolveCapturesPath(basePath); + if (!existsSync(filePath)) return false; + try { + const content = readFileSync(filePath, "utf-8"); + return /\*\*Status:\*\*\s*pending/i.test(content); + } catch { + return false; + } +} + +/** + * Count pending captures without full parse — single file read. + * Uses regex to count `**Status:** pending` occurrences. + * Returns 0 if file doesn't exist or on error. + */ +export function countPendingCaptures(basePath: string): number { + const filePath = resolveCapturesPath(basePath); + if (!existsSync(filePath)) return 0; + try { + const content = readFileSync(filePath, "utf-8"); + const matches = content.match(/\*\*Status:\*\*\s*pending/gi); + return matches ? matches.length : 0; + } catch { + return 0; + } +} + +/** + * Mark a capture as resolved with classification and rationale. + * Rewrites the entry in place, preserving other entries. + */ +export function markCaptureResolved( + basePath: string, + captureId: string, + classification: Classification, + resolution: string, + rationale: string, +): void { + const filePath = resolveCapturesPath(basePath); + if (!existsSync(filePath)) return; + + const content = readFileSync(filePath, "utf-8"); + const resolvedAt = new Date().toISOString(); + + // Find the section for this capture ID and rewrite its fields + const sectionRegex = new RegExp( + `(### ${escapeRegex(captureId)}\\n(?:(?!### ).)*?)(?=### |$)`, + "s", + ); + const match = sectionRegex.exec(content); + if (!match) return; + + let section = match[1]; + + // Update Status field + section = section.replace( + /\*\*Status:\*\*\s*.+/, + `**Status:** resolved`, + ); + + // Append classification, resolution, rationale, and timestamp if not present + const newFields = [ + `**Classification:** ${classification}`, + `**Resolution:** ${resolution}`, + `**Rationale:** ${rationale}`, + `**Resolved:** ${resolvedAt}`, + ]; + + // Remove any existing classification/resolution/rationale/resolved fields + // (in case of re-triage) + section = section.replace(/\*\*Classification:\*\*\s*.+\n?/g, ""); + section = section.replace(/\*\*Resolution:\*\*\s*.+\n?/g, ""); + section = section.replace(/\*\*Rationale:\*\*\s*.+\n?/g, ""); + section = section.replace(/\*\*Resolved:\*\*\s*.+\n?/g, ""); + + // Add new fields after Status line + section = section.trimEnd() + "\n" + newFields.join("\n") + "\n"; + + const updated = content.replace(sectionRegex, section); + writeFileSync(filePath, updated, "utf-8"); +} + +// ─── Parser ─────────────────────────────────────────────────────────────────── + +/** + * Parse CAPTURES.md content into CaptureEntry array. + */ +function parseCapturesContent(content: string): CaptureEntry[] { + const entries: CaptureEntry[] = []; + + // Split on H3 headings + const sections = content.split(/^### /m).slice(1); // skip content before first H3 + + for (const section of sections) { + const lines = section.split("\n"); + const id = lines[0]?.trim(); + if (!id) continue; + + const body = lines.slice(1).join("\n"); + const text = extractBoldField(body, "Text"); + const timestamp = extractBoldField(body, "Captured"); + const statusRaw = extractBoldField(body, "Status"); + const classification = extractBoldField(body, "Classification") as Classification | null; + const resolution = extractBoldField(body, "Resolution"); + const rationale = extractBoldField(body, "Rationale"); + const resolvedAt = extractBoldField(body, "Resolved"); + + if (!text || !timestamp) continue; + + const status = (statusRaw === "resolved" || statusRaw === "triaged") + ? statusRaw + : "pending"; + + entries.push({ + id, + text, + timestamp, + status, + ...(classification && VALID_CLASSIFICATIONS.includes(classification) ? { classification } : {}), + ...(resolution ? { resolution } : {}), + ...(rationale ? { rationale } : {}), + ...(resolvedAt ? { resolvedAt } : {}), + }); + } + + return entries; +} + +/** + * Extract value from a bold-prefixed line like "**Key:** Value". + * Local copy of the pattern from files.ts to keep this module self-contained. + */ +function extractBoldField(text: string, key: string): string | null { + const regex = new RegExp(`^\\*\\*${escapeRegex(key)}:\\*\\*\\s*(.+)$`, "m"); + const match = regex.exec(text); + return match ? match[1].trim() : null; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// ─── Triage Output Parser ───────────────────────────────────────────────────── + +/** + * Parse LLM triage output into TriageResult array. + * + * Handles: + * - Clean JSON array + * - JSON wrapped in fenced code block (```json ... ```) + * - JSON with leading/trailing prose + * - Single object (not array) — wraps in array + * - Malformed JSON — returns empty array (caller should fall back to note) + * - Partial results — valid entries are kept, invalid skipped + */ +export function parseTriageOutput(llmResponse: string): TriageResult[] { + if (!llmResponse || !llmResponse.trim()) return []; + + // Try to extract JSON from fenced code blocks first + const fenced = llmResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); + const jsonStr = fenced ? fenced[1] : extractJsonSubstring(llmResponse); + + if (!jsonStr) return []; + + try { + const parsed = JSON.parse(jsonStr); + const arr = Array.isArray(parsed) ? parsed : [parsed]; + return arr + .filter(isValidTriageResult) + .map(normalizeTriageResult); + } catch { + return []; + } +} + +/** + * Try to find a JSON array or object substring in prose text. + * Looks for the first [ or { and finds its matching bracket. + */ +function extractJsonSubstring(text: string): string | null { + // Find first [ or { + const arrStart = text.indexOf("["); + const objStart = text.indexOf("{"); + + let start: number; + let openChar: string; + let closeChar: string; + + if (arrStart === -1 && objStart === -1) return null; + if (arrStart === -1) { + start = objStart; + openChar = "{"; + closeChar = "}"; + } else if (objStart === -1) { + start = arrStart; + openChar = "["; + closeChar = "]"; + } else { + start = Math.min(arrStart, objStart); + openChar = start === arrStart ? "[" : "{"; + closeChar = start === arrStart ? "]" : "}"; + } + + // Find matching bracket + let depth = 0; + let inString = false; + let escape = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === openChar) depth++; + if (ch === closeChar) depth--; + if (depth === 0) { + return text.slice(start, i + 1); + } + } + + return null; +} + +function isValidTriageResult(obj: unknown): boolean { + if (!obj || typeof obj !== "object") return false; + const o = obj as Record; + return ( + typeof o.captureId === "string" && + typeof o.classification === "string" && + VALID_CLASSIFICATIONS.includes(o.classification) && + typeof o.rationale === "string" + ); +} + +function normalizeTriageResult(obj: Record): TriageResult { + return { + captureId: obj.captureId as string, + classification: obj.classification as Classification, + rationale: obj.rationale as string, + ...(Array.isArray(obj.affectedFiles) ? { affectedFiles: obj.affectedFiles as string[] } : {}), + ...(typeof obj.targetSlice === "string" ? { targetSlice: obj.targetSlice } : {}), + }; +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 7e4007e3b..ad01c7b65 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -14,6 +14,7 @@ import { GSDDashboardOverlay } from "./dashboard-overlay.js"; import { showQueue, showDiscuss } from "./guided-flow.js"; import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js"; import { resolveProjectRoot } from "./worktree.js"; +import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; import { getGlobalGSDPreferencesPath, getLegacyGlobalGSDPreferencesPath, @@ -64,10 +65,11 @@ function projectRoot(): string { export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - 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", + description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge", getArgumentCompletions: (prefix: string) => { const subcommands = [ "next", "auto", "stop", "pause", "status", "queue", "discuss", + "capture", "triage", "history", "undo", "skip", "export", "cleanup", "prefs", "config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge", ]; @@ -259,6 +261,16 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed.startsWith("capture ") || trimmed === "capture") { + await handleCapture(trimmed.replace(/^capture\s*/, "").trim(), ctx); + return; + } + + if (trimmed === "triage") { + await handleTriage(ctx, pi, process.cwd()); + return; + } + if (trimmed === "config") { await handleConfig(ctx); return; @@ -306,7 +318,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer |knowledge .`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|capture|triage|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer |knowledge .`, "warning", ); }, @@ -1195,6 +1207,102 @@ async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Prom ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success"); } +// ─── Capture Command ────────────────────────────────────────────────────────── + +/** + * Handle `/gsd capture "..."` — fire-and-forget thought capture. + * Appends to `.gsd/CAPTURES.md` without interrupting auto-mode. + * Works in all modes: auto running, paused, stopped, no project. + */ +async function handleCapture(args: string, ctx: ExtensionCommandContext): Promise { + // Strip surrounding quotes from the argument + let text = args.trim(); + if (!text) { + ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning"); + return; + } + // Remove wrapping quotes (single or double) + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { + text = text.slice(1, -1); + } + if (!text) { + ctx.ui.notify('Usage: /gsd capture "your thought here"', "warning"); + return; + } + + const basePath = process.cwd(); + + // Ensure .gsd/ exists — capture should work even without a milestone + const gsdDir = join(basePath, ".gsd"); + if (!existsSync(gsdDir)) { + mkdirSync(gsdDir, { recursive: true }); + } + + const id = appendCapture(basePath, text); + ctx.ui.notify(`Captured: ${id} — "${text.length > 60 ? text.slice(0, 57) + "..." : text}"`, "info"); +} + +// ─── Triage Command ─────────────────────────────────────────────────────────── + +/** + * Handle `/gsd triage` — manually trigger triage of pending captures. + * Dispatches the triage prompt to the LLM for classification. + * Triage result handling (confirmation UI) is wired in T03. + */ +async function handleTriage(ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string): Promise { + if (!hasPendingCaptures(basePath)) { + ctx.ui.notify("No pending captures to triage.", "info"); + return; + } + + const pending = loadPendingCaptures(basePath); + ctx.ui.notify(`Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`, "info"); + + // Build context for the triage prompt + const state = await deriveState(basePath); + let currentPlan = ""; + let roadmapContext = ""; + + if (state.activeMilestone && state.activeSlice) { + const { resolveSliceFile, resolveMilestoneFile } = await import("./paths.js"); + const planFile = resolveSliceFile(basePath, state.activeMilestone.id, state.activeSlice.id, "PLAN"); + if (planFile) { + const { loadFile: load } = await import("./files.js"); + currentPlan = (await load(planFile)) ?? ""; + } + const roadmapFile = resolveMilestoneFile(basePath, state.activeMilestone.id, "ROADMAP"); + if (roadmapFile) { + const { loadFile: load } = await import("./files.js"); + roadmapContext = (await load(roadmapFile)) ?? ""; + } + } + + // Format pending captures for the prompt + const capturesList = pending.map(c => + `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})` + ).join("\n"); + + // Dispatch triage prompt + const { loadPrompt } = await import("./prompt-loader.js"); + const prompt = loadPrompt("triage-captures", { + pendingCaptures: capturesList, + currentPlan: currentPlan || "(no active slice plan)", + roadmapContext: roadmapContext || "(no active roadmap)", + }); + + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + const workflow = readFileSync(workflowPath, "utf-8"); + + pi.sendMessage( + { + customType: "gsd-triage", + content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`, + display: false, + }, + { triggerTurn: true }, + ); +} + async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { const basePath = process.cwd(); const state = await deriveState(basePath); diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 410f3db96..30e7a657b 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -39,6 +39,9 @@ function unitLabel(type: string): string { case "execute-task": return "Execute"; case "complete-slice": return "Complete"; case "reassess-roadmap": return "Reassess"; + case "triage-captures": return "Triage"; + case "quick-task": return "Quick Task"; + case "replan-slice": return "Replan"; default: return type; } } @@ -345,6 +348,13 @@ export class GSDDashboardOverlay { lines.push(blank()); } + // Pending captures badge — only shown when captures are waiting for triage + if (this.dashData.pendingCaptureCount > 0) { + const count = this.dashData.pendingCaptureCount; + lines.push(row(th.fg("warning", `📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`))); + lines.push(blank()); + } + if (this.loading) { lines.push(centered(th.fg("dim", "Loading dashboard…"))); return lines; diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index c264d275f..7d09f05df 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -60,7 +60,8 @@ export function checkPostUnitHooks( } // Don't trigger hooks for other hook units (prevent hook-on-hook chains) - if (completedUnitType.startsWith("hook/")) return null; + // Don't trigger hooks for triage units (prevent hook-on-triage chains) + if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures") return null; // Check if any hooks are configured for this unit type const hooks = resolvePostUnitHooks().filter(h => diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md index 933e6a580..4f9cf3628 100644 --- a/src/resources/extensions/gsd/prompts/reassess-roadmap.md +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -16,6 +16,12 @@ All relevant context has been preloaded below — the current roadmap, completed {{inlinedContext}} +## Deferred Captures + +The following user thoughts were captured during execution and deferred to future slices during triage. Consider whether any should influence the remaining roadmap: + +{{deferredCaptures}} + If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during reassessment, without relaxing required verification or artifact rules. Then assess whether the remaining roadmap still makes sense given what was just built. diff --git a/src/resources/extensions/gsd/prompts/replan-slice.md b/src/resources/extensions/gsd/prompts/replan-slice.md index 0548b9d08..91111553f 100644 --- a/src/resources/extensions/gsd/prompts/replan-slice.md +++ b/src/resources/extensions/gsd/prompts/replan-slice.md @@ -12,6 +12,14 @@ All relevant context has been preloaded below — the roadmap, current slice pla {{inlinedContext}} +## Capture Context + +The following user-captured thoughts triggered or informed this replan: + +{{captureContext}} + +Consider these captures when rewriting the remaining tasks — they represent the user's real-time insights about what needs to change. + ## Hard Constraints - **Do NOT renumber or remove completed tasks.** All `[x]` tasks and their IDs must remain exactly as they are in the plan. diff --git a/src/resources/extensions/gsd/prompts/triage-captures.md b/src/resources/extensions/gsd/prompts/triage-captures.md new file mode 100644 index 000000000..60dd5ca95 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/triage-captures.md @@ -0,0 +1,62 @@ +You are triaging user-captured thoughts during a GSD session. + +## UNIT: Triage Captures + +The user captured thoughts during execution using `/gsd capture`. Your job is to classify each capture, present your proposals, get user confirmation, and update CAPTURES.md with the final classifications. + +## Pending Captures + +{{pendingCaptures}} + +## Current Slice Plan + +{{currentPlan}} + +## Current Roadmap + +{{roadmapContext}} + +## Classification Criteria + +For each capture, classify it as one of: + +- **quick-task**: Small, self-contained, no downstream impact. Can be done in minutes without modifying the plan. Examples: fix a typo, add a missing import, tweak a config value. +- **inject**: Belongs in the current slice but wasn't planned. Needs a new task added to the slice plan. Examples: add error handling to a module being built, add a missing test case for current work. +- **defer**: Belongs in a future slice or milestone. Not urgent for current work. Examples: performance optimization, feature that depends on unbuilt infrastructure, nice-to-have enhancement. +- **replan**: Changes the shape of remaining work in the current slice. Existing incomplete tasks may need rewriting. Examples: "the approach is wrong, we need to use X instead of Y", discovering a fundamental constraint. +- **note**: Informational only. No action needed right now. Good context for future reference. Examples: "remember that the API has a rate limit", observations about code quality. + +## Decision Guidelines + +- Prefer **quick-task** when the work is clearly small and self-contained. +- Prefer **inject** over **replan** when only a new task is needed, not rewriting existing ones. +- Prefer **defer** over **inject** when the work doesn't belong in the current slice's scope. +- Use **replan** only when remaining incomplete tasks need to change — not just for adding work. +- Use **note** for observations that don't require action. +- When unsure between quick-task and inject, consider: will this take more than 10 minutes? If yes, inject. + +## Instructions + +1. **Classify** each pending capture using the criteria above. + +2. **Present** your classifications to the user using `ask_user_questions`. For each capture, show: + - The capture text + - Your proposed classification + - Your rationale + - If applicable, which files would be affected + + For captures classified as **note** or **defer**, auto-confirm without asking — these are low-impact. + For captures classified as **quick-task**, **inject**, or **replan**, ask the user to confirm or choose a different classification. + +3. **Update** `.gsd/CAPTURES.md` — for each capture, update its section with the confirmed classification: + - Change `**Status:** pending` to `**Status:** resolved` + - Add `**Classification:** ` + - Add `**Resolution:** ` + - Add `**Rationale:** ` + - Add `**Resolved:** ` + +4. **Summarize** what was triaged: how many captures, what classifications were assigned, and what actions are pending (e.g., "2 quick-tasks ready for execution, 1 deferred to S03"). + +**Important:** Do NOT execute any resolutions. Only classify and update CAPTURES.md. Resolution execution happens separately (in auto-mode dispatch or manually by the user). + +When done, say: "Triage complete." diff --git a/src/resources/extensions/gsd/tests/captures.test.ts b/src/resources/extensions/gsd/tests/captures.test.ts new file mode 100644 index 000000000..219667929 --- /dev/null +++ b/src/resources/extensions/gsd/tests/captures.test.ts @@ -0,0 +1,438 @@ +/** + * Unit tests for GSD Captures — file I/O, parsing, and worktree path resolution. + * + * Exercises the boundary contract that S02 (auto-mode dispatch) depends on: + * - appendCapture creates/appends entries to CAPTURES.md + * - loadAllCaptures / loadPendingCaptures parse and filter correctly + * - hasPendingCaptures does fast regex check without full parse + * - markCaptureResolved updates entry in place + * - resolveCapturesPath handles worktree paths + * - parseTriageOutput handles valid, malformed, and partial JSON + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + appendCapture, + loadAllCaptures, + loadPendingCaptures, + hasPendingCaptures, + markCaptureResolved, + resolveCapturesPath, + parseTriageOutput, +} from "../captures.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// ─── appendCapture ──────────────────────────────────────────────────────────── + +test("captures: appendCapture creates CAPTURES.md on first call", () => { + const tmp = makeTempDir("cap-create"); + try { + const id = appendCapture(tmp, "first thought"); + assert.ok(id.startsWith("CAP-"), "ID should start with CAP-"); + assert.ok( + existsSync(join(tmp, ".gsd", "CAPTURES.md")), + "CAPTURES.md should exist", + ); + const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); + assert.ok(content.includes("# Captures"), "should have header"); + assert.ok(content.includes(`### ${id}`), "should have entry heading"); + assert.ok( + content.includes("**Text:** first thought"), + "should have text field", + ); + assert.ok( + content.includes("**Status:** pending"), + "should have pending status", + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: appendCapture appends to existing file", () => { + const tmp = makeTempDir("cap-append"); + try { + const id1 = appendCapture(tmp, "thought one"); + const id2 = appendCapture(tmp, "thought two"); + assert.notStrictEqual(id1, id2, "IDs should be unique"); + + const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); + assert.ok(content.includes(`### ${id1}`), "should have first entry"); + assert.ok(content.includes(`### ${id2}`), "should have second entry"); + assert.ok( + content.includes("**Text:** thought one"), + "should have first text", + ); + assert.ok( + content.includes("**Text:** thought two"), + "should have second text", + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── loadAllCaptures / loadPendingCaptures ──────────────────────────────────── + +test("captures: loadAllCaptures parses entries correctly", () => { + const tmp = makeTempDir("cap-load"); + try { + appendCapture(tmp, "alpha"); + appendCapture(tmp, "beta"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 2, "should have 2 entries"); + assert.strictEqual(all[0].text, "alpha"); + assert.strictEqual(all[1].text, "beta"); + assert.strictEqual(all[0].status, "pending"); + assert.strictEqual(all[1].status, "pending"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: loadAllCaptures returns empty array when no file", () => { + const tmp = makeTempDir("cap-nofile"); + try { + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: loadPendingCaptures filters resolved entries", () => { + const tmp = makeTempDir("cap-pending"); + try { + const id1 = appendCapture(tmp, "pending one"); + appendCapture(tmp, "pending two"); + + // Resolve the first one + markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note"); + + const pending = loadPendingCaptures(tmp); + assert.strictEqual(pending.length, 1, "should have 1 pending"); + assert.strictEqual(pending[0].text, "pending two"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 2, "all should still have 2"); + assert.strictEqual(all[0].status, "resolved"); + assert.strictEqual(all[1].status, "pending"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── hasPendingCaptures ─────────────────────────────────────────────────────── + +test("captures: hasPendingCaptures returns false when no file", () => { + const tmp = makeTempDir("cap-has-nofile"); + try { + assert.strictEqual(hasPendingCaptures(tmp), false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: hasPendingCaptures returns true with pending entries", () => { + const tmp = makeTempDir("cap-has-true"); + try { + appendCapture(tmp, "something"); + assert.strictEqual(hasPendingCaptures(tmp), true); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: hasPendingCaptures returns false when all resolved", () => { + const tmp = makeTempDir("cap-has-false"); + try { + const id = appendCapture(tmp, "will resolve"); + markCaptureResolved(tmp, id, "note", "done", "resolved it"); + assert.strictEqual(hasPendingCaptures(tmp), false); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── markCaptureResolved ────────────────────────────────────────────────────── + +test("captures: markCaptureResolved updates entry in place", () => { + const tmp = makeTempDir("cap-resolve"); + try { + const id1 = appendCapture(tmp, "keep pending"); + const id2 = appendCapture(tmp, "will resolve"); + appendCapture(tmp, "also pending"); + + markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 3, "should still have 3 entries"); + + const resolved = all.find((c) => c.id === id2)!; + assert.strictEqual(resolved.status, "resolved"); + assert.strictEqual(resolved.classification, "quick-task"); + assert.strictEqual(resolved.resolution, "executed inline"); + assert.strictEqual(resolved.rationale, "small fix"); + assert.ok(resolved.resolvedAt, "should have resolved timestamp"); + + // Others should be unaffected + const kept = all.find((c) => c.id === id1)!; + assert.strictEqual(kept.status, "pending"); + assert.strictEqual(kept.classification, undefined); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── resolveCapturesPath ────────────────────────────────────────────────────── + +test("captures: resolveCapturesPath returns .gsd/CAPTURES.md for normal path", () => { + const base = join(tmpdir(), "cap-test-project"); + const result = resolveCapturesPath(base); + assert.ok(result.endsWith(join(".gsd", "CAPTURES.md"))); + assert.ok(result.startsWith(base)); +}); + +test("captures: resolveCapturesPath resolves worktree path to project root", () => { + const base = join(tmpdir(), "cap-test-project"); + const worktreePath = join(base, ".gsd", "worktrees", "M004"); + const result = resolveCapturesPath(worktreePath); + assert.ok( + result.endsWith(join(".gsd", "CAPTURES.md")), + `should end with .gsd/CAPTURES.md, got: ${result}`, + ); + // Should resolve to project root, not worktree root + assert.ok( + !result.includes("worktrees"), + `should not contain worktrees, got: ${result}`, + ); + assert.ok( + result.startsWith(base), + `should start with ${base}, got: ${result}`, + ); +}); + +// ─── parseTriageOutput ──────────────────────────────────────────────────────── + +test("triage: parseTriageOutput handles valid JSON array", () => { + const input = JSON.stringify([ + { + captureId: "CAP-abc123", + classification: "quick-task", + rationale: "Small fix", + affectedFiles: ["src/foo.ts"], + }, + { + captureId: "CAP-def456", + classification: "defer", + rationale: "Future work", + targetSlice: "S03", + }, + ]); + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 2); + assert.strictEqual(results[0].captureId, "CAP-abc123"); + assert.strictEqual(results[0].classification, "quick-task"); + assert.deepStrictEqual(results[0].affectedFiles, ["src/foo.ts"]); + assert.strictEqual(results[1].classification, "defer"); + assert.strictEqual(results[1].targetSlice, "S03"); +}); + +test("triage: parseTriageOutput handles fenced code block", () => { + const input = `Here are my classifications: + +\`\`\`json +[ + { + "captureId": "CAP-aaa", + "classification": "note", + "rationale": "Just informational" + } +] +\`\`\` + +That's my analysis.`; + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].captureId, "CAP-aaa"); + assert.strictEqual(results[0].classification, "note"); +}); + +test("triage: parseTriageOutput handles JSON with leading/trailing prose", () => { + const input = `I've analyzed the captures. Here are my results: +[{"captureId": "CAP-bbb", "classification": "inject", "rationale": "Needs a new task"}] +Let me know if you need changes.`; + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].classification, "inject"); +}); + +test("triage: parseTriageOutput returns empty array on malformed JSON", () => { + const results = parseTriageOutput("this is not json at all"); + assert.strictEqual(results.length, 0); +}); + +test("triage: parseTriageOutput returns empty array on empty input", () => { + assert.strictEqual(parseTriageOutput("").length, 0); + assert.strictEqual(parseTriageOutput(" ").length, 0); +}); + +test("triage: parseTriageOutput filters invalid entries from partial results", () => { + const input = JSON.stringify([ + { + captureId: "CAP-good", + classification: "note", + rationale: "Valid entry", + }, + { + captureId: "CAP-bad", + classification: "invalid-type", + rationale: "Bad classification", + }, + { + // Missing required fields + captureId: "CAP-incomplete", + }, + { + captureId: "CAP-also-good", + classification: "replan", + rationale: "Needs restructuring", + }, + ]); + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 2, "should keep only valid entries"); + assert.strictEqual(results[0].captureId, "CAP-good"); + assert.strictEqual(results[1].captureId, "CAP-also-good"); +}); + +test("triage: parseTriageOutput wraps single object in array", () => { + const input = JSON.stringify({ + captureId: "CAP-single", + classification: "quick-task", + rationale: "Just one", + }); + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].captureId, "CAP-single"); +}); + +test("triage: parseTriageOutput handles all five classification types", () => { + const types = [ + "quick-task", + "inject", + "defer", + "replan", + "note", + ] as const; + + const input = JSON.stringify( + types.map((t, i) => ({ + captureId: `CAP-${i}`, + classification: t, + rationale: `Type: ${t}`, + })), + ); + + const results = parseTriageOutput(input); + assert.strictEqual(results.length, 5); + for (let i = 0; i < types.length; i++) { + assert.strictEqual(results[i].classification, types[i]); + } +}); + +// ─── Edge Cases ─────────────────────────────────────────────────────────────── + +test("captures: appendCapture handles special characters in text", () => { + const tmp = makeTempDir("cap-special"); + try { + const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`'); + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes"); + assert.ok(all[0].text.includes("**bold**"), "should preserve bold"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: markCaptureResolved is no-op for non-existent ID", () => { + const tmp = makeTempDir("cap-noop"); + try { + appendCapture(tmp, "real capture"); + // Should not throw + markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test"); + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.strictEqual(all[0].status, "pending", "original should be unchanged"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: markCaptureResolved is no-op when no file exists", () => { + const tmp = makeTempDir("cap-nofile-resolve"); + try { + // Should not throw + markCaptureResolved(tmp, "CAP-abc", "note", "test", "test"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("captures: re-resolving a capture overwrites previous resolution", () => { + const tmp = makeTempDir("cap-reresolve"); + try { + const id = appendCapture(tmp, "will re-resolve"); + markCaptureResolved(tmp, id, "note", "first resolution", "first rationale"); + markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.strictEqual(all[0].classification, "inject", "should have updated classification"); + assert.strictEqual(all[0].resolution, "second resolution"); + assert.strictEqual(all[0].rationale, "second rationale"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => { + const input = JSON.stringify([ + { + captureId: "CAP-files", + classification: "quick-task", + rationale: "Has files", + affectedFiles: ["src/a.ts", "src/b.ts"], + }, + { + captureId: "CAP-target", + classification: "defer", + rationale: "Has target", + targetSlice: "S04", + }, + ]); + + const results = parseTriageOutput(input); + assert.deepStrictEqual(results[0].affectedFiles, ["src/a.ts", "src/b.ts"]); + assert.strictEqual(results[0].targetSlice, undefined); + assert.strictEqual(results[1].targetSlice, "S04"); + assert.strictEqual(results[1].affectedFiles, undefined); +}); diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts new file mode 100644 index 000000000..df8d05dc1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -0,0 +1,224 @@ +/** + * Triage dispatch ordering contract tests. + * + * These tests verify structural invariants of the triage integration + * by inspecting the actual source code of auto.ts and post-unit-hooks.ts. + * Full behavioral testing requires the @gsd/pi-coding-agent runtime. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const autoPath = join(__dirname, "..", "auto.ts"); +const hooksPath = join(__dirname, "..", "post-unit-hooks.ts"); +const autoPromptsPath = join(__dirname, "..", "auto-prompts.ts"); + +const autoSrc = readFileSync(autoPath, "utf-8"); +const hooksSrc = readFileSync(hooksPath, "utf-8"); +const autoPromptsSrc = (() => { try { return readFileSync(autoPromptsPath, "utf-8"); } catch { return autoSrc; } })(); + +// ─── Hook exclusion ────────────────────────────────────────────────────────── + +test("dispatch: triage-captures excluded from post-unit hook triggering", () => { + // post-unit-hooks.ts must return null for triage-captures unit type + assert.ok( + hooksSrc.includes('"triage-captures"'), + "post-unit-hooks.ts should reference triage-captures", + ); + assert.ok( + hooksSrc.includes('completedUnitType === "triage-captures"'), + "should check for triage-captures in the hook exclusion guard", + ); +}); + +// ─── Triage check placement ────────────────────────────────────────────────── + +test("dispatch: triage check appears after hook section and before stepMode check", () => { + const hookRetryIndex = autoSrc.indexOf("isRetryPending()"); + // Find the triage check in handleAgentEnd (not in getAutoDashboardData) + const triageCheckIndex = autoSrc.indexOf("Triage check: dispatch triage unit"); + const stepModeIndex = autoSrc.indexOf("In step mode, pause and show a wizard"); + + assert.ok(hookRetryIndex > 0, "hook retry check should exist"); + assert.ok(triageCheckIndex > 0, "triage check block should exist"); + assert.ok(stepModeIndex > 0, "step mode check should exist"); + + assert.ok( + triageCheckIndex > hookRetryIndex, + "triage check should come after hook retry check", + ); + assert.ok( + triageCheckIndex < stepModeIndex, + "triage check should come before stepMode check", + ); +}); + +// ─── Guard conditions ──────────────────────────────────────────────────────── + +test("dispatch: triage check guards against step mode", () => { + // The triage block should check !stepMode + const triageBlock = autoSrc.slice( + autoSrc.indexOf("Triage check: dispatch triage unit"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + triageBlock.includes("!stepMode"), + "triage block should guard against step mode", + ); +}); + +test("dispatch: triage check guards against hook unit types", () => { + const triageBlock = autoSrc.slice( + autoSrc.indexOf("Triage check: dispatch triage unit"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + triageBlock.includes('!currentUnit.type.startsWith("hook/")'), + "triage block should not fire for hook units", + ); +}); + +test("dispatch: triage check guards against triage-on-triage", () => { + const triageBlock = autoSrc.slice( + autoSrc.indexOf("Triage check: dispatch triage unit"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + triageBlock.includes('currentUnit.type !== "triage-captures"'), + "triage block should not fire for triage units", + ); +}); + +test("dispatch: triage check guards against quick-task triggering triage", () => { + const triageBlock = autoSrc.slice( + autoSrc.indexOf("Triage check: dispatch triage unit"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + triageBlock.includes('currentUnit.type !== "quick-task"'), + "triage block should not fire for quick-task units", + ); +}); + +test("dispatch: triage dispatch uses early-return pattern", () => { + const triageBlock = autoSrc.slice( + autoSrc.indexOf("Triage check: dispatch triage unit"), + autoSrc.indexOf("In step mode, pause and show a wizard"), + ); + assert.ok( + triageBlock.includes("return; // handleAgentEnd will fire again"), + "triage dispatch should return after sending message", + ); +}); + +test("dispatch: triage imports hasPendingCaptures and loadPendingCaptures", () => { + assert.ok( + autoSrc.includes('hasPendingCaptures, loadPendingCaptures, countPendingCaptures') && + autoSrc.includes('from "./captures.js"'), + "auto.ts should import capture functions including countPendingCaptures", + ); +}); + +// ─── Prompt integration ────────────────────────────────────────────────────── + +test("dispatch: replan prompt builder loads capture context", () => { + const src = autoPromptsSrc; + assert.ok( + src.includes("loadReplanCaptures"), + "buildReplanSlicePrompt should load replan captures", + ); + assert.ok( + src.includes("captureContext"), + "buildReplanSlicePrompt should pass captureContext to template", + ); +}); + +test("dispatch: reassess prompt builder loads deferred captures", () => { + const src = autoPromptsSrc; + assert.ok( + src.includes("loadDeferredCaptures"), + "buildReassessRoadmapPrompt should load deferred captures", + ); + assert.ok( + src.includes("deferredCaptures"), + "buildReassessRoadmapPrompt should pass deferredCaptures to template", + ); +}); + +// ─── Prompt templates ──────────────────────────────────────────────────────── + +test("dispatch: replan prompt template includes captureContext variable", () => { + const promptPath = join(__dirname, "..", "prompts", "replan-slice.md"); + const prompt = readFileSync(promptPath, "utf-8"); + assert.ok( + prompt.includes("{{captureContext}}"), + "replan-slice.md should include {{captureContext}}", + ); +}); + +test("dispatch: reassess prompt template includes deferredCaptures variable", () => { + const promptPath = join(__dirname, "..", "prompts", "reassess-roadmap.md"); + const prompt = readFileSync(promptPath, "utf-8"); + assert.ok( + prompt.includes("{{deferredCaptures}}"), + "reassess-roadmap.md should include {{deferredCaptures}}", + ); +}); + +test("dispatch: triage prompt template exists and has classification criteria", () => { + const promptPath = join(__dirname, "..", "prompts", "triage-captures.md"); + const prompt = readFileSync(promptPath, "utf-8"); + assert.ok(prompt.includes("quick-task"), "should have quick-task classification"); + assert.ok(prompt.includes("inject"), "should have inject classification"); + assert.ok(prompt.includes("defer"), "should have defer classification"); + assert.ok(prompt.includes("replan"), "should have replan classification"); + assert.ok(prompt.includes("note"), "should have note classification"); + assert.ok(prompt.includes("{{pendingCaptures}}"), "should have pending captures variable"); +}); + +// ─── Dashboard integration ─────────────────────────────────────────────────── + +test("dashboard: AutoDashboardData includes pendingCaptureCount field", () => { + assert.ok( + autoSrc.includes("pendingCaptureCount"), + "auto.ts should have pendingCaptureCount in AutoDashboardData", + ); +}); + +test("dashboard: getAutoDashboardData computes pendingCaptureCount", () => { + assert.ok( + autoSrc.includes("pendingCaptureCount = countPendingCaptures") || + autoSrc.includes("pendingCaptureCount = countPendingCaptures(basePath)"), + "getAutoDashboardData should compute pendingCaptureCount from countPendingCaptures (single-read)", + ); +}); + +test("dashboard: overlay renders pending captures badge", () => { + const overlayPath = join(__dirname, "..", "dashboard-overlay.ts"); + const overlaySrc = readFileSync(overlayPath, "utf-8"); + assert.ok( + overlaySrc.includes("pendingCaptureCount"), + "dashboard-overlay.ts should reference pendingCaptureCount", + ); + assert.ok( + overlaySrc.includes("pending capture"), + "dashboard-overlay.ts should show pending captures text", + ); +}); + +test("dashboard: overlay labels triage-captures and quick-task unit types", () => { + const overlayPath = join(__dirname, "..", "dashboard-overlay.ts"); + const overlaySrc = readFileSync(overlayPath, "utf-8"); + assert.ok( + overlaySrc.includes('"triage-captures"'), + "unitLabel should handle triage-captures", + ); + assert.ok( + overlaySrc.includes('"quick-task"'), + "unitLabel should handle quick-task", + ); +}); diff --git a/src/resources/extensions/gsd/tests/triage-resolution.test.ts b/src/resources/extensions/gsd/tests/triage-resolution.test.ts new file mode 100644 index 000000000..7c62025c2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/triage-resolution.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for GSD Triage Resolution — resolution execution and file overlap detection. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { appendCapture, markCaptureResolved, loadAllCaptures } from "../captures.ts"; +// Import only the functions that don't depend on @gsd/pi-coding-agent +// (triage-ui.ts imports next-action-ui.ts which imports the unavailable package) +import { executeInject, executeReplan, detectFileOverlap, loadDeferredCaptures, loadReplanCaptures, buildQuickTaskPrompt } from "../triage-resolution.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function setupPlanFile(tmp: string, mid: string, sid: string, content: string): string { + const planDir = join(tmp, ".gsd", "milestones", mid, "slices", sid); + mkdirSync(planDir, { recursive: true }); + const planPath = join(planDir, `${sid}-PLAN.md`); + writeFileSync(planPath, content, "utf-8"); + return planPath; +} + +const SAMPLE_PLAN = `# S01: Test Slice + +**Goal:** Test +**Demo:** Test + +## Must-Haves + +- Something works + +## Tasks + +- [x] **T01: First task** \`est:1h\` + - Why: Setup + - Files: \`src/foo.ts\`, \`src/bar.ts\` + - Do: Build it + - Done when: Tests pass + +- [ ] **T02: Second task** \`est:1h\` + - Why: Feature + - Files: \`src/baz.ts\`, \`src/qux.ts\` + - Do: Build it + - Done when: Tests pass + +- [ ] **T03: Third task** \`est:30m\` + - Why: Polish + - Files: \`src/qux.ts\`, \`src/config.ts\` + - Do: Build it + - Done when: Tests pass + +## Files Likely Touched + +- \`src/foo.ts\` +- \`src/bar.ts\` +`; + +// ─── executeInject ──────────────────────────────────────────────────────────── + +test("resolution: executeInject appends a new task to the plan", () => { + const tmp = makeTempDir("res-inject"); + try { + const planPath = setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const captureId = appendCapture(tmp, "add retry logic"); + const captures = loadAllCaptures(tmp); + const capture = captures[0]; + + const newId = executeInject(tmp, "M001", "S01", capture); + + assert.strictEqual(newId, "T04", "should be T04 (next after T03)"); + + const updated = readFileSync(planPath, "utf-8"); + assert.ok(updated.includes("**T04:"), "should have T04 in plan"); + assert.ok(updated.includes(capture.text), "should include capture text"); + assert.ok(updated.includes("## Files Likely Touched"), "should preserve files section"); + + // T04 should appear before Files Likely Touched + const t04Pos = updated.indexOf("**T04:"); + const filesPos = updated.indexOf("## Files Likely Touched"); + assert.ok(t04Pos < filesPos, "T04 should be before Files section"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: executeInject returns null when plan doesn't exist", () => { + const tmp = makeTempDir("res-inject-noplan"); + try { + const captureId = appendCapture(tmp, "some task"); + const captures = loadAllCaptures(tmp); + const result = executeInject(tmp, "M001", "S01", captures[0]); + assert.strictEqual(result, null); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── executeReplan ──────────────────────────────────────────────────────────── + +test("resolution: executeReplan writes REPLAN-TRIGGER.md", () => { + const tmp = makeTempDir("res-replan"); + try { + setupPlanFile(tmp, "M001", "S01", SAMPLE_PLAN); + const captureId = appendCapture(tmp, "approach is wrong, need different strategy"); + const captures = loadAllCaptures(tmp); + const capture = captures[0]; + + const result = executeReplan(tmp, "M001", "S01", capture); + assert.strictEqual(result, true); + + const triggerPath = join( + tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-REPLAN-TRIGGER.md", + ); + assert.ok(existsSync(triggerPath), "trigger file should exist"); + + const content = readFileSync(triggerPath, "utf-8"); + assert.ok(content.includes(capture.id), "should include capture ID"); + assert.ok(content.includes(capture.text), "should include capture text"); + assert.ok(content.includes("# Replan Trigger"), "should have header"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── detectFileOverlap ─────────────────────────────────────────────────────── + +test("resolution: detectFileOverlap finds overlapping incomplete tasks", () => { + const overlaps = detectFileOverlap(["src/qux.ts"], SAMPLE_PLAN); + assert.deepStrictEqual(overlaps, ["T02", "T03"]); +}); + +test("resolution: detectFileOverlap ignores completed tasks", () => { + // T01 is [x] and uses src/foo.ts — should NOT be returned + const overlaps = detectFileOverlap(["src/foo.ts"], SAMPLE_PLAN); + assert.deepStrictEqual(overlaps, []); +}); + +test("resolution: detectFileOverlap returns empty when no overlap", () => { + const overlaps = detectFileOverlap(["src/unrelated.ts"], SAMPLE_PLAN); + assert.deepStrictEqual(overlaps, []); +}); + +test("resolution: detectFileOverlap returns empty for empty affected files", () => { + assert.deepStrictEqual(detectFileOverlap([], SAMPLE_PLAN), []); +}); + +test("resolution: detectFileOverlap is case-insensitive", () => { + const overlaps = detectFileOverlap(["SRC/QUX.TS"], SAMPLE_PLAN); + assert.deepStrictEqual(overlaps, ["T02", "T03"]); +}); + +// ─── loadDeferredCaptures / loadReplanCaptures ─────────────────────────────── + +test("resolution: loadDeferredCaptures returns only deferred captures", () => { + const tmp = makeTempDir("res-deferred"); + try { + const id1 = appendCapture(tmp, "deferred one"); + const id2 = appendCapture(tmp, "note one"); + const id3 = appendCapture(tmp, "deferred two"); + + markCaptureResolved(tmp, id1, "defer", "deferred to S03", "future work"); + markCaptureResolved(tmp, id2, "note", "acknowledged", "just a note"); + markCaptureResolved(tmp, id3, "defer", "deferred to S04", "later"); + + const deferred = loadDeferredCaptures(tmp); + assert.strictEqual(deferred.length, 2); + assert.strictEqual(deferred[0].id, id1); + assert.strictEqual(deferred[1].id, id3); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("resolution: loadReplanCaptures returns only replan captures", () => { + const tmp = makeTempDir("res-replan-load"); + try { + const id1 = appendCapture(tmp, "needs replan"); + const id2 = appendCapture(tmp, "just a note"); + + markCaptureResolved(tmp, id1, "replan", "replan triggered", "approach changed"); + markCaptureResolved(tmp, id2, "note", "acknowledged", "info only"); + + const replans = loadReplanCaptures(tmp); + assert.strictEqual(replans.length, 1); + assert.strictEqual(replans[0].id, id1); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── buildQuickTaskPrompt ──────────────────────────────────────────────────── + +test("resolution: buildQuickTaskPrompt includes capture text and ID", () => { + const prompt = buildQuickTaskPrompt({ + id: "CAP-abc123", + text: "add retry logic to OAuth", + timestamp: "2026-03-15T20:00:00Z", + status: "resolved", + classification: "quick-task", + }); + + assert.ok(prompt.includes("CAP-abc123"), "should include capture ID"); + assert.ok(prompt.includes("add retry logic to OAuth"), "should include capture text"); + assert.ok(prompt.includes("Quick Task"), "should have Quick Task header"); + assert.ok(prompt.includes("Do NOT modify"), "should warn about plan files"); +}); diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts new file mode 100644 index 000000000..0d49c4c39 --- /dev/null +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -0,0 +1,200 @@ +/** + * GSD Triage Resolution — Execute triage classifications + * + * Provides resolution executors for each capture classification type: + * + * - inject: appends a new task to the current slice plan + * - replan: writes REPLAN-TRIGGER.md so next dispatchNextUnit enters replanning-slice + * - defer/note: query helpers for loading deferred/replan captures + * + * Also provides detectFileOverlap() for surfacing downstream impact on quick tasks. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Classification, CaptureEntry } from "./captures.js"; +import { + loadPendingCaptures, + loadAllCaptures, + markCaptureResolved, +} from "./captures.js"; + +// ─── Resolution Executors ───────────────────────────────────────────────────── + +/** + * Inject a new task into the current slice plan. + * Reads the plan, finds the highest task ID, appends a new task entry. + * Returns the new task ID, or null if injection failed. + */ +export function executeInject( + basePath: string, + mid: string, + sid: string, + capture: CaptureEntry, +): string | null { + try { + // Resolve the plan file path + const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`); + if (!existsSync(planPath)) return null; + + const content = readFileSync(planPath, "utf-8"); + + // Find the highest existing task ID + const taskMatches = [...content.matchAll(/- \[[ x]\] \*\*T(\d+):/g)]; + if (taskMatches.length === 0) return null; + + const maxId = Math.max(...taskMatches.map(m => parseInt(m[1], 10))); + const newId = `T${String(maxId + 1).padStart(2, "0")}`; + + // Build the new task entry + const newTask = [ + `- [ ] **${newId}: ${capture.text}** \`est:30m\``, + ` - Why: Injected from capture ${capture.id} during triage`, + ` - Do: ${capture.text}`, + ` - Done when: Capture intent fulfilled`, + ].join("\n"); + + // Find the last task entry and append after it + // Look for the "## Files Likely Touched" section as the boundary + const filesSection = content.indexOf("## Files Likely Touched"); + if (filesSection !== -1) { + const updated = content.slice(0, filesSection) + newTask + "\n\n" + content.slice(filesSection); + writeFileSync(planPath, updated, "utf-8"); + } else { + // No Files section — append at end + writeFileSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8"); + } + + return newId; + } catch { + return null; + } +} + +/** + * Trigger replanning by writing a REPLAN-TRIGGER.md marker file. + * The existing state.ts derivation detects this and sets phase to "replanning-slice". + * Returns true if the trigger was written successfully. + */ +export function executeReplan( + basePath: string, + mid: string, + sid: string, + capture: CaptureEntry, +): boolean { + try { + const triggerPath = join( + basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-REPLAN-TRIGGER.md`, + ); + const content = [ + `# Replan Trigger`, + ``, + `**Source:** Capture ${capture.id}`, + `**Capture:** ${capture.text}`, + `**Rationale:** ${capture.rationale ?? "User-initiated replan via capture triage"}`, + `**Triggered:** ${new Date().toISOString()}`, + ``, + `This file was created by the triage pipeline. The next dispatch cycle`, + `will detect it and enter the replanning-slice phase.`, + ].join("\n"); + + writeFileSync(triggerPath, content, "utf-8"); + return true; + } catch { + return false; + } +} + +// ─── File Overlap Detection ─────────────────────────────────────────────────── + +/** + * Detect file overlap between a capture's affected files and planned tasks. + * + * Parses the slice plan for task file references and returns task IDs + * whose files overlap with the capture's affected files. + * + * @param affectedFiles - Files the capture would touch + * @param planContent - Content of the slice plan.md + * @returns Array of task IDs (e.g., ["T03", "T04"]) whose files overlap + */ +export function detectFileOverlap( + affectedFiles: string[], + planContent: string, +): string[] { + if (!affectedFiles || affectedFiles.length === 0) return []; + + const overlappingTasks: string[] = []; + + // Normalize affected files for comparison + const normalizedAffected = new Set( + affectedFiles.map(f => f.replace(/^\.\//, "").toLowerCase()), + ); + + // Parse plan for incomplete tasks and their file references + const taskPattern = /- \[ \] \*\*(T\d+):[^*]*\*\*/g; + const tasks = [...planContent.matchAll(taskPattern)]; + + for (const taskMatch of tasks) { + const taskId = taskMatch[1]; + const taskStart = taskMatch.index!; + + // Find the end of this task (next task or end of section) + const nextTask = planContent.indexOf("- [", taskStart + 1); + const sectionEnd = planContent.indexOf("##", taskStart + 1); + const taskEnd = Math.min( + nextTask === -1 ? planContent.length : nextTask, + sectionEnd === -1 ? planContent.length : sectionEnd, + ); + + const taskContent = planContent.slice(taskStart, taskEnd); + + // Extract file references — look for backtick-quoted paths + const fileRefs = [...taskContent.matchAll(/`([^`]+\.[a-z]+)`/g)] + .map(m => m[1].replace(/^\.\//, "").toLowerCase()); + + // Check for overlap + const hasOverlap = fileRefs.some(f => normalizedAffected.has(f)); + if (hasOverlap) { + overlappingTasks.push(taskId); + } + } + + return overlappingTasks; +} + +/** + * Load deferred captures (classification === "defer") for injection into + * reassess-roadmap prompts. + */ +export function loadDeferredCaptures(basePath: string): CaptureEntry[] { + return loadAllCaptures(basePath).filter(c => c.classification === "defer"); +} + +/** + * Load replan-triggering captures for injection into replan-slice prompts. + */ +export function loadReplanCaptures(basePath: string): CaptureEntry[] { + return loadAllCaptures(basePath).filter(c => c.classification === "replan"); +} + +/** + * Build a quick-task execution prompt from a capture. + */ +export function buildQuickTaskPrompt(capture: CaptureEntry): string { + return [ + `You are executing a quick one-off task captured during a GSD auto-mode session.`, + ``, + `## Quick Task`, + ``, + `**Capture ID:** ${capture.id}`, + `**Task:** ${capture.text}`, + ``, + `## Instructions`, + ``, + `1. Execute this task as a small, self-contained change.`, + `2. Do NOT modify any \`.gsd/\` plan files — this is a one-off, not a planned task.`, + `3. Commit your changes with a descriptive message.`, + `4. Keep changes minimal and focused on the capture text.`, + `5. When done, say: "Quick task complete."`, + ].join("\n"); +} diff --git a/src/resources/extensions/gsd/triage-ui.ts b/src/resources/extensions/gsd/triage-ui.ts new file mode 100644 index 000000000..ce7473a0e --- /dev/null +++ b/src/resources/extensions/gsd/triage-ui.ts @@ -0,0 +1,175 @@ +/** + * GSD Triage UI — Confirmation flow for programmatic triage results + * + * Used by auto-mode dispatch (S02) when triage fires between tasks. + * For manual `/gsd triage`, the LLM session handles confirmation directly. + * + * This module provides `showTriageConfirmation` which presents each + * triage result to the user via `showNextAction` and returns the + * confirmed classifications. + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { showNextAction } from "../shared/next-action-ui.js"; +import type { CaptureEntry, Classification, TriageResult } from "./captures.js"; +import { markCaptureResolved } from "./captures.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ConfirmedTriage { + captureId: string; + classification: Classification; + rationale: string; + affectedFiles?: string[]; + targetSlice?: string; + userOverride: boolean; // true if user changed the proposed classification +} + +// ─── Classification Labels ──────────────────────────────────────────────────── + +const CLASSIFICATION_LABELS: Record = { + "quick-task": { + label: "Quick task", + description: "Execute as a one-off at the next seam — no plan modification.", + }, + "inject": { + label: "Inject into plan", + description: "Add a new task to the current slice plan.", + }, + "defer": { + label: "Defer", + description: "Move to a future slice or milestone — not urgent now.", + }, + "replan": { + label: "Replan slice", + description: "Remaining tasks need rewriting — triggers slice replan.", + }, + "note": { + label: "Note", + description: "Informational only — no action needed.", + }, +}; + +const ALL_CLASSIFICATIONS: Classification[] = [ + "quick-task", "inject", "defer", "replan", "note", +]; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Present triage results to the user for confirmation. + * + * For each capture: + * - note/defer: auto-confirm (no user interaction needed) + * - quick-task/inject/replan: show confirmation UI with proposed + alternatives + * + * Returns confirmed results with final classifications. + * Updates CAPTURES.md with resolved status. + * + * @param fileOverlaps - Map of captureId → list of planned task IDs whose files overlap + */ +export async function showTriageConfirmation( + ctx: ExtensionCommandContext, + triageResults: TriageResult[], + captures: CaptureEntry[], + basePath: string, + fileOverlaps?: Map, +): Promise { + const confirmed: ConfirmedTriage[] = []; + const captureMap = new Map(captures.map(c => [c.id, c])); + + for (const result of triageResults) { + const capture = captureMap.get(result.captureId); + if (!capture) continue; + + // Auto-confirm note and defer — low-impact, no plan modification + if (result.classification === "note" || result.classification === "defer") { + const resolution = result.classification === "note" + ? "acknowledged as note" + : `deferred${result.targetSlice ? ` to ${result.targetSlice}` : ""}`; + + markCaptureResolved( + basePath, + result.captureId, + result.classification, + resolution, + result.rationale, + ); + + confirmed.push({ + captureId: result.captureId, + classification: result.classification, + rationale: result.rationale, + affectedFiles: result.affectedFiles, + targetSlice: result.targetSlice, + userOverride: false, + }); + continue; + } + + // Build summary lines for the confirmation UI + const summary: string[] = [ + `"${capture.text}"`, + "", + `Proposed: **${CLASSIFICATION_LABELS[result.classification].label}** — ${result.rationale}`, + ]; + + // Add file overlap warning if present + const overlaps = fileOverlaps?.get(result.captureId); + if (overlaps && overlaps.length > 0) { + summary.push(""); + summary.push(`⚠ Touches files planned for ${overlaps.join(", ")} — consider inject or defer`); + } + + if (result.affectedFiles && result.affectedFiles.length > 0) { + summary.push(""); + summary.push(`Files: ${result.affectedFiles.join(", ")}`); + } + + // Build action options — proposed first (recommended), then alternatives + const proposed = result.classification; + const actions = ALL_CLASSIFICATIONS.map(cls => ({ + id: cls, + label: CLASSIFICATION_LABELS[cls].label, + description: CLASSIFICATION_LABELS[cls].description, + recommended: cls === proposed, + })); + + const choice = await showNextAction(ctx as any, { + title: `Triage: ${result.captureId}`, + summary, + actions, + notYetMessage: "Capture will remain pending for later triage.", + }); + + if (choice === "not_yet") { + // User skipped — leave capture pending + continue; + } + + const finalClassification = choice as Classification; + const userOverride = finalClassification !== proposed; + const resolution = userOverride + ? `user chose ${finalClassification} (was ${proposed})` + : `confirmed as ${finalClassification}`; + + markCaptureResolved( + basePath, + result.captureId, + finalClassification, + resolution, + userOverride ? `User override: ${result.rationale}` : result.rationale, + ); + + confirmed.push({ + captureId: result.captureId, + classification: finalClassification, + rationale: result.rationale, + affectedFiles: result.affectedFiles, + targetSlice: result.targetSlice, + userOverride, + }); + } + + return confirmed; +}