diff --git a/src/resources/extensions/gsd/commands-config.ts b/src/resources/extensions/gsd/commands-config.ts new file mode 100644 index 000000000..ec5a8b596 --- /dev/null +++ b/src/resources/extensions/gsd/commands-config.ts @@ -0,0 +1,102 @@ +/** + * GSD Config — Tool API key management. + * + * Contains: TOOL_KEYS, loadToolApiKeys, getConfigAuthStorage, handleConfig + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { AuthStorage } from "@gsd/pi-coding-agent"; +import { existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; + +/** + * Tool API key configurations. + * This is the source of truth for tool credentials - used by both the config wizard + * and session startup to load keys from auth.json into environment variables. + */ +export const TOOL_KEYS = [ + { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" }, + { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" }, + { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" }, + { id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" }, + { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" }, +] as const; + +/** + * Load tool API keys from auth.json into environment variables. + * Called at session startup to ensure tools have access to their credentials. + */ +export function loadToolApiKeys(): void { + try { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + if (!existsSync(authPath)) return; + + const auth = AuthStorage.create(authPath); + for (const tool of TOOL_KEYS) { + const cred = auth.get(tool.id); + if (cred && cred.type === "api_key" && cred.key && !process.env[tool.env]) { + process.env[tool.env] = cred.key; + } + } + } catch { + // Failed to load tool keys — ignore, they can still be set via env vars + } +} + +export function getConfigAuthStorage(): AuthStorage { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + mkdirSync(dirname(authPath), { recursive: true }); + return AuthStorage.create(authPath); +} + +export async function handleConfig(ctx: ExtensionCommandContext): Promise { + const auth = getConfigAuthStorage(); + + // Show current status + const statusLines = ["GSD Tool Configuration\n"]; + for (const tool of TOOL_KEYS) { + const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key; + statusLines.push(` ${hasKey ? "\u2713" : "\u2717"} ${tool.label}${hasKey ? "" : ` \u2014 get key at ${tool.hint}`}`); + } + ctx.ui.notify(statusLines.join("\n"), "info"); + + // Ask which tools to configure + const options = TOOL_KEYS.map(t => { + const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key; + return `${t.label} ${hasKey ? "(configured \u2713)" : "(not set)"}`; + }); + options.push("(done)"); + + let changed = false; + while (true) { + const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options); + if (!choice || typeof choice !== "string" || choice === "(done)") break; + + const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label)); + if (toolIdx === -1) break; + + const tool = TOOL_KEYS[toolIdx]; + const input = await ctx.ui.input( + `API key for ${tool.label} (${tool.hint}):`, + "paste your key here", + ); + + if (input !== null && input !== undefined) { + const key = input.trim(); + if (key) { + auth.set(tool.id, { type: "api_key", key }); + process.env[tool.env] = key; + ctx.ui.notify(`${tool.label} key saved and activated.`, "info"); + // Update option label + options[toolIdx] = `${tool.label} (configured \u2713)`; + changed = true; + } + } + } + + if (changed) { + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info"); + } +} diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts new file mode 100644 index 000000000..f9ea52b34 --- /dev/null +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -0,0 +1,402 @@ +/** + * GSD Command Handlers — fire-and-forget handlers that delegate to other modules. + * + * Contains: handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, + * handleRunHook, handleUpdate, handleSkillHealth + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { deriveState } from "./state.js"; +import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; +import { appendOverride, appendKnowledge } from "./files.js"; +import { + formatDoctorIssuesForPrompt, + formatDoctorReport, + runGSDDoctor, + selectDoctorScope, + filterDoctorIssues, +} from "./doctor.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { isAutoActive } from "./auto.js"; +import { resolveProjectRoot } from "./worktree.js"; +import { assertSafeDirectory } from "./validate-directory.js"; + +/** Resolve the effective project root, accounting for worktree paths. */ +function projectRoot(): string { + const root = resolveProjectRoot(process.cwd()); + assertSafeDirectory(root); + return root; +} + +function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + const workflow = readFileSync(workflowPath, "utf-8"); + const prompt = loadPrompt("doctor-heal", { + doctorSummary: reportText, + structuredIssues, + scopeLabel: scope ?? "active milestone / blocking scope", + doctorCommandSuffix: scope ? ` ${scope}` : "", + }); + + const content = `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${prompt}`; + + pi.sendMessage( + { customType: "gsd-doctor-heal", content, display: false }, + { triggerTurn: true }, + ); +} + +export async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const trimmed = args.trim(); + const parts = trimmed ? trimmed.split(/\s+/) : []; + const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor"; + const requestedScope = mode === "doctor" ? parts[0] : parts[1]; + const scope = await selectDoctorScope(projectRoot(), requestedScope); + const effectiveScope = mode === "audit" ? requestedScope : scope; + const report = await runGSDDoctor(projectRoot(), { + fix: mode === "fix" || mode === "heal", + scope: effectiveScope, + }); + + const reportText = formatDoctorReport(report, { + scope: effectiveScope, + includeWarnings: mode === "audit", + maxIssues: mode === "audit" ? 50 : 12, + title: mode === "audit" ? "GSD doctor audit." : mode === "heal" ? "GSD doctor heal prep." : undefined, + }); + + ctx.ui.notify(reportText, report.ok ? "info" : "warning"); + + if (mode === "heal") { + const unresolved = filterDoctorIssues(report.issues, { + scope: effectiveScope, + includeWarnings: true, + }); + const actionable = unresolved.filter(issue => issue.severity === "error" || issue.code === "all_tasks_done_missing_slice_uat" || issue.code === "slice_checked_missing_uat"); + if (actionable.length === 0) { + ctx.ui.notify("Doctor heal found nothing actionable to hand off to the LLM.", "info"); + return; + } + + const structuredIssues = formatDoctorIssuesForPrompt(actionable); + dispatchDoctorHeal(pi, effectiveScope, reportText, structuredIssues); + ctx.ui.notify(`Doctor heal dispatched ${actionable.length} issue(s) to the LLM.`, "info"); + } +} + +export async function handleSkillHealth(args: string, ctx: ExtensionCommandContext): Promise { + const { + generateSkillHealthReport, + formatSkillHealthReport, + formatSkillDetail, + } = await import("./skill-health.js"); + + const basePath = projectRoot(); + + // /gsd skill-health — detail view + if (args && !args.startsWith("--")) { + const detail = formatSkillDetail(basePath, args); + ctx.ui.notify(detail, "info"); + return; + } + + // Parse flags + const staleMatch = args.match(/--stale\s+(\d+)/); + const staleDays = staleMatch ? parseInt(staleMatch[1], 10) : undefined; + const decliningOnly = args.includes("--declining"); + + const report = generateSkillHealthReport(basePath, staleDays); + + if (decliningOnly) { + if (report.decliningSkills.length === 0) { + ctx.ui.notify("No skills flagged for declining performance.", "info"); + return; + } + const filtered = { + ...report, + skills: report.skills.filter(s => s.flagged), + }; + ctx.ui.notify(formatSkillHealthReport(filtered), "info"); + return; + } + + ctx.ui.notify(formatSkillHealthReport(report), "info"); +} + +export 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"); +} + +export 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: loadTriagePrompt } = await import("./prompt-loader.js"); + const prompt = loadTriagePrompt("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 }, + ); +} + +export async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const basePath = process.cwd(); + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id ?? "none"; + const sid = state.activeSlice?.id ?? "none"; + const tid = state.activeTask?.id ?? "none"; + const appliedAt = `${mid}/${sid}/${tid}`; + await appendOverride(basePath, change, appliedAt); + + if (isAutoActive()) { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.", + "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", + "", + "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info"); + } else { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md`.", + "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.", + "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info"); + } +} + +export async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Promise { + const parts = args.split(/\s+/); + const typeArg = parts[0]?.toLowerCase(); + + if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) { + ctx.ui.notify( + "Usage: /gsd knowledge \nExample: /gsd knowledge rule Use real DB for integration tests", + "warning", + ); + return; + } + + const entryText = parts.slice(1).join(" ").trim(); + if (!entryText) { + ctx.ui.notify(`Usage: /gsd knowledge ${typeArg} `, "warning"); + return; + } + + const type = typeArg as "rule" | "pattern" | "lesson"; + const basePath = process.cwd(); + const state = await deriveState(basePath); + const scope = state.activeMilestone?.id + ? `${state.activeMilestone.id}${state.activeSlice ? `/${state.activeSlice.id}` : ""}` + : "global"; + + await appendKnowledge(basePath, type, entryText, scope); + ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success"); +} + +export async function handleRunHook(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const parts = args.trim().split(/\s+/); + if (parts.length < 3) { + ctx.ui.notify(`Usage: /gsd run-hook + +Unit types: + execute-task - Task execution (unit-id: M001/S01/T01) + plan-slice - Slice planning (unit-id: M001/S01) + research-milestone - Milestone research (unit-id: M001) + complete-slice - Slice completion (unit-id: M001/S01) + complete-milestone - Milestone completion (unit-id: M001) + +Examples: + /gsd run-hook code-review execute-task M001/S01/T01 + /gsd run-hook lint-check plan-slice M001/S01`, "warning"); + return; + } + + const [hookName, unitType, unitId] = parts; + const basePath = projectRoot(); + + // Import the hook trigger function + const { triggerHookManually, formatHookStatus, getHookStatus } = await import("./post-unit-hooks.js"); + const { dispatchHookUnit } = await import("./auto.js"); + + // Check if the hook exists + const hooks = getHookStatus(); + const hookExists = hooks.some(h => h.name === hookName); + if (!hookExists) { + ctx.ui.notify(`Hook "${hookName}" not found. Configured hooks:\n${formatHookStatus()}`, "error"); + return; + } + + // Validate unit ID format + const unitIdPattern = /^M\d{3}\/S\d{2,3}\/T\d{2,3}$/; + if (!unitIdPattern.test(unitId)) { + ctx.ui.notify(`Invalid unit ID format: "${unitId}". Expected format: M004/S04/T03`, "warning"); + return; + } + + // Trigger the hook manually + const hookUnit = triggerHookManually(hookName, unitType, unitId, basePath); + if (!hookUnit) { + ctx.ui.notify(`Failed to trigger hook "${hookName}". The hook may be disabled or not configured for unit type "${unitType}".`, "error"); + return; + } + + ctx.ui.notify(`Manually triggering hook: ${hookName} for ${unitType} ${unitId}`, "info"); + + // Dispatch the hook unit directly, bypassing normal pre-dispatch hooks + const success = await dispatchHookUnit( + ctx, + pi, + hookName, + unitType, + unitId, + hookUnit.prompt, + hookUnit.model, + basePath, + ); + + if (!success) { + ctx.ui.notify("Failed to dispatch hook. Auto-mode may have been cancelled.", "error"); + } +} + +// ─── Self-update handler ──────────────────────────────────────────────────── + +function compareSemverLocal(a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const va = pa[i] || 0 + const vb = pb[i] || 0 + if (va > vb) return 1 + if (va < vb) return -1 + } + return 0 +} + +export async function handleUpdate(ctx: ExtensionCommandContext): Promise { + const { execSync } = await import("node:child_process"); + + const NPM_PACKAGE = "gsd-pi"; + const current = process.env.GSD_VERSION || "0.0.0"; + + ctx.ui.notify(`Current version: v${current}\nChecking npm registry...`, "info"); + + let latest: string; + try { + latest = execSync(`npm view ${NPM_PACKAGE} version`, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + ctx.ui.notify("Failed to reach npm registry. Check your network connection.", "error"); + return; + } + + if (compareSemverLocal(latest, current) <= 0) { + ctx.ui.notify(`Already up to date (v${current}).`, "info"); + return; + } + + ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info"); + + try { + execSync(`npm install -g ${NPM_PACKAGE}@latest`, { + stdio: ["ignore", "pipe", "ignore"], + }); + ctx.ui.notify( + `Updated to v${latest}. Restart your GSD session to use the new version.`, + "info", + ); + } catch { + ctx.ui.notify( + `Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`, + "error", + ); + } +} diff --git a/src/resources/extensions/gsd/commands-inspect.ts b/src/resources/extensions/gsd/commands-inspect.ts new file mode 100644 index 000000000..29b9b7746 --- /dev/null +++ b/src/resources/extensions/gsd/commands-inspect.ts @@ -0,0 +1,90 @@ +/** + * GSD Inspect — SQLite DB diagnostics. + * + * Contains: InspectData type, formatInspectOutput, handleInspect + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +export interface InspectData { + schemaVersion: number | null; + counts: { decisions: number; requirements: number; artifacts: number }; + recentDecisions: Array<{ id: string; decision: string; choice: string }>; + recentRequirements: Array<{ id: string; status: string; description: string }>; +} + +export function formatInspectOutput(data: InspectData): string { + const lines: string[] = []; + lines.push("=== GSD Database Inspect ==="); + lines.push(`Schema version: ${data.schemaVersion ?? "unknown"}`); + lines.push(""); + lines.push(`Decisions: ${data.counts.decisions}`); + lines.push(`Requirements: ${data.counts.requirements}`); + lines.push(`Artifacts: ${data.counts.artifacts}`); + + if (data.recentDecisions.length > 0) { + lines.push(""); + lines.push("Recent decisions:"); + for (const d of data.recentDecisions) { + lines.push(` ${d.id}: ${d.decision} → ${d.choice}`); + } + } + + if (data.recentRequirements.length > 0) { + lines.push(""); + lines.push("Recent requirements:"); + for (const r of data.recentRequirements) { + lines.push(` ${r.id} [${r.status}]: ${r.description}`); + } + } + + return lines.join("\n"); +} + +export async function handleInspect(ctx: ExtensionCommandContext): Promise { + try { + const { isDbAvailable, _getAdapter } = await import("./gsd-db.js"); + + if (!isDbAvailable()) { + ctx.ui.notify("No GSD database available. Run /gsd auto to create one.", "info"); + return; + } + + const adapter = _getAdapter(); + if (!adapter) { + ctx.ui.notify("No GSD database available. Run /gsd auto to create one.", "info"); + return; + } + + const versionRow = adapter.prepare("SELECT MAX(version) as v FROM schema_version").get(); + const schemaVersion = versionRow ? (versionRow["v"] as number | null) : null; + + const dCount = adapter.prepare("SELECT count(*) as cnt FROM decisions").get(); + const rCount = adapter.prepare("SELECT count(*) as cnt FROM requirements").get(); + const aCount = adapter.prepare("SELECT count(*) as cnt FROM artifacts").get(); + + const recentDecisions = adapter + .prepare("SELECT id, decision, choice FROM decisions ORDER BY seq DESC LIMIT 5") + .all() as Array<{ id: string; decision: string; choice: string }>; + + const recentRequirements = adapter + .prepare("SELECT id, status, description FROM requirements ORDER BY id DESC LIMIT 5") + .all() as Array<{ id: string; status: string; description: string }>; + + const data: InspectData = { + schemaVersion, + counts: { + decisions: (dCount?.["cnt"] as number) ?? 0, + requirements: (rCount?.["cnt"] as number) ?? 0, + artifacts: (aCount?.["cnt"] as number) ?? 0, + }, + recentDecisions, + recentRequirements, + }; + + ctx.ui.notify(formatInspectOutput(data), "info"); + } catch (err) { + process.stderr.write(`gsd-db: /gsd inspect failed: ${err instanceof Error ? err.message : String(err)}\n`); + ctx.ui.notify("Failed to inspect GSD database. Check stderr for details.", "error"); + } +} diff --git a/src/resources/extensions/gsd/commands-maintenance.ts b/src/resources/extensions/gsd/commands-maintenance.ts new file mode 100644 index 000000000..ef20edef7 --- /dev/null +++ b/src/resources/extensions/gsd/commands-maintenance.ts @@ -0,0 +1,206 @@ +/** + * GSD Maintenance — cleanup, skip, and dry-run handlers. + * + * Contains: handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { deriveState } from "./state.js"; +import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; + +export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise { + let branches: string[]; + try { + branches = nativeBranchList(basePath, "gsd/*"); + } catch { + ctx.ui.notify("No GSD branches found.", "info"); + return; + } + + if (branches.length === 0) { + ctx.ui.notify("No GSD branches to clean up.", "info"); + return; + } + + const mainBranch = nativeDetectMainBranch(basePath); + + let merged: string[]; + try { + merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*"); + } catch { + merged = []; + } + + if (merged.length === 0) { + ctx.ui.notify(`${branches.length} GSD branches found, none are merged into ${mainBranch} yet.`, "info"); + return; + } + + let deleted = 0; + for (const branch of merged) { + try { + nativeBranchDelete(basePath, branch, false); + deleted++; + } catch { /* skip branches that can't be deleted */ } + } + + ctx.ui.notify(`Cleaned up ${deleted} merged branches. ${branches.length - deleted} remain.`, "success"); +} + +export async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise { + let refs: string[]; + try { + refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); + } catch { + ctx.ui.notify("No snapshot refs found.", "info"); + return; + } + + if (refs.length === 0) { + ctx.ui.notify("No snapshot refs to clean up.", "info"); + return; + } + + const byLabel = new Map(); + for (const ref of refs) { + const parts = ref.split("/"); + const label = parts.slice(0, -1).join("/"); + if (!byLabel.has(label)) byLabel.set(label, []); + byLabel.get(label)!.push(ref); + } + + let pruned = 0; + for (const [, labelRefs] of byLabel) { + const sorted = labelRefs.sort(); + for (const old of sorted.slice(0, -5)) { + try { + nativeUpdateRef(basePath, old); + pruned++; + } catch { /* skip */ } + } + } + + ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); +} + +export async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise { + if (!unitArg) { + ctx.ui.notify("Usage: /gsd skip (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info"); + return; + } + + const { existsSync: fileExists, writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readFile } = await import("node:fs"); + const { join: pathJoin } = await import("node:path"); + + const completedKeysFile = pathJoin(basePath, ".gsd", "completed-units.json"); + let keys: string[] = []; + try { + if (fileExists(completedKeysFile)) { + keys = JSON.parse(readFile(completedKeysFile, "utf-8")); + } + } catch { /* start fresh */ } + + // Normalize: accept "execute-task/M001/S01/T03", "M001/S01/T03", or just "T03" + let skipKey = unitArg; + + if (!skipKey.includes("execute-task") && !skipKey.includes("plan-") && !skipKey.includes("research-") && !skipKey.includes("complete-")) { + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id; + const sid = state.activeSlice?.id; + + if (unitArg.match(/^T\d+$/i) && mid && sid) { + skipKey = `execute-task/${mid}/${sid}/${unitArg.toUpperCase()}`; + } else if (unitArg.match(/^S\d+$/i) && mid) { + skipKey = `plan-slice/${mid}/${unitArg.toUpperCase()}`; + } else if (unitArg.includes("/")) { + skipKey = `execute-task/${unitArg}`; + } + } + + if (keys.includes(skipKey)) { + ctx.ui.notify(`Already skipped: ${skipKey}`, "info"); + return; + } + + keys.push(skipKey); + mkDir(pathJoin(basePath, ".gsd"), { recursive: true }); + writeFile(completedKeysFile, JSON.stringify(keys), "utf-8"); + + ctx.ui.notify(`Skipped: ${skipKey}. Will not be dispatched in auto-mode.`, "success"); +} + +export async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Promise { + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone — nothing to dispatch.", "info"); + return; + } + + const { getLedger, getProjectTotals, formatCost, formatTokenCount, loadLedgerFromDisk } = await import("./metrics.js"); + const { loadEffectiveGSDPreferences: loadPrefs } = await import("./preferences.js"); + const { formatDuration } = await import("../shared/format-utils.js"); + + const ledger = getLedger(); + const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? []; + const prefs = loadPrefs()?.preferences; + + let nextType = "unknown"; + let nextId = "unknown"; + + const mid = state.activeMilestone.id; + const midTitle = state.activeMilestone.title; + + if (state.phase === "pre-planning") { + nextType = "research-milestone"; + nextId = mid; + } else if (state.phase === "planning" && state.activeSlice) { + nextType = "plan-slice"; + nextId = `${mid}/${state.activeSlice.id}`; + } else if (state.phase === "executing" && state.activeTask && state.activeSlice) { + nextType = "execute-task"; + nextId = `${mid}/${state.activeSlice.id}/${state.activeTask.id}`; + } else if (state.phase === "summarizing" && state.activeSlice) { + nextType = "complete-slice"; + nextId = `${mid}/${state.activeSlice.id}`; + } else if (state.phase === "completing-milestone") { + nextType = "complete-milestone"; + nextId = mid; + } else { + nextType = state.phase; + nextId = mid; + } + + const sameTypeUnits = units.filter(u => u.type === nextType); + const avgCost = sameTypeUnits.length > 0 + ? sameTypeUnits.reduce((s, u) => s + u.cost, 0) / sameTypeUnits.length + : null; + const avgDuration = sameTypeUnits.length > 0 + ? sameTypeUnits.reduce((s, u) => s + (u.finishedAt - u.startedAt), 0) / sameTypeUnits.length + : null; + + const totals = units.length > 0 ? getProjectTotals(units) : null; + const budgetRemaining = prefs?.budget_ceiling && totals + ? prefs.budget_ceiling - totals.cost + : null; + + const lines = [ + `Dry-run preview:`, + ``, + ` Next unit: ${nextType}`, + ` ID: ${nextId}`, + ` Milestone: ${mid}: ${midTitle}`, + ` Phase: ${state.phase}`, + ` Est. cost: ${avgCost !== null ? `${formatCost(avgCost)} (avg of ${sameTypeUnits.length} similar)` : "unknown (first of this type)"}`, + ` Est. duration: ${avgDuration !== null ? formatDuration(avgDuration) : "unknown"}`, + ` Spent so far: ${totals ? formatCost(totals.cost) : "$0"}`, + ` Budget left: ${budgetRemaining !== null ? formatCost(budgetRemaining) : "no ceiling set"}`, + ]; + + if (state.progress) { + const p = state.progress; + lines.push(` Progress: ${p.tasks?.done ?? 0}/${p.tasks?.total ?? "?"} tasks, ${p.slices?.done ?? 0}/${p.slices?.total ?? "?"} slices`); + } + + ctx.ui.notify(lines.join("\n"), "info"); +} diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts new file mode 100644 index 000000000..810a35851 --- /dev/null +++ b/src/resources/extensions/gsd/commands-prefs-wizard.ts @@ -0,0 +1,747 @@ +/** + * GSD Preferences Wizard — TUI wizard for configuring GSD preferences. + * + * Contains: handlePrefsWizard, buildCategorySummaries, all configure* functions, + * serializePreferencesToFrontmatter, yamlSafeString, ensurePreferencesFile, + * handlePrefsMode, handleImportClaude, handlePrefs + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + getGlobalGSDPreferencesPath, + getLegacyGlobalGSDPreferencesPath, + getProjectGSDPreferencesPath, + loadGlobalGSDPreferences, + loadProjectGSDPreferences, + loadEffectiveGSDPreferences, + resolveAllSkillReferences, +} from "./preferences.js"; +import { loadFile, saveFile, splitFrontmatter, parseFrontmatterMap } from "./files.js"; +import { runClaudeImportFlow } from "./claude-import.js"; + +export async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { + const trimmed = args.trim(); + + if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup" + || trimmed === "wizard global" || trimmed === "setup global") { + await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); + await handlePrefsWizard(ctx, "global"); + return; + } + + if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") { + await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project"); + await handlePrefsWizard(ctx, "project"); + return; + } + + if (trimmed === "import-claude" || trimmed === "import-claude global") { + await handleImportClaude(ctx, "global"); + return; + } + + if (trimmed === "import-claude project") { + await handleImportClaude(ctx, "project"); + return; + } + if (trimmed === "status") { + const globalPrefs = loadGlobalGSDPreferences(); + const projectPrefs = loadProjectGSDPreferences(); + const canonicalGlobal = getGlobalGSDPreferencesPath(); + const legacyGlobal = getLegacyGlobalGSDPreferencesPath(); + const globalStatus = globalPrefs + ? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}` + : `missing: ${canonicalGlobal}`; + const projectStatus = projectPrefs ? `present: ${projectPrefs.path}` : `missing: ${getProjectGSDPreferencesPath()}`; + + const lines = [`GSD skill prefs — global ${globalStatus}; project ${projectStatus}`]; + + const effective = loadEffectiveGSDPreferences(); + let hasUnresolved = false; + if (effective) { + const report = resolveAllSkillReferences(effective.preferences, process.cwd()); + const resolved = [...report.resolutions.values()].filter(r => r.method !== "unresolved"); + hasUnresolved = report.warnings.length > 0; + if (resolved.length > 0 || hasUnresolved) { + lines.push(`Skills: ${resolved.length} resolved, ${report.warnings.length} unresolved`); + } + if (hasUnresolved) { + lines.push(`Unresolved: ${report.warnings.join(", ")}`); + } + } + + ctx.ui.notify(lines.join("\n"), hasUnresolved ? "warning" : "info"); + return; + } + + ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup|import-claude [global|project]]", "info"); +} + +export async function handleImportClaude(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + if (!existsSync(path)) { + await ensurePreferencesFile(path, ctx, scope); + } + + const readPrefs = (): Record => { + if (!existsSync(path)) return { version: 1 }; + const content = readFileSync(path, "utf-8"); + const [frontmatterLines] = splitFrontmatter(content); + return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; + }; + + const writePrefs = async (prefs: Record): Promise => { + prefs.version = prefs.version || 1; + const frontmatter = serializePreferencesToFrontmatter(prefs); + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const existingContent = readFileSync(path, "utf-8"); + const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); + if (closingIdx !== -1) { + const afterFrontmatter = existingContent.slice(closingIdx + 4); + if (afterFrontmatter.trim()) body = afterFrontmatter; + } + } + await saveFile(path, `---\n${frontmatter}---${body}`); + }; + + await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); +} + +export async function handlePrefsMode(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; + + await configureMode(ctx, prefs); + + // Serialize and save + prefs.version = prefs.version || 1; + const frontmatter = serializePreferencesToFrontmatter(prefs); + + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const existingContent = readFileSync(path, "utf-8"); + const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); + if (closingIdx !== -1) { + const afterFrontmatter = existingContent.slice(closingIdx + 4); + if (afterFrontmatter.trim()) { + body = afterFrontmatter; + } + } + } + + const content = `---\n${frontmatter}---${body}`; + await saveFile(path, content); + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); +} + +/** Build short summary strings for each preference category. */ +export function buildCategorySummaries(prefs: Record): Record { + // Mode + const mode = prefs.mode as string | undefined; + const modeSummary = mode ?? "(not set)"; + + // Models + const models = prefs.models as Record | undefined; + let modelsSummary = "(not configured)"; + if (models && Object.keys(models).length > 0) { + const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${model}`); + modelsSummary = parts.join(", "); + } + + // Timeouts + const autoSup = prefs.auto_supervisor as Record | undefined; + let timeoutsSummary = "(defaults)"; + if (autoSup && Object.keys(autoSup).length > 0) { + const soft = autoSup.soft_timeout_minutes ?? "20"; + const idle = autoSup.idle_timeout_minutes ?? "10"; + const hard = autoSup.hard_timeout_minutes ?? "30"; + timeoutsSummary = `soft: ${soft}m, idle: ${idle}m, hard: ${hard}m`; + } + + // Git + const git = prefs.git as Record | undefined; + let gitSummary = "(defaults)"; + if (git && Object.keys(git).length > 0) { + const branch = git.main_branch ?? "main"; + const push = git.auto_push ? "on" : "off"; + gitSummary = `main: ${branch}, push: ${push}`; + } + + // Skills + const discovery = prefs.skill_discovery as string | undefined; + const uat = prefs.uat_dispatch; + let skillsSummary = "(not configured)"; + if (discovery || uat !== undefined) { + const parts: string[] = []; + if (discovery) parts.push(`discovery: ${discovery}`); + if (uat !== undefined) parts.push(`uat: ${uat}`); + skillsSummary = parts.join(", "); + } + + // Budget + const ceiling = prefs.budget_ceiling; + const enforcement = prefs.budget_enforcement as string | undefined; + let budgetSummary = "(no limit)"; + if (ceiling !== undefined) { + budgetSummary = `$${ceiling}`; + if (enforcement) budgetSummary += ` / ${enforcement}`; + } else if (enforcement) { + budgetSummary = enforcement; + } + + // Notifications + const notif = prefs.notifications as Record | undefined; + let notifSummary = "(defaults)"; + if (notif && Object.keys(notif).length > 0) { + const allKeys = ["enabled", "on_complete", "on_error", "on_budget", "on_milestone", "on_attention"]; + const enabledCount = allKeys.filter(k => notif[k] !== false).length; + notifSummary = `${enabledCount}/${allKeys.length} enabled`; + } + + // Advanced + const uniqueIds = prefs.unique_milestone_ids; + let advancedSummary = "(defaults)"; + if (uniqueIds !== undefined) { + advancedSummary = `unique IDs: ${uniqueIds ? "on" : "off"}`; + } + + return { + mode: modeSummary, + models: modelsSummary, + timeouts: timeoutsSummary, + git: gitSummary, + skills: skillsSummary, + budget: budgetSummary, + notifications: notifSummary, + advanced: advancedSummary, + }; +} + +// ─── Category configuration functions ──────────────────────────────────────── + +async function configureModels(ctx: ExtensionCommandContext, prefs: Record): Promise { + const modelPhases = ["research", "planning", "execution", "completion"] as const; + const models: Record = (prefs.models as Record) ?? {}; + + const availableModels = ctx.modelRegistry.getAvailable(); + if (availableModels.length > 0) { + // Group models by provider, sorted alphabetically + const byProvider = new Map(); + for (const m of availableModels) { + let group = byProvider.get(m.provider); + if (!group) { + group = []; + byProvider.set(m.provider, group); + } + group.push(m); + } + const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b)); + + const modelOptions: string[] = []; + for (const provider of providers) { + const group = byProvider.get(provider)!; + modelOptions.push(`─── ${provider} (${group.length}) ───`); + for (const m of group) { + modelOptions.push(`${m.id} · ${m.provider}`); + } + } + modelOptions.push("(keep current)", "(clear)"); + + for (const phase of modelPhases) { + const current = models[phase] ?? ""; + const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`; + const choice = await ctx.ui.select(title, modelOptions); + + if (choice && typeof choice === "string" && choice !== "(keep current)") { + if (choice === "(clear)") { + delete models[phase]; + } else { + models[phase] = choice.split(" · ")[0]; + } + } + } + } else { + for (const phase of modelPhases) { + const current = models[phase] ?? ""; + const input = await ctx.ui.input( + `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, + current || "e.g. claude-sonnet-4-20250514", + ); + if (input !== null && input !== undefined) { + const val = input.trim(); + if (val) { + models[phase] = val; + } else if (current) { + delete models[phase]; + } + } + } + } + if (Object.keys(models).length > 0) { + prefs.models = models; + } +} + +async function configureTimeouts(ctx: ExtensionCommandContext, prefs: Record): Promise { + const autoSup: Record = (prefs.auto_supervisor as Record) ?? {}; + const timeoutFields = [ + { key: "soft_timeout_minutes", label: "Soft timeout (minutes)", defaultVal: "20" }, + { key: "idle_timeout_minutes", label: "Idle timeout (minutes)", defaultVal: "10" }, + { key: "hard_timeout_minutes", label: "Hard timeout (minutes)", defaultVal: "30" }, + ] as const; + + for (const field of timeoutFields) { + const current = autoSup[field.key]; + const currentStr = current !== undefined && current !== null ? String(current) : ""; + const input = await ctx.ui.input( + `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, + currentStr || field.defaultVal, + ); + if (input !== null && input !== undefined) { + const val = input.trim(); + if (val && /^\d+$/.test(val)) { + autoSup[field.key] = Number(val); + } else if (val && !/^\d+$/.test(val)) { + ctx.ui.notify(`Invalid value "${val}" for ${field.label} — must be a whole number. Keeping previous value.`, "warning"); + } else if (!val && currentStr) { + delete autoSup[field.key]; + } + } + } + if (Object.keys(autoSup).length > 0) { + prefs.auto_supervisor = autoSup; + } +} + +async function configureGit(ctx: ExtensionCommandContext, prefs: Record): Promise { + const git: Record = (prefs.git as Record) ?? {}; + + // main_branch + const currentBranch = git.main_branch ? String(git.main_branch) : ""; + const branchInput = await ctx.ui.input( + `Git main branch${currentBranch ? ` (current: ${currentBranch})` : ""}:`, + currentBranch || "main", + ); + if (branchInput !== null && branchInput !== undefined) { + const val = branchInput.trim(); + if (val) { + git.main_branch = val; + } else if (currentBranch) { + delete git.main_branch; + } + } + + // Boolean git toggles + const gitBooleanFields = [ + { key: "auto_push", label: "Auto-push commits after committing", defaultVal: false }, + { key: "push_branches", label: "Push milestone branches to remote", defaultVal: false }, + { key: "snapshots", label: "Create WIP snapshot commits during long tasks", defaultVal: false }, + ] as const; + + for (const field of gitBooleanFields) { + const current = git[field.key]; + const currentStr = current !== undefined ? String(current) : ""; + const choice = await ctx.ui.select( + `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, + ["true", "false", "(keep current)"], + ); + if (choice && choice !== "(keep current)") { + git[field.key] = choice === "true"; + } + } + + // remote + const currentRemote = git.remote ? String(git.remote) : ""; + const remoteInput = await ctx.ui.input( + `Git remote name${currentRemote ? ` (current: ${currentRemote})` : " (default: origin)"}:`, + currentRemote || "origin", + ); + if (remoteInput !== null && remoteInput !== undefined) { + const val = remoteInput.trim(); + if (val && val !== "origin") { + git.remote = val; + } else if (!val && currentRemote) { + delete git.remote; + } + } + + // pre_merge_check + const currentPreMerge = git.pre_merge_check !== undefined ? String(git.pre_merge_check) : ""; + const preMergeChoice = await ctx.ui.select( + `Pre-merge check${currentPreMerge ? ` (current: ${currentPreMerge})` : " (default: false)"}:`, + ["true", "false", "auto", "(keep current)"], + ); + if (preMergeChoice && preMergeChoice !== "(keep current)") { + if (preMergeChoice === "auto") { + git.pre_merge_check = "auto"; + } else { + git.pre_merge_check = preMergeChoice === "true"; + } + } + + // commit_type + const currentCommitType = git.commit_type ? String(git.commit_type) : ""; + const commitTypes = ["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style", "(inferred — default)", "(keep current)"]; + const commitChoice = await ctx.ui.select( + `Default commit type${currentCommitType ? ` (current: ${currentCommitType})` : ""}:`, + commitTypes, + ); + if (commitChoice && typeof commitChoice === "string" && commitChoice !== "(keep current)") { + if ((commitChoice as string).startsWith("(inferred")) { + delete git.commit_type; + } else { + git.commit_type = commitChoice; + } + } + + // merge_strategy + const currentMerge = git.merge_strategy ? String(git.merge_strategy) : ""; + const mergeChoice = await ctx.ui.select( + `Merge strategy${currentMerge ? ` (current: ${currentMerge})` : ""}:`, + ["squash", "merge", "(keep current)"], + ); + if (mergeChoice && mergeChoice !== "(keep current)") { + git.merge_strategy = mergeChoice; + } + + // isolation + const currentIsolation = git.isolation ? String(git.isolation) : ""; + const isolationChoice = await ctx.ui.select( + `Git isolation strategy${currentIsolation ? ` (current: ${currentIsolation})` : " (default: worktree)"}:`, + ["worktree", "branch", "none", "(keep current)"], + ); + if (isolationChoice && isolationChoice !== "(keep current)") { + git.isolation = isolationChoice; + } + + // commit_docs + const currentCommitDocs = git.commit_docs; + const commitDocsChoice = await ctx.ui.select( + `Track .gsd/ planning docs in git${currentCommitDocs !== undefined ? ` (current: ${currentCommitDocs})` : ""}:`, + ["true", "false", "(keep current)"], + ); + if (commitDocsChoice && commitDocsChoice !== "(keep current)") { + git.commit_docs = commitDocsChoice === "true"; + } + + if (Object.keys(git).length > 0) { + prefs.git = git; + } +} + +async function configureSkills(ctx: ExtensionCommandContext, prefs: Record): Promise { + // Skill discovery mode + const currentDiscovery = (prefs.skill_discovery as string) ?? ""; + const discoveryChoice = await ctx.ui.select( + `Skill discovery mode${currentDiscovery ? ` (current: ${currentDiscovery})` : ""}:`, + ["auto", "suggest", "off", "(keep current)"], + ); + if (discoveryChoice && discoveryChoice !== "(keep current)") { + prefs.skill_discovery = discoveryChoice; + } + + // UAT dispatch + const currentUat = prefs.uat_dispatch; + const uatChoice = await ctx.ui.select( + `UAT dispatch mode${currentUat !== undefined ? ` (current: ${currentUat})` : " (default: false)"}:`, + ["true", "false", "(keep current)"], + ); + if (uatChoice && uatChoice !== "(keep current)") { + prefs.uat_dispatch = uatChoice === "true"; + } +} + +async function configureBudget(ctx: ExtensionCommandContext, prefs: Record): Promise { + const currentCeiling = prefs.budget_ceiling; + const ceilingStr = currentCeiling !== undefined ? String(currentCeiling) : ""; + const ceilingInput = await ctx.ui.input( + `Budget ceiling (USD)${ceilingStr ? ` (current: $${ceilingStr})` : " (default: no limit)"}:`, + ceilingStr || "", + ); + if (ceilingInput !== null && ceilingInput !== undefined) { + const val = ceilingInput.trim().replace(/^\$/, ""); + if (val && !isNaN(Number(val)) && isFinite(Number(val))) { + prefs.budget_ceiling = Number(val); + } else if (val && (isNaN(Number(val)) || !isFinite(Number(val)))) { + ctx.ui.notify(`Invalid budget ceiling "${val}" — must be a number. Keeping previous value.`, "warning"); + } else if (!val && ceilingStr) { + delete prefs.budget_ceiling; + } + } + + const currentEnforcement = (prefs.budget_enforcement as string) ?? ""; + const enforcementChoice = await ctx.ui.select( + `Budget enforcement${currentEnforcement ? ` (current: ${currentEnforcement})` : " (default: pause)"}:`, + ["warn", "pause", "halt", "(keep current)"], + ); + if (enforcementChoice && enforcementChoice !== "(keep current)") { + prefs.budget_enforcement = enforcementChoice; + } + + const currentContextPause = prefs.context_pause_threshold; + const contextPauseStr = currentContextPause !== undefined ? String(currentContextPause) : ""; + const contextPauseInput = await ctx.ui.input( + `Context pause threshold (0-100%, 0=disabled)${contextPauseStr ? ` (current: ${contextPauseStr}%)` : " (default: 0)"}:`, + contextPauseStr || "0", + ); + if (contextPauseInput !== null && contextPauseInput !== undefined) { + const val = contextPauseInput.trim().replace(/%$/, ""); + if (val && !isNaN(Number(val)) && Number(val) >= 0 && Number(val) <= 100) { + const num = Number(val); + if (num === 0) { + delete prefs.context_pause_threshold; + } else { + prefs.context_pause_threshold = num; + } + } else if (val && (isNaN(Number(val)) || Number(val) < 0 || Number(val) > 100)) { + ctx.ui.notify(`Invalid context pause threshold "${val}" — must be 0-100. Keeping previous value.`, "warning"); + } + } +} + +async function configureNotifications(ctx: ExtensionCommandContext, prefs: Record): Promise { + const notif: Record = (prefs.notifications as Record) ?? {}; + const notifFields = [ + { key: "enabled", label: "Notifications enabled (master toggle)", defaultVal: true }, + { key: "on_complete", label: "Notify on unit completion", defaultVal: true }, + { key: "on_error", label: "Notify on errors", defaultVal: true }, + { key: "on_budget", label: "Notify on budget thresholds", defaultVal: true }, + { key: "on_milestone", label: "Notify on milestone completion", defaultVal: true }, + { key: "on_attention", label: "Notify when manual attention needed", defaultVal: true }, + ] as const; + + for (const field of notifFields) { + const current = notif[field.key]; + const currentStr = current !== undefined && typeof current === "boolean" ? String(current) : ""; + const choice = await ctx.ui.select( + `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, + ["true", "false", "(keep current)"], + ); + if (choice && choice !== "(keep current)") { + notif[field.key] = choice === "true"; + } + } + if (Object.keys(notif).length > 0) { + prefs.notifications = notif; + } +} + +export async function configureMode(ctx: ExtensionCommandContext, prefs: Record): Promise { + const currentMode = prefs.mode as string | undefined; + const modeChoice = await ctx.ui.select( + `Workflow mode${currentMode ? ` (current: ${currentMode})` : ""}:`, + [ + "solo — auto-push, squash, simple IDs (personal projects)", + "team — unique IDs, push branches, pre-merge checks (shared repos)", + "(none) — configure everything manually", + "(keep current)", + ], + ); + const modeStr = typeof modeChoice === "string" ? modeChoice : ""; + if (modeStr && modeStr !== "(keep current)") { + if (modeStr.startsWith("solo")) { + prefs.mode = "solo"; + ctx.ui.notify( + "Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=false, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=false", + "info", + ); + } else if (modeStr.startsWith("team")) { + prefs.mode = "team"; + ctx.ui.notify( + "Mode: team — defaults: auto_push=false, push_branches=true, pre_merge_check=true, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=true", + "info", + ); + } else { + delete prefs.mode; + } + } +} + +async function configureAdvanced(ctx: ExtensionCommandContext, prefs: Record): Promise { + const currentUnique = prefs.unique_milestone_ids; + const uniqueChoice = await ctx.ui.select( + `Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`, + ["true", "false", "(keep current)"], + ); + if (uniqueChoice && uniqueChoice !== "(keep current)") { + prefs.unique_milestone_ids = uniqueChoice === "true"; + } +} + +// ─── Main wizard with category menu ───────────────────────────────────────── + +export async function handlePrefsWizard( + ctx: ExtensionCommandContext, + scope: "global" | "project", +): Promise { + const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); + const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); + const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; + + ctx.ui.notify(`GSD preferences (${scope}) — pick a category to configure.`, "info"); + + while (true) { + const summaries = buildCategorySummaries(prefs); + const options = [ + `Workflow Mode ${summaries.mode}`, + `Models ${summaries.models}`, + `Timeouts ${summaries.timeouts}`, + `Git ${summaries.git}`, + `Skills ${summaries.skills}`, + `Budget ${summaries.budget}`, + `Notifications ${summaries.notifications}`, + `Advanced ${summaries.advanced}`, + `── Save & Exit ──`, + ]; + + const raw = await ctx.ui.select("GSD Preferences", options); + const choice = typeof raw === "string" ? raw : ""; + if (!choice || choice.includes("Save & Exit")) break; + + if (choice.startsWith("Workflow Mode")) await configureMode(ctx, prefs); + else if (choice.startsWith("Models")) await configureModels(ctx, prefs); + else if (choice.startsWith("Timeouts")) await configureTimeouts(ctx, prefs); + else if (choice.startsWith("Git")) await configureGit(ctx, prefs); + else if (choice.startsWith("Skills")) await configureSkills(ctx, prefs); + else if (choice.startsWith("Budget")) await configureBudget(ctx, prefs); + else if (choice.startsWith("Notifications")) await configureNotifications(ctx, prefs); + else if (choice.startsWith("Advanced")) await configureAdvanced(ctx, prefs); + } + + // ─── Serialize to frontmatter ─────────────────────────────────────────── + prefs.version = prefs.version || 1; + const frontmatter = serializePreferencesToFrontmatter(prefs); + + // Preserve existing body content (everything after closing ---) + let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; + if (existsSync(path)) { + const existingContent = readFileSync(path, "utf-8"); + const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); + if (closingIdx !== -1) { + const afterFrontmatter = existingContent.slice(closingIdx + 4); // skip past "\n---" + if (afterFrontmatter.trim()) { + body = afterFrontmatter; + } + } + } + + const content = `---\n${frontmatter}---${body}`; + + await saveFile(path, content); + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); +} + +/** Wrap a YAML value in double quotes if it contains special characters. */ +export function yamlSafeString(val: unknown): string { + if (typeof val !== "string") return String(val); + if (/[:#{\[\]'"`,|>&*!?@%]/.test(val) || val.trim() !== val || val === "") { + return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + return val; +} + +export function serializePreferencesToFrontmatter(prefs: Record): string { + const lines: string[] = []; + + function serializeValue(key: string, value: unknown, indent: number): void { + const prefix = " ".repeat(indent); + if (value === null || value === undefined) return; + + if (Array.isArray(value)) { + if (value.length === 0) { + return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings + } + lines.push(`${prefix}${key}:`); + for (const item of value) { + if (typeof item === "object" && item !== null) { + const entries = Object.entries(item as Record); + if (entries.length > 0) { + const [firstKey, firstVal] = entries[0]; + lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`); + for (let i = 1; i < entries.length; i++) { + const [k, v] = entries[i]; + if (Array.isArray(v)) { + lines.push(`${prefix} ${k}:`); + for (const arrItem of v) { + lines.push(`${prefix} - ${yamlSafeString(arrItem)}`); + } + } else { + lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`); + } + } + } + } else { + lines.push(`${prefix} - ${yamlSafeString(item)}`); + } + } + return; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings + } + lines.push(`${prefix}${key}:`); + for (const [k, v] of entries) { + serializeValue(k, v, indent + 1); + } + return; + } + + lines.push(`${prefix}${key}: ${yamlSafeString(value)}`); + } + + // Ordered keys for consistent output + const orderedKeys = [ + "version", "mode", "always_use_skills", "prefer_skills", "avoid_skills", + "skill_rules", "custom_instructions", "models", "skill_discovery", + "auto_supervisor", "uat_dispatch", "unique_milestone_ids", + "budget_ceiling", "budget_enforcement", "context_pause_threshold", + "notifications", "remote_questions", "git", + "post_unit_hooks", "pre_dispatch_hooks", + ]; + + const seen = new Set(); + for (const key of orderedKeys) { + if (key in prefs) { + serializeValue(key, prefs[key], 0); + seen.add(key); + } + } + // Any remaining keys not in the ordered list + for (const [key, value] of Object.entries(prefs)) { + if (!seen.has(key)) { + serializeValue(key, value, 0); + } + } + + return lines.join("\n") + "\n"; +} + +export async function ensurePreferencesFile( + path: string, + ctx: ExtensionCommandContext, + scope: "global" | "project", +): Promise { + if (!existsSync(path)) { + const template = await loadFile(join(dirname(fileURLToPath(import.meta.url)), "templates", "preferences.md")); + if (!template) { + ctx.ui.notify("Could not load GSD preferences template.", "error"); + return; + } + await saveFile(path, template); + ctx.ui.notify(`Created ${scope} GSD skill preferences at ${path}`, "info"); + } else { + ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info"); + } +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 82270e32e..a5ae90311 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -5,37 +5,21 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { AuthStorage } from "@gsd/pi-coding-agent"; -import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { enableDebug, isDebugEnabled } from "./debug-logger.js"; -import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { enableDebug } from "./debug-logger.js"; import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; import { GSDVisualizerOverlay } from "./visualizer-overlay.js"; import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js"; import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, dispatchDirectPhase } from "./auto.js"; import { resolveProjectRoot } from "./worktree.js"; -import { assertSafeDirectory, validateDirectory } from "./validate-directory.js"; -import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js"; +import { assertSafeDirectory } from "./validate-directory.js"; import { getGlobalGSDPreferencesPath, - getLegacyGlobalGSDPreferencesPath, getProjectGSDPreferencesPath, - loadGlobalGSDPreferences, - loadProjectGSDPreferences, loadEffectiveGSDPreferences, - resolveAllSkillReferences, } from "./preferences.js"; -import { loadFile, saveFile, appendOverride, appendKnowledge, splitFrontmatter, parseFrontmatterMap } from "./files.js"; -import { runClaudeImportFlow } from "./claude-import.js"; -import { - formatDoctorIssuesForPrompt, - formatDoctorReport, - runGSDDoctor, - selectDoctorScope, - filterDoctorIssues, -} from "./doctor.js"; import { loadPrompt } from "./prompt-loader.js"; import { handleRemote } from "../remote-questions/mod.js"; @@ -51,7 +35,20 @@ import { import { formatEligibilityReport } from "./parallel-eligibility.js"; import { mergeAllCompleted, mergeCompletedMilestone, formatMergeResults } from "./parallel-merge.js"; import { resolveParallelConfig } from "./preferences.js"; -import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js"; + +// ─── Imports from extracted modules ────────────────────────────────────────── +import { handlePrefs, handlePrefsMode, handlePrefsWizard, ensurePreferencesFile } from "./commands-prefs-wizard.js"; +import { handleConfig } from "./commands-config.js"; +import { handleInspect } from "./commands-inspect.js"; +import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js"; +import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js"; + +// ─── Re-exports (preserve public API surface) ─────────────────────────────── +export { handlePrefs, handlePrefsMode, handlePrefsWizard, ensurePreferencesFile, handleImportClaude, buildCategorySummaries, serializePreferencesToFrontmatter, yamlSafeString, configureMode } from "./commands-prefs-wizard.js"; +export { TOOL_KEYS, loadToolApiKeys, getConfigAuthStorage, handleConfig } from "./commands-config.js"; +export { type InspectData, formatInspectOutput, handleInspect } from "./commands-inspect.js"; +export { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js"; +export { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js"; export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); @@ -72,7 +69,7 @@ export function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, } /** Resolve the effective project root, accounting for worktree paths. */ -function projectRoot(): string { +export function projectRoot(): string { const root = resolveProjectRoot(process.cwd()); assertSafeDirectory(root); return root; @@ -125,10 +122,10 @@ export function registerGSDCommand(pi: ExtensionAPI): void { if (parts.length <= 1) { return subcommands .filter((item) => item.cmd.startsWith(parts[0] ?? "")) - .map((item) => ({ - value: item.cmd, - label: item.cmd, - description: item.desc + .map((item) => ({ + value: item.cmd, + label: item.cmd, + description: item.desc })); } @@ -856,1483 +853,3 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise< "info", ); } - -async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { - const trimmed = args.trim(); - - if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup" - || trimmed === "wizard global" || trimmed === "setup global") { - await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); - await handlePrefsWizard(ctx, "global"); - return; - } - - if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") { - await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project"); - await handlePrefsWizard(ctx, "project"); - return; - } - - if (trimmed === "import-claude" || trimmed === "import-claude global") { - await handleImportClaude(ctx, "global"); - return; - } - - if (trimmed === "import-claude project") { - await handleImportClaude(ctx, "project"); - return; - } - if (trimmed === "status") { - const globalPrefs = loadGlobalGSDPreferences(); - const projectPrefs = loadProjectGSDPreferences(); - const canonicalGlobal = getGlobalGSDPreferencesPath(); - const legacyGlobal = getLegacyGlobalGSDPreferencesPath(); - const globalStatus = globalPrefs - ? `present: ${globalPrefs.path}${globalPrefs.path === legacyGlobal ? " (legacy fallback)" : ""}` - : `missing: ${canonicalGlobal}`; - const projectStatus = projectPrefs ? `present: ${projectPrefs.path}` : `missing: ${getProjectGSDPreferencesPath()}`; - - const lines = [`GSD skill prefs — global ${globalStatus}; project ${projectStatus}`]; - - const effective = loadEffectiveGSDPreferences(); - let hasUnresolved = false; - if (effective) { - const report = resolveAllSkillReferences(effective.preferences, process.cwd()); - const resolved = [...report.resolutions.values()].filter(r => r.method !== "unresolved"); - hasUnresolved = report.warnings.length > 0; - if (resolved.length > 0 || hasUnresolved) { - lines.push(`Skills: ${resolved.length} resolved, ${report.warnings.length} unresolved`); - } - if (hasUnresolved) { - lines.push(`Unresolved: ${report.warnings.join(", ")}`); - } - } - - ctx.ui.notify(lines.join("\n"), hasUnresolved ? "warning" : "info"); - return; - } - - ctx.ui.notify("Usage: /gsd prefs [global|project|status|wizard|setup|import-claude [global|project]]", "info"); -} - -async function handleImportClaude(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - if (!existsSync(path)) { - await ensurePreferencesFile(path, ctx, scope); - } - - const readPrefs = (): Record => { - if (!existsSync(path)) return { version: 1 }; - const content = readFileSync(path, "utf-8"); - const [frontmatterLines] = splitFrontmatter(content); - return frontmatterLines ? parseFrontmatterMap(frontmatterLines) : { version: 1 }; - }; - - const writePrefs = async (prefs: Record): Promise => { - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const existingContent = readFileSync(path, "utf-8"); - const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); - if (closingIdx !== -1) { - const afterFrontmatter = existingContent.slice(closingIdx + 4); - if (afterFrontmatter.trim()) body = afterFrontmatter; - } - } - await saveFile(path, `---\n${frontmatter}---${body}`); - }; - - await runClaudeImportFlow(ctx, scope, readPrefs, writePrefs); -} - -async function handlePrefsMode(ctx: ExtensionCommandContext, scope: "global" | "project"): Promise { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); - const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; - - await configureMode(ctx, prefs); - - // Serialize and save - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - - let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const existingContent = readFileSync(path, "utf-8"); - const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); - if (closingIdx !== -1) { - const afterFrontmatter = existingContent.slice(closingIdx + 4); - if (afterFrontmatter.trim()) { - body = afterFrontmatter; - } - } - } - - const content = `---\n${frontmatter}---${body}`; - await saveFile(path, content); - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); -} - -async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - const trimmed = args.trim(); - const parts = trimmed ? trimmed.split(/\s+/) : []; - const mode = parts[0] === "fix" || parts[0] === "heal" || parts[0] === "audit" ? parts[0] : "doctor"; - const requestedScope = mode === "doctor" ? parts[0] : parts[1]; - const scope = await selectDoctorScope(projectRoot(), requestedScope); - const effectiveScope = mode === "audit" ? requestedScope : scope; - const report = await runGSDDoctor(projectRoot(), { - fix: mode === "fix" || mode === "heal", - scope: effectiveScope, - }); - - const reportText = formatDoctorReport(report, { - scope: effectiveScope, - includeWarnings: mode === "audit", - maxIssues: mode === "audit" ? 50 : 12, - title: mode === "audit" ? "GSD doctor audit." : mode === "heal" ? "GSD doctor heal prep." : undefined, - }); - - ctx.ui.notify(reportText, report.ok ? "info" : "warning"); - - if (mode === "heal") { - const unresolved = filterDoctorIssues(report.issues, { - scope: effectiveScope, - includeWarnings: true, - }); - const actionable = unresolved.filter(issue => issue.severity === "error" || issue.code === "all_tasks_done_missing_slice_uat" || issue.code === "slice_checked_missing_uat"); - if (actionable.length === 0) { - ctx.ui.notify("Doctor heal found nothing actionable to hand off to the LLM.", "info"); - return; - } - - const structuredIssues = formatDoctorIssuesForPrompt(actionable); - dispatchDoctorHeal(pi, effectiveScope, reportText, structuredIssues); - ctx.ui.notify(`Doctor heal dispatched ${actionable.length} issue(s) to the LLM.`, "info"); - } -} - -// ─── Inspect ────────────────────────────────────────────────────────────────── - -export interface InspectData { - schemaVersion: number | null; - counts: { decisions: number; requirements: number; artifacts: number }; - recentDecisions: Array<{ id: string; decision: string; choice: string }>; - recentRequirements: Array<{ id: string; status: string; description: string }>; -} - -export function formatInspectOutput(data: InspectData): string { - const lines: string[] = []; - lines.push("=== GSD Database Inspect ==="); - lines.push(`Schema version: ${data.schemaVersion ?? "unknown"}`); - lines.push(""); - lines.push(`Decisions: ${data.counts.decisions}`); - lines.push(`Requirements: ${data.counts.requirements}`); - lines.push(`Artifacts: ${data.counts.artifacts}`); - - if (data.recentDecisions.length > 0) { - lines.push(""); - lines.push("Recent decisions:"); - for (const d of data.recentDecisions) { - lines.push(` ${d.id}: ${d.decision} → ${d.choice}`); - } - } - - if (data.recentRequirements.length > 0) { - lines.push(""); - lines.push("Recent requirements:"); - for (const r of data.recentRequirements) { - lines.push(` ${r.id} [${r.status}]: ${r.description}`); - } - } - - return lines.join("\n"); -} - -async function handleInspect(ctx: ExtensionCommandContext): Promise { - try { - const { isDbAvailable, _getAdapter } = await import("./gsd-db.js"); - - if (!isDbAvailable()) { - ctx.ui.notify("No GSD database available. Run /gsd auto to create one.", "info"); - return; - } - - const adapter = _getAdapter(); - if (!adapter) { - ctx.ui.notify("No GSD database available. Run /gsd auto to create one.", "info"); - return; - } - - const versionRow = adapter.prepare("SELECT MAX(version) as v FROM schema_version").get(); - const schemaVersion = versionRow ? (versionRow["v"] as number | null) : null; - - const dCount = adapter.prepare("SELECT count(*) as cnt FROM decisions").get(); - const rCount = adapter.prepare("SELECT count(*) as cnt FROM requirements").get(); - const aCount = adapter.prepare("SELECT count(*) as cnt FROM artifacts").get(); - - const recentDecisions = adapter - .prepare("SELECT id, decision, choice FROM decisions ORDER BY seq DESC LIMIT 5") - .all() as Array<{ id: string; decision: string; choice: string }>; - - const recentRequirements = adapter - .prepare("SELECT id, status, description FROM requirements ORDER BY id DESC LIMIT 5") - .all() as Array<{ id: string; status: string; description: string }>; - - const data: InspectData = { - schemaVersion, - counts: { - decisions: (dCount?.["cnt"] as number) ?? 0, - requirements: (rCount?.["cnt"] as number) ?? 0, - artifacts: (aCount?.["cnt"] as number) ?? 0, - }, - recentDecisions, - recentRequirements, - }; - - ctx.ui.notify(formatInspectOutput(data), "info"); - } catch (err) { - process.stderr.write(`gsd-db: /gsd inspect failed: ${err instanceof Error ? err.message : String(err)}\n`); - ctx.ui.notify("Failed to inspect GSD database. Check stderr for details.", "error"); - } -} - -// ─── Skill Health ───────────────────────────────────────────────────────────── - -async function handleSkillHealth(args: string, ctx: ExtensionCommandContext): Promise { - const { - generateSkillHealthReport, - formatSkillHealthReport, - formatSkillDetail, - } = await import("./skill-health.js"); - - const basePath = projectRoot(); - - // /gsd skill-health — detail view - if (args && !args.startsWith("--")) { - const detail = formatSkillDetail(basePath, args); - ctx.ui.notify(detail, "info"); - return; - } - - // Parse flags - const staleMatch = args.match(/--stale\s+(\d+)/); - const staleDays = staleMatch ? parseInt(staleMatch[1], 10) : undefined; - const decliningOnly = args.includes("--declining"); - - const report = generateSkillHealthReport(basePath, staleDays); - - if (decliningOnly) { - if (report.decliningSkills.length === 0) { - ctx.ui.notify("No skills flagged for declining performance.", "info"); - return; - } - const filtered = { - ...report, - skills: report.skills.filter(s => s.flagged), - }; - ctx.ui.notify(formatSkillHealthReport(filtered), "info"); - return; - } - - ctx.ui.notify(formatSkillHealthReport(report), "info"); -} - -// ─── Preferences Wizard ─────────────────────────────────────────────────────── - -/** Build short summary strings for each preference category. */ -function buildCategorySummaries(prefs: Record): Record { - // Mode - const mode = prefs.mode as string | undefined; - const modeSummary = mode ?? "(not set)"; - - // Models - const models = prefs.models as Record | undefined; - let modelsSummary = "(not configured)"; - if (models && Object.keys(models).length > 0) { - const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${model}`); - modelsSummary = parts.join(", "); - } - - // Timeouts - const autoSup = prefs.auto_supervisor as Record | undefined; - let timeoutsSummary = "(defaults)"; - if (autoSup && Object.keys(autoSup).length > 0) { - const soft = autoSup.soft_timeout_minutes ?? "20"; - const idle = autoSup.idle_timeout_minutes ?? "10"; - const hard = autoSup.hard_timeout_minutes ?? "30"; - timeoutsSummary = `soft: ${soft}m, idle: ${idle}m, hard: ${hard}m`; - } - - // Git - const git = prefs.git as Record | undefined; - let gitSummary = "(defaults)"; - if (git && Object.keys(git).length > 0) { - const branch = git.main_branch ?? "main"; - const push = git.auto_push ? "on" : "off"; - gitSummary = `main: ${branch}, push: ${push}`; - } - - // Skills - const discovery = prefs.skill_discovery as string | undefined; - const uat = prefs.uat_dispatch; - let skillsSummary = "(not configured)"; - if (discovery || uat !== undefined) { - const parts: string[] = []; - if (discovery) parts.push(`discovery: ${discovery}`); - if (uat !== undefined) parts.push(`uat: ${uat}`); - skillsSummary = parts.join(", "); - } - - // Budget - const ceiling = prefs.budget_ceiling; - const enforcement = prefs.budget_enforcement as string | undefined; - let budgetSummary = "(no limit)"; - if (ceiling !== undefined) { - budgetSummary = `$${ceiling}`; - if (enforcement) budgetSummary += ` / ${enforcement}`; - } else if (enforcement) { - budgetSummary = enforcement; - } - - // Notifications - const notif = prefs.notifications as Record | undefined; - let notifSummary = "(defaults)"; - if (notif && Object.keys(notif).length > 0) { - const allKeys = ["enabled", "on_complete", "on_error", "on_budget", "on_milestone", "on_attention"]; - const enabledCount = allKeys.filter(k => notif[k] !== false).length; - notifSummary = `${enabledCount}/${allKeys.length} enabled`; - } - - // Advanced - const uniqueIds = prefs.unique_milestone_ids; - let advancedSummary = "(defaults)"; - if (uniqueIds !== undefined) { - advancedSummary = `unique IDs: ${uniqueIds ? "on" : "off"}`; - } - - return { - mode: modeSummary, - models: modelsSummary, - timeouts: timeoutsSummary, - git: gitSummary, - skills: skillsSummary, - budget: budgetSummary, - notifications: notifSummary, - advanced: advancedSummary, - }; -} - -// ─── Category configuration functions ──────────────────────────────────────── - -async function configureModels(ctx: ExtensionCommandContext, prefs: Record): Promise { - const modelPhases = ["research", "planning", "execution", "completion"] as const; - const models: Record = (prefs.models as Record) ?? {}; - - const availableModels = ctx.modelRegistry.getAvailable(); - if (availableModels.length > 0) { - // Group models by provider, sorted alphabetically - const byProvider = new Map(); - for (const m of availableModels) { - let group = byProvider.get(m.provider); - if (!group) { - group = []; - byProvider.set(m.provider, group); - } - group.push(m); - } - const providers = Array.from(byProvider.keys()).sort((a, b) => a.localeCompare(b)); - - const modelOptions: string[] = []; - for (const provider of providers) { - const group = byProvider.get(provider)!; - modelOptions.push(`─── ${provider} (${group.length}) ───`); - for (const m of group) { - modelOptions.push(`${m.id} · ${m.provider}`); - } - } - modelOptions.push("(keep current)", "(clear)"); - - for (const phase of modelPhases) { - const current = models[phase] ?? ""; - const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`; - const choice = await ctx.ui.select(title, modelOptions); - - if (choice && typeof choice === "string" && choice !== "(keep current)") { - if (choice === "(clear)") { - delete models[phase]; - } else { - models[phase] = choice.split(" · ")[0]; - } - } - } - } else { - for (const phase of modelPhases) { - const current = models[phase] ?? ""; - const input = await ctx.ui.input( - `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, - current || "e.g. claude-sonnet-4-20250514", - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - if (val) { - models[phase] = val; - } else if (current) { - delete models[phase]; - } - } - } - } - if (Object.keys(models).length > 0) { - prefs.models = models; - } -} - -async function configureTimeouts(ctx: ExtensionCommandContext, prefs: Record): Promise { - const autoSup: Record = (prefs.auto_supervisor as Record) ?? {}; - const timeoutFields = [ - { key: "soft_timeout_minutes", label: "Soft timeout (minutes)", defaultVal: "20" }, - { key: "idle_timeout_minutes", label: "Idle timeout (minutes)", defaultVal: "10" }, - { key: "hard_timeout_minutes", label: "Hard timeout (minutes)", defaultVal: "30" }, - ] as const; - - for (const field of timeoutFields) { - const current = autoSup[field.key]; - const currentStr = current !== undefined && current !== null ? String(current) : ""; - const input = await ctx.ui.input( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - currentStr || field.defaultVal, - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - if (val && /^\d+$/.test(val)) { - autoSup[field.key] = Number(val); - } else if (val && !/^\d+$/.test(val)) { - ctx.ui.notify(`Invalid value "${val}" for ${field.label} — must be a whole number. Keeping previous value.`, "warning"); - } else if (!val && currentStr) { - delete autoSup[field.key]; - } - } - } - if (Object.keys(autoSup).length > 0) { - prefs.auto_supervisor = autoSup; - } -} - -async function configureGit(ctx: ExtensionCommandContext, prefs: Record): Promise { - const git: Record = (prefs.git as Record) ?? {}; - - // main_branch - const currentBranch = git.main_branch ? String(git.main_branch) : ""; - const branchInput = await ctx.ui.input( - `Git main branch${currentBranch ? ` (current: ${currentBranch})` : ""}:`, - currentBranch || "main", - ); - if (branchInput !== null && branchInput !== undefined) { - const val = branchInput.trim(); - if (val) { - git.main_branch = val; - } else if (currentBranch) { - delete git.main_branch; - } - } - - // Boolean git toggles - const gitBooleanFields = [ - { key: "auto_push", label: "Auto-push commits after committing", defaultVal: false }, - { key: "push_branches", label: "Push milestone branches to remote", defaultVal: false }, - { key: "snapshots", label: "Create WIP snapshot commits during long tasks", defaultVal: false }, - ] as const; - - for (const field of gitBooleanFields) { - const current = git[field.key]; - const currentStr = current !== undefined ? String(current) : ""; - const choice = await ctx.ui.select( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - ["true", "false", "(keep current)"], - ); - if (choice && choice !== "(keep current)") { - git[field.key] = choice === "true"; - } - } - - // remote - const currentRemote = git.remote ? String(git.remote) : ""; - const remoteInput = await ctx.ui.input( - `Git remote name${currentRemote ? ` (current: ${currentRemote})` : " (default: origin)"}:`, - currentRemote || "origin", - ); - if (remoteInput !== null && remoteInput !== undefined) { - const val = remoteInput.trim(); - if (val && val !== "origin") { - git.remote = val; - } else if (!val && currentRemote) { - delete git.remote; - } - } - - // pre_merge_check - const currentPreMerge = git.pre_merge_check !== undefined ? String(git.pre_merge_check) : ""; - const preMergeChoice = await ctx.ui.select( - `Pre-merge check${currentPreMerge ? ` (current: ${currentPreMerge})` : " (default: false)"}:`, - ["true", "false", "auto", "(keep current)"], - ); - if (preMergeChoice && preMergeChoice !== "(keep current)") { - if (preMergeChoice === "auto") { - git.pre_merge_check = "auto"; - } else { - git.pre_merge_check = preMergeChoice === "true"; - } - } - - // commit_type - const currentCommitType = git.commit_type ? String(git.commit_type) : ""; - const commitTypes = ["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "build", "style", "(inferred — default)", "(keep current)"]; - const commitChoice = await ctx.ui.select( - `Default commit type${currentCommitType ? ` (current: ${currentCommitType})` : ""}:`, - commitTypes, - ); - if (commitChoice && typeof commitChoice === "string" && commitChoice !== "(keep current)") { - if ((commitChoice as string).startsWith("(inferred")) { - delete git.commit_type; - } else { - git.commit_type = commitChoice; - } - } - - // merge_strategy - const currentMerge = git.merge_strategy ? String(git.merge_strategy) : ""; - const mergeChoice = await ctx.ui.select( - `Merge strategy${currentMerge ? ` (current: ${currentMerge})` : ""}:`, - ["squash", "merge", "(keep current)"], - ); - if (mergeChoice && mergeChoice !== "(keep current)") { - git.merge_strategy = mergeChoice; - } - - // isolation - const currentIsolation = git.isolation ? String(git.isolation) : ""; - const isolationChoice = await ctx.ui.select( - `Git isolation strategy${currentIsolation ? ` (current: ${currentIsolation})` : " (default: worktree)"}:`, - ["worktree", "branch", "none", "(keep current)"], - ); - if (isolationChoice && isolationChoice !== "(keep current)") { - git.isolation = isolationChoice; - } - - // commit_docs - const currentCommitDocs = git.commit_docs; - const commitDocsChoice = await ctx.ui.select( - `Track .gsd/ planning docs in git${currentCommitDocs !== undefined ? ` (current: ${currentCommitDocs})` : ""}:`, - ["true", "false", "(keep current)"], - ); - if (commitDocsChoice && commitDocsChoice !== "(keep current)") { - git.commit_docs = commitDocsChoice === "true"; - } - - if (Object.keys(git).length > 0) { - prefs.git = git; - } -} - -async function configureSkills(ctx: ExtensionCommandContext, prefs: Record): Promise { - // Skill discovery mode - const currentDiscovery = (prefs.skill_discovery as string) ?? ""; - const discoveryChoice = await ctx.ui.select( - `Skill discovery mode${currentDiscovery ? ` (current: ${currentDiscovery})` : ""}:`, - ["auto", "suggest", "off", "(keep current)"], - ); - if (discoveryChoice && discoveryChoice !== "(keep current)") { - prefs.skill_discovery = discoveryChoice; - } - - // UAT dispatch - const currentUat = prefs.uat_dispatch; - const uatChoice = await ctx.ui.select( - `UAT dispatch mode${currentUat !== undefined ? ` (current: ${currentUat})` : " (default: false)"}:`, - ["true", "false", "(keep current)"], - ); - if (uatChoice && uatChoice !== "(keep current)") { - prefs.uat_dispatch = uatChoice === "true"; - } -} - -async function configureBudget(ctx: ExtensionCommandContext, prefs: Record): Promise { - const currentCeiling = prefs.budget_ceiling; - const ceilingStr = currentCeiling !== undefined ? String(currentCeiling) : ""; - const ceilingInput = await ctx.ui.input( - `Budget ceiling (USD)${ceilingStr ? ` (current: $${ceilingStr})` : " (default: no limit)"}:`, - ceilingStr || "", - ); - if (ceilingInput !== null && ceilingInput !== undefined) { - const val = ceilingInput.trim().replace(/^\$/, ""); - if (val && !isNaN(Number(val)) && isFinite(Number(val))) { - prefs.budget_ceiling = Number(val); - } else if (val && (isNaN(Number(val)) || !isFinite(Number(val)))) { - ctx.ui.notify(`Invalid budget ceiling "${val}" — must be a number. Keeping previous value.`, "warning"); - } else if (!val && ceilingStr) { - delete prefs.budget_ceiling; - } - } - - const currentEnforcement = (prefs.budget_enforcement as string) ?? ""; - const enforcementChoice = await ctx.ui.select( - `Budget enforcement${currentEnforcement ? ` (current: ${currentEnforcement})` : " (default: pause)"}:`, - ["warn", "pause", "halt", "(keep current)"], - ); - if (enforcementChoice && enforcementChoice !== "(keep current)") { - prefs.budget_enforcement = enforcementChoice; - } - - const currentContextPause = prefs.context_pause_threshold; - const contextPauseStr = currentContextPause !== undefined ? String(currentContextPause) : ""; - const contextPauseInput = await ctx.ui.input( - `Context pause threshold (0-100%, 0=disabled)${contextPauseStr ? ` (current: ${contextPauseStr}%)` : " (default: 0)"}:`, - contextPauseStr || "0", - ); - if (contextPauseInput !== null && contextPauseInput !== undefined) { - const val = contextPauseInput.trim().replace(/%$/, ""); - if (val && !isNaN(Number(val)) && Number(val) >= 0 && Number(val) <= 100) { - const num = Number(val); - if (num === 0) { - delete prefs.context_pause_threshold; - } else { - prefs.context_pause_threshold = num; - } - } else if (val && (isNaN(Number(val)) || Number(val) < 0 || Number(val) > 100)) { - ctx.ui.notify(`Invalid context pause threshold "${val}" — must be 0-100. Keeping previous value.`, "warning"); - } - } -} - -async function configureNotifications(ctx: ExtensionCommandContext, prefs: Record): Promise { - const notif: Record = (prefs.notifications as Record) ?? {}; - const notifFields = [ - { key: "enabled", label: "Notifications enabled (master toggle)", defaultVal: true }, - { key: "on_complete", label: "Notify on unit completion", defaultVal: true }, - { key: "on_error", label: "Notify on errors", defaultVal: true }, - { key: "on_budget", label: "Notify on budget thresholds", defaultVal: true }, - { key: "on_milestone", label: "Notify on milestone completion", defaultVal: true }, - { key: "on_attention", label: "Notify when manual attention needed", defaultVal: true }, - ] as const; - - for (const field of notifFields) { - const current = notif[field.key]; - const currentStr = current !== undefined && typeof current === "boolean" ? String(current) : ""; - const choice = await ctx.ui.select( - `${field.label}${currentStr ? ` (current: ${currentStr})` : ` (default: ${field.defaultVal})`}:`, - ["true", "false", "(keep current)"], - ); - if (choice && choice !== "(keep current)") { - notif[field.key] = choice === "true"; - } - } - if (Object.keys(notif).length > 0) { - prefs.notifications = notif; - } -} - -async function configureMode(ctx: ExtensionCommandContext, prefs: Record): Promise { - const currentMode = prefs.mode as string | undefined; - const modeChoice = await ctx.ui.select( - `Workflow mode${currentMode ? ` (current: ${currentMode})` : ""}:`, - [ - "solo — auto-push, squash, simple IDs (personal projects)", - "team — unique IDs, push branches, pre-merge checks (shared repos)", - "(none) — configure everything manually", - "(keep current)", - ], - ); - const modeStr = typeof modeChoice === "string" ? modeChoice : ""; - if (modeStr && modeStr !== "(keep current)") { - if (modeStr.startsWith("solo")) { - prefs.mode = "solo"; - ctx.ui.notify( - "Mode: solo — defaults: auto_push=true, push_branches=false, pre_merge_check=false, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=false", - "info", - ); - } else if (modeStr.startsWith("team")) { - prefs.mode = "team"; - ctx.ui.notify( - "Mode: team — defaults: auto_push=false, push_branches=true, pre_merge_check=true, merge_strategy=squash, isolation=worktree, commit_docs=true, unique_milestone_ids=true", - "info", - ); - } else { - delete prefs.mode; - } - } -} - -async function configureAdvanced(ctx: ExtensionCommandContext, prefs: Record): Promise { - const currentUnique = prefs.unique_milestone_ids; - const uniqueChoice = await ctx.ui.select( - `Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`, - ["true", "false", "(keep current)"], - ); - if (uniqueChoice && uniqueChoice !== "(keep current)") { - prefs.unique_milestone_ids = uniqueChoice === "true"; - } -} - -// ─── Main wizard with category menu ───────────────────────────────────────── - -async function handlePrefsWizard( - ctx: ExtensionCommandContext, - scope: "global" | "project", -): Promise { - const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath(); - const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences(); - const prefs: Record = existing?.preferences ? { ...existing.preferences } : {}; - - ctx.ui.notify(`GSD preferences (${scope}) — pick a category to configure.`, "info"); - - while (true) { - const summaries = buildCategorySummaries(prefs); - const options = [ - `Workflow Mode ${summaries.mode}`, - `Models ${summaries.models}`, - `Timeouts ${summaries.timeouts}`, - `Git ${summaries.git}`, - `Skills ${summaries.skills}`, - `Budget ${summaries.budget}`, - `Notifications ${summaries.notifications}`, - `Advanced ${summaries.advanced}`, - `── Save & Exit ──`, - ]; - - const raw = await ctx.ui.select("GSD Preferences", options); - const choice = typeof raw === "string" ? raw : ""; - if (!choice || choice.includes("Save & Exit")) break; - - if (choice.startsWith("Workflow Mode")) await configureMode(ctx, prefs); - else if (choice.startsWith("Models")) await configureModels(ctx, prefs); - else if (choice.startsWith("Timeouts")) await configureTimeouts(ctx, prefs); - else if (choice.startsWith("Git")) await configureGit(ctx, prefs); - else if (choice.startsWith("Skills")) await configureSkills(ctx, prefs); - else if (choice.startsWith("Budget")) await configureBudget(ctx, prefs); - else if (choice.startsWith("Notifications")) await configureNotifications(ctx, prefs); - else if (choice.startsWith("Advanced")) await configureAdvanced(ctx, prefs); - } - - // ─── Serialize to frontmatter ─────────────────────────────────────────── - prefs.version = prefs.version || 1; - const frontmatter = serializePreferencesToFrontmatter(prefs); - - // Preserve existing body content (everything after closing ---) - let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n"; - if (existsSync(path)) { - const existingContent = readFileSync(path, "utf-8"); - const closingIdx = existingContent.indexOf("\n---", existingContent.indexOf("---")); - if (closingIdx !== -1) { - const afterFrontmatter = existingContent.slice(closingIdx + 4); // skip past "\n---" - if (afterFrontmatter.trim()) { - body = afterFrontmatter; - } - } - } - - const content = `---\n${frontmatter}---${body}`; - - await saveFile(path, content); - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify(`Saved ${scope} preferences to ${path}`, "info"); -} - -/** Wrap a YAML value in double quotes if it contains special characters. */ -function yamlSafeString(val: unknown): string { - if (typeof val !== "string") return String(val); - if (/[:#{\[\]'"`,|>&*!?@%]/.test(val) || val.trim() !== val || val === "") { - return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; - } - return val; -} - -function serializePreferencesToFrontmatter(prefs: Record): string { - const lines: string[] = []; - - function serializeValue(key: string, value: unknown, indent: number): void { - const prefix = " ".repeat(indent); - if (value === null || value === undefined) return; - - if (Array.isArray(value)) { - if (value.length === 0) { - return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings - } - lines.push(`${prefix}${key}:`); - for (const item of value) { - if (typeof item === "object" && item !== null) { - const entries = Object.entries(item as Record); - if (entries.length > 0) { - const [firstKey, firstVal] = entries[0]; - lines.push(`${prefix} - ${firstKey}: ${yamlSafeString(firstVal)}`); - for (let i = 1; i < entries.length; i++) { - const [k, v] = entries[i]; - if (Array.isArray(v)) { - lines.push(`${prefix} ${k}:`); - for (const arrItem of v) { - lines.push(`${prefix} - ${yamlSafeString(arrItem)}`); - } - } else { - lines.push(`${prefix} ${k}: ${yamlSafeString(v)}`); - } - } - } - } else { - lines.push(`${prefix} - ${yamlSafeString(item)}`); - } - } - return; - } - - if (typeof value === "object") { - const entries = Object.entries(value as Record); - if (entries.length === 0) { - return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings - } - lines.push(`${prefix}${key}:`); - for (const [k, v] of entries) { - serializeValue(k, v, indent + 1); - } - return; - } - - lines.push(`${prefix}${key}: ${yamlSafeString(value)}`); - } - - // Ordered keys for consistent output - const orderedKeys = [ - "version", "mode", "always_use_skills", "prefer_skills", "avoid_skills", - "skill_rules", "custom_instructions", "models", "skill_discovery", - "auto_supervisor", "uat_dispatch", "unique_milestone_ids", - "budget_ceiling", "budget_enforcement", "context_pause_threshold", - "notifications", "remote_questions", "git", - "post_unit_hooks", "pre_dispatch_hooks", - ]; - - const seen = new Set(); - for (const key of orderedKeys) { - if (key in prefs) { - serializeValue(key, prefs[key], 0); - seen.add(key); - } - } - // Any remaining keys not in the ordered list - for (const [key, value] of Object.entries(prefs)) { - if (!seen.has(key)) { - serializeValue(key, value, 0); - } - } - - return lines.join("\n") + "\n"; -} - -// ─── Tool Config Wizard ─────────────────────────────────────────────────────── - -/** - * Tool API key configurations. - * This is the source of truth for tool credentials - used by both the config wizard - * and session startup to load keys from auth.json into environment variables. - */ -export const TOOL_KEYS = [ - { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" }, - { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" }, - { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" }, - { id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" }, - { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" }, -] as const; - -/** - * Load tool API keys from auth.json into environment variables. - * Called at session startup to ensure tools have access to their credentials. - */ -export function loadToolApiKeys(): void { - try { - const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); - if (!existsSync(authPath)) return; - - const auth = AuthStorage.create(authPath); - for (const tool of TOOL_KEYS) { - const cred = auth.get(tool.id); - if (cred && cred.type === "api_key" && cred.key && !process.env[tool.env]) { - process.env[tool.env] = cred.key; - } - } - } catch { - // Failed to load tool keys — ignore, they can still be set via env vars - } -} - -function getConfigAuthStorage(): AuthStorage { - const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); - mkdirSync(dirname(authPath), { recursive: true }); - return AuthStorage.create(authPath); -} - -async function handleConfig(ctx: ExtensionCommandContext): Promise { - const auth = getConfigAuthStorage(); - - // Show current status - const statusLines = ["GSD Tool Configuration\n"]; - for (const tool of TOOL_KEYS) { - const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key; - statusLines.push(` ${hasKey ? "✓" : "✗"} ${tool.label}${hasKey ? "" : ` — get key at ${tool.hint}`}`); - } - ctx.ui.notify(statusLines.join("\n"), "info"); - - // Ask which tools to configure - const options = TOOL_KEYS.map(t => { - const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key; - return `${t.label} ${hasKey ? "(configured ✓)" : "(not set)"}`; - }); - options.push("(done)"); - - let changed = false; - while (true) { - const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options); - if (!choice || typeof choice !== "string" || choice === "(done)") break; - - const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label)); - if (toolIdx === -1) break; - - const tool = TOOL_KEYS[toolIdx]; - const input = await ctx.ui.input( - `API key for ${tool.label} (${tool.hint}):`, - "paste your key here", - ); - - if (input !== null && input !== undefined) { - const key = input.trim(); - if (key) { - auth.set(tool.id, { type: "api_key", key }); - process.env[tool.env] = key; - ctx.ui.notify(`${tool.label} key saved and activated.`, "info"); - // Update option label - options[toolIdx] = `${tool.label} (configured ✓)`; - changed = true; - } - } - } - - if (changed) { - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info"); - } -} - -async function ensurePreferencesFile( - path: string, - ctx: ExtensionCommandContext, - scope: "global" | "project", -): Promise { - if (!existsSync(path)) { - const template = await loadFile(join(dirname(fileURLToPath(import.meta.url)), "templates", "preferences.md")); - if (!template) { - ctx.ui.notify("Could not load GSD preferences template.", "error"); - return; - } - await saveFile(path, template); - ctx.ui.notify(`Created ${scope} GSD skill preferences at ${path}`, "info"); - } else { - ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info"); - } - -} - -// ─── Skip handler ───────────────────────────────────────────────────────────── - -async function handleSkip(unitArg: string, ctx: ExtensionCommandContext, basePath: string): Promise { - if (!unitArg) { - ctx.ui.notify("Usage: /gsd skip (e.g., /gsd skip execute-task/M001/S01/T03 or /gsd skip T03)", "info"); - return; - } - - const { existsSync: fileExists, writeFileSync: writeFile, mkdirSync: mkDir, readFileSync: readFile } = await import("node:fs"); - const { join: pathJoin } = await import("node:path"); - - const completedKeysFile = pathJoin(basePath, ".gsd", "completed-units.json"); - let keys: string[] = []; - try { - if (fileExists(completedKeysFile)) { - keys = JSON.parse(readFile(completedKeysFile, "utf-8")); - } - } catch { /* start fresh */ } - - // Normalize: accept "execute-task/M001/S01/T03", "M001/S01/T03", or just "T03" - let skipKey = unitArg; - - if (!skipKey.includes("execute-task") && !skipKey.includes("plan-") && !skipKey.includes("research-") && !skipKey.includes("complete-")) { - const state = await deriveState(basePath); - const mid = state.activeMilestone?.id; - const sid = state.activeSlice?.id; - - if (unitArg.match(/^T\d+$/i) && mid && sid) { - skipKey = `execute-task/${mid}/${sid}/${unitArg.toUpperCase()}`; - } else if (unitArg.match(/^S\d+$/i) && mid) { - skipKey = `plan-slice/${mid}/${unitArg.toUpperCase()}`; - } else if (unitArg.includes("/")) { - skipKey = `execute-task/${unitArg}`; - } - } - - if (keys.includes(skipKey)) { - ctx.ui.notify(`Already skipped: ${skipKey}`, "info"); - return; - } - - keys.push(skipKey); - mkDir(pathJoin(basePath, ".gsd"), { recursive: true }); - writeFile(completedKeysFile, JSON.stringify(keys), "utf-8"); - - ctx.ui.notify(`Skipped: ${skipKey}. Will not be dispatched in auto-mode.`, "success"); -} - -// ─── Dry-run handler ────────────────────────────────────────────────────────── - -async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Promise { - const state = await deriveState(basePath); - - if (!state.activeMilestone) { - ctx.ui.notify("No active milestone — nothing to dispatch.", "info"); - return; - } - - const { getLedger, getProjectTotals, formatCost, formatTokenCount, loadLedgerFromDisk } = await import("./metrics.js"); - const { loadEffectiveGSDPreferences: loadPrefs } = await import("./preferences.js"); - const { formatDuration } = await import("../shared/format-utils.js"); - - const ledger = getLedger(); - const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? []; - const prefs = loadPrefs()?.preferences; - - let nextType = "unknown"; - let nextId = "unknown"; - - const mid = state.activeMilestone.id; - const midTitle = state.activeMilestone.title; - - if (state.phase === "pre-planning") { - nextType = "research-milestone"; - nextId = mid; - } else if (state.phase === "planning" && state.activeSlice) { - nextType = "plan-slice"; - nextId = `${mid}/${state.activeSlice.id}`; - } else if (state.phase === "executing" && state.activeTask && state.activeSlice) { - nextType = "execute-task"; - nextId = `${mid}/${state.activeSlice.id}/${state.activeTask.id}`; - } else if (state.phase === "summarizing" && state.activeSlice) { - nextType = "complete-slice"; - nextId = `${mid}/${state.activeSlice.id}`; - } else if (state.phase === "completing-milestone") { - nextType = "complete-milestone"; - nextId = mid; - } else { - nextType = state.phase; - nextId = mid; - } - - const sameTypeUnits = units.filter(u => u.type === nextType); - const avgCost = sameTypeUnits.length > 0 - ? sameTypeUnits.reduce((s, u) => s + u.cost, 0) / sameTypeUnits.length - : null; - const avgDuration = sameTypeUnits.length > 0 - ? sameTypeUnits.reduce((s, u) => s + (u.finishedAt - u.startedAt), 0) / sameTypeUnits.length - : null; - - const totals = units.length > 0 ? getProjectTotals(units) : null; - const budgetRemaining = prefs?.budget_ceiling && totals - ? prefs.budget_ceiling - totals.cost - : null; - - const lines = [ - `Dry-run preview:`, - ``, - ` Next unit: ${nextType}`, - ` ID: ${nextId}`, - ` Milestone: ${mid}: ${midTitle}`, - ` Phase: ${state.phase}`, - ` Est. cost: ${avgCost !== null ? `${formatCost(avgCost)} (avg of ${sameTypeUnits.length} similar)` : "unknown (first of this type)"}`, - ` Est. duration: ${avgDuration !== null ? formatDuration(avgDuration) : "unknown"}`, - ` Spent so far: ${totals ? formatCost(totals.cost) : "$0"}`, - ` Budget left: ${budgetRemaining !== null ? formatCost(budgetRemaining) : "no ceiling set"}`, - ]; - - if (state.progress) { - const p = state.progress; - lines.push(` Progress: ${p.tasks?.done ?? 0}/${p.tasks?.total ?? "?"} tasks, ${p.slices?.done ?? 0}/${p.slices?.total ?? "?"} slices`); - } - - ctx.ui.notify(lines.join("\n"), "info"); -} - -// ─── Branch cleanup handler ────────────────────────────────────────────────── - -async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise { - let branches: string[]; - try { - branches = nativeBranchList(basePath, "gsd/*"); - } catch { - ctx.ui.notify("No GSD branches found.", "info"); - return; - } - - if (branches.length === 0) { - ctx.ui.notify("No GSD branches to clean up.", "info"); - return; - } - - const mainBranch = nativeDetectMainBranch(basePath); - - let merged: string[]; - try { - merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*"); - } catch { - merged = []; - } - - if (merged.length === 0) { - ctx.ui.notify(`${branches.length} GSD branches found, none are merged into ${mainBranch} yet.`, "info"); - return; - } - - let deleted = 0; - for (const branch of merged) { - try { - nativeBranchDelete(basePath, branch, false); - deleted++; - } catch { /* skip branches that can't be deleted */ } - } - - ctx.ui.notify(`Cleaned up ${deleted} merged branches. ${branches.length - deleted} remain.`, "success"); -} - -// ─── Snapshot cleanup handler ───────────────────────────────────────────────── - -async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise { - let refs: string[]; - try { - refs = nativeForEachRef(basePath, "refs/gsd/snapshots/"); - } catch { - ctx.ui.notify("No snapshot refs found.", "info"); - return; - } - - if (refs.length === 0) { - ctx.ui.notify("No snapshot refs to clean up.", "info"); - return; - } - - const byLabel = new Map(); - for (const ref of refs) { - const parts = ref.split("/"); - const label = parts.slice(0, -1).join("/"); - if (!byLabel.has(label)) byLabel.set(label, []); - byLabel.get(label)!.push(ref); - } - - let pruned = 0; - for (const [, labelRefs] of byLabel) { - const sorted = labelRefs.sort(); - for (const old of sorted.slice(0, -5)) { - try { - nativeUpdateRef(basePath, old); - pruned++; - } catch { /* skip */ } - } - } - - ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); -} - -async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Promise { - const parts = args.split(/\s+/); - const typeArg = parts[0]?.toLowerCase(); - - if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) { - ctx.ui.notify( - "Usage: /gsd knowledge \nExample: /gsd knowledge rule Use real DB for integration tests", - "warning", - ); - return; - } - - const entryText = parts.slice(1).join(" ").trim(); - if (!entryText) { - ctx.ui.notify(`Usage: /gsd knowledge ${typeArg} `, "warning"); - return; - } - - const type = typeArg as "rule" | "pattern" | "lesson"; - const basePath = process.cwd(); - const state = await deriveState(basePath); - const scope = state.activeMilestone?.id - ? `${state.activeMilestone.id}${state.activeSlice ? `/${state.activeSlice.id}` : ""}` - : "global"; - - await appendKnowledge(basePath, type, entryText, scope); - 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); - const mid = state.activeMilestone?.id ?? "none"; - const sid = state.activeSlice?.id ?? "none"; - const tid = state.activeTask?.id ?? "none"; - const appliedAt = `${mid}/${sid}/${tid}`; - await appendOverride(basePath, change, appliedAt); - - if (isAutoActive()) { - pi.sendMessage({ - customType: "gsd-hard-steer", - content: [ - "HARD STEER — User override registered.", - "", - `**Override:** ${change}`, - "", - "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.", - "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", - "", - "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", - ].join("\n"), - display: false, - }, { triggerTurn: true }); - ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info"); - } else { - pi.sendMessage({ - customType: "gsd-hard-steer", - content: [ - "HARD STEER — User override registered.", - "", - `**Override:** ${change}`, - "", - "This override has been saved to `.gsd/OVERRIDES.md`.", - "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.", - "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", - ].join("\n"), - display: false, - }, { triggerTurn: true }); - ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info"); - } -} - -async function handleRunHook(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { - const parts = args.trim().split(/\s+/); - if (parts.length < 3) { - ctx.ui.notify(`Usage: /gsd run-hook - -Unit types: - execute-task - Task execution (unit-id: M001/S01/T01) - plan-slice - Slice planning (unit-id: M001/S01) - research-milestone - Milestone research (unit-id: M001) - complete-slice - Slice completion (unit-id: M001/S01) - complete-milestone - Milestone completion (unit-id: M001) - -Examples: - /gsd run-hook code-review execute-task M001/S01/T01 - /gsd run-hook lint-check plan-slice M001/S01`, "warning"); - return; - } - - const [hookName, unitType, unitId] = parts; - const basePath = projectRoot(); - - // Import the hook trigger function - const { triggerHookManually, formatHookStatus, getHookStatus } = await import("./post-unit-hooks.js"); - const { dispatchHookUnit } = await import("./auto.js"); - - // Check if the hook exists - const hooks = getHookStatus(); - const hookExists = hooks.some(h => h.name === hookName); - if (!hookExists) { - ctx.ui.notify(`Hook "${hookName}" not found. Configured hooks:\n${formatHookStatus()}`, "error"); - return; - } - - // Validate unit ID format - const unitIdPattern = /^M\d{3}\/S\d{2,3}\/T\d{2,3}$/; - if (!unitIdPattern.test(unitId)) { - ctx.ui.notify(`Invalid unit ID format: "${unitId}". Expected format: M004/S04/T03`, "warning"); - return; - } - - // Trigger the hook manually - const hookUnit = triggerHookManually(hookName, unitType, unitId, basePath); - if (!hookUnit) { - ctx.ui.notify(`Failed to trigger hook "${hookName}". The hook may be disabled or not configured for unit type "${unitType}".`, "error"); - return; - } - - ctx.ui.notify(`Manually triggering hook: ${hookName} for ${unitType} ${unitId}`, "info"); - - // Dispatch the hook unit directly, bypassing normal pre-dispatch hooks - const success = await dispatchHookUnit( - ctx, - pi, - hookName, - unitType, - unitId, - hookUnit.prompt, - hookUnit.model, - basePath, - ); - - if (!success) { - ctx.ui.notify("Failed to dispatch hook. Auto-mode may have been cancelled.", "error"); - } -} - -// ─── Self-update handler ──────────────────────────────────────────────────── - -function compareSemverLocal(a: string, b: string): number { - const pa = a.split('.').map(Number) - const pb = b.split('.').map(Number) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const va = pa[i] || 0 - const vb = pb[i] || 0 - if (va > vb) return 1 - if (va < vb) return -1 - } - return 0 -} - -async function handleUpdate(ctx: ExtensionCommandContext): Promise { - const { execSync } = await import("node:child_process"); - - const NPM_PACKAGE = "gsd-pi"; - const current = process.env.GSD_VERSION || "0.0.0"; - - ctx.ui.notify(`Current version: v${current}\nChecking npm registry...`, "info"); - - let latest: string; - try { - latest = execSync(`npm view ${NPM_PACKAGE} version`, { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - ctx.ui.notify("Failed to reach npm registry. Check your network connection.", "error"); - return; - } - - if (compareSemverLocal(latest, current) <= 0) { - ctx.ui.notify(`Already up to date (v${current}).`, "info"); - return; - } - - ctx.ui.notify(`Updating: v${current} → v${latest}...`, "info"); - - try { - execSync(`npm install -g ${NPM_PACKAGE}@latest`, { - stdio: ["ignore", "pipe", "ignore"], - }); - ctx.ui.notify( - `Updated to v${latest}. Restart your GSD session to use the new version.`, - "info", - ); - } catch { - ctx.ui.notify( - `Update failed. Try manually: npm install -g ${NPM_PACKAGE}@latest`, - "error", - ); - } -}