Merge pull request #1093 from gsd-build/refactor/decompose-commands-phase2

refactor: decompose commands.ts into 5 focused modules
This commit is contained in:
TÂCHES 2026-03-17 22:25:45 -06:00 committed by GitHub
commit 79303deb30
6 changed files with 1570 additions and 1506 deletions

View file

@ -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<void> {
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");
}
}

View file

@ -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<void> {
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<void> {
const {
generateSkillHealthReport,
formatSkillHealthReport,
formatSkillDetail,
} = await import("./skill-health.js");
const basePath = projectRoot();
// /gsd skill-health <skill-name> — 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<void> {
// 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<void> {
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<void> {
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<void> {
const parts = args.split(/\s+/);
const typeArg = parts[0]?.toLowerCase();
if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) {
ctx.ui.notify(
"Usage: /gsd knowledge <rule|pattern|lesson> <description>\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} <description>`, "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<void> {
const parts = args.trim().split(/\s+/);
if (parts.length < 3) {
ctx.ui.notify(`Usage: /gsd run-hook <hook-name> <unit-type> <unit-id>
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<void> {
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",
);
}
}

View file

@ -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<void> {
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");
}
}

View file

@ -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<void> {
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<void> {
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<string, string[]>();
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<void> {
if (!unitArg) {
ctx.ui.notify("Usage: /gsd skip <unit-id> (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<void> {
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");
}

View file

@ -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<void> {
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<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
if (!existsSync(path)) {
await ensurePreferencesFile(path, ctx, scope);
}
const readPrefs = (): Record<string, unknown> => {
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<string, unknown>): Promise<void> => {
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<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = 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<string, unknown>): Record<string, string> {
// Mode
const mode = prefs.mode as string | undefined;
const modeSummary = mode ?? "(not set)";
// Models
const models = prefs.models as Record<string, string> | 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<string, unknown> | 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<string, unknown> | 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<string, boolean> | 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<string, unknown>): Promise<void> {
const modelPhases = ["research", "planning", "execution", "completion"] as const;
const models: Record<string, string> = (prefs.models as Record<string, string>) ?? {};
const availableModels = ctx.modelRegistry.getAvailable();
if (availableModels.length > 0) {
// Group models by provider, sorted alphabetically
const byProvider = new Map<string, typeof availableModels>();
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<string, unknown>): Promise<void> {
const autoSup: Record<string, unknown> = (prefs.auto_supervisor as Record<string, unknown>) ?? {};
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<string, unknown>): Promise<void> {
const git: Record<string, unknown> = (prefs.git as Record<string, unknown>) ?? {};
// 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<string, unknown>): Promise<void> {
// 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<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
const notif: Record<string, boolean> = (prefs.notifications as Record<string, boolean>) ?? {};
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<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
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<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = 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, unknown>): 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<string, unknown>);
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<string, unknown>);
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<string>();
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<void> {
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");
}
}

File diff suppressed because it is too large Load diff