From 3e37264e3fdae453acf0a3db302f6863bd723b3f Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 14:08:08 -0500 Subject: [PATCH] =?UTF-8?q?feat(gsd):=20add=20v1=E2=86=92v2=20command=20pa?= =?UTF-8?q?rity=20=E2=80=94=2012=20missing=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 12 commands that exist in GSDv1 but had no v2 equivalent: High priority: - ship: Create PR from milestone artifacts (title, body, metrics) - add-slice: Append slice to roadmap via engine updateRoadmap() - insert-slice: Insert slice at position with reordering - remove-slice: Remove pending slice (--force for planned slices) - do: Natural language routing via keyword matching (30 routes) - session-report: Session cost/tokens/work summary (--json, --save) Medium priority: - backlog: Structured backlog with 999.x numbering (add/promote/remove) - pr-branch: Clean PR branch filtering .gsd/ commits via cherry-pick - add-tests: LLM-dispatched test generation for completed slices - map-codebase: Codebase analysis (tech/arch/quality/concerns) All slice mutations go through the engine's updateRoadmap() command, preserving the single-writer architecture. No direct markdown edits. Includes 46 unit tests across 6 test files, 2 prompt templates, catalog entries with nested completions for all commands. --- .../extensions/gsd/commands-add-tests.ts | 127 ++++++++++ .../extensions/gsd/commands-backlog.ts | 184 ++++++++++++++ src/resources/extensions/gsd/commands-do.ts | 112 +++++++++ .../extensions/gsd/commands-map-codebase.ts | 64 +++++ .../extensions/gsd/commands-pr-branch.ts | 136 +++++++++++ .../extensions/gsd/commands-session-report.ts | 100 ++++++++ src/resources/extensions/gsd/commands-ship.ts | 179 ++++++++++++++ .../extensions/gsd/commands-slice-mutation.ts | 226 ++++++++++++++++++ .../extensions/gsd/commands/catalog.ts | 45 +++- .../extensions/gsd/commands/handlers/ops.ts | 25 ++ .../gsd/commands/handlers/workflow.ts | 28 +++ .../extensions/gsd/prompts/add-tests.md | 35 +++ .../extensions/gsd/prompts/map-codebase.md | 62 +++++ .../gsd/tests/commands-backlog.test.ts | 158 ++++++++++++ .../extensions/gsd/tests/commands-do.test.ts | 135 +++++++++++ .../gsd/tests/commands-pr-branch.test.ts | 68 ++++++ .../gsd/tests/commands-session-report.test.ts | 82 +++++++ .../gsd/tests/commands-ship.test.ts | 71 ++++++ .../gsd/tests/commands-slice-mutation.test.ts | 110 +++++++++ 19 files changed, 1946 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/commands-add-tests.ts create mode 100644 src/resources/extensions/gsd/commands-backlog.ts create mode 100644 src/resources/extensions/gsd/commands-do.ts create mode 100644 src/resources/extensions/gsd/commands-map-codebase.ts create mode 100644 src/resources/extensions/gsd/commands-pr-branch.ts create mode 100644 src/resources/extensions/gsd/commands-session-report.ts create mode 100644 src/resources/extensions/gsd/commands-ship.ts create mode 100644 src/resources/extensions/gsd/commands-slice-mutation.ts create mode 100644 src/resources/extensions/gsd/prompts/add-tests.md create mode 100644 src/resources/extensions/gsd/prompts/map-codebase.md create mode 100644 src/resources/extensions/gsd/tests/commands-backlog.test.ts create mode 100644 src/resources/extensions/gsd/tests/commands-do.test.ts create mode 100644 src/resources/extensions/gsd/tests/commands-pr-branch.test.ts create mode 100644 src/resources/extensions/gsd/tests/commands-session-report.test.ts create mode 100644 src/resources/extensions/gsd/tests/commands-ship.test.ts create mode 100644 src/resources/extensions/gsd/tests/commands-slice-mutation.test.ts diff --git a/src/resources/extensions/gsd/commands-add-tests.ts b/src/resources/extensions/gsd/commands-add-tests.ts new file mode 100644 index 000000000..a88a9b069 --- /dev/null +++ b/src/resources/extensions/gsd/commands-add-tests.ts @@ -0,0 +1,127 @@ +/** + * GSD Command — /gsd add-tests + * + * Generates tests for a completed slice by dispatching an LLM prompt + * with implementation context (summaries, changed files, test patterns). + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { deriveState } from "./state.js"; +import { gsdRoot } from "./paths.js"; +import { loadPrompt } from "./prompt-loader.js"; + +function findLastCompletedSlice(state: { activeMilestone?: { slices?: Array<{ id: string; status: string }> } }): string | null { + const slices = state.activeMilestone?.slices ?? []; + for (let i = slices.length - 1; i >= 0; i--) { + if (slices[i].status === "done" || slices[i].status === "completed") { + return slices[i].id; + } + } + return null; +} + +function readSliceSummary(basePath: string, milestoneId: string, sliceId: string): { title: string; content: string } { + const summaryPath = join(gsdRoot(basePath), "milestones", milestoneId, sliceId, "SUMMARY.md"); + if (existsSync(summaryPath)) { + const content = readFileSync(summaryPath, "utf-8"); + const titleMatch = content.match(/^#\s+(.+)/m); + return { title: titleMatch?.[1] ?? sliceId, content }; + } + return { title: sliceId, content: "(no summary available)" }; +} + +function detectTestPatterns(basePath: string): string { + const patterns: string[] = []; + + // Check for common test configs + const checks = [ + { file: "jest.config.ts", name: "Jest" }, + { file: "jest.config.js", name: "Jest" }, + { file: "vitest.config.ts", name: "Vitest" }, + { file: "vitest.config.js", name: "Vitest" }, + { file: ".mocharc.yml", name: "Mocha" }, + ]; + + for (const check of checks) { + if (existsSync(join(basePath, check.file))) { + patterns.push(`Framework: ${check.name} (${check.file})`); + } + } + + // Look for existing test files to infer patterns + const testDirs = ["tests", "test", "src/__tests__", "__tests__"]; + for (const dir of testDirs) { + const fullDir = join(basePath, dir); + if (existsSync(fullDir)) { + try { + const files = readdirSync(fullDir).filter((f) => f.endsWith(".test.ts") || f.endsWith(".spec.ts") || f.endsWith(".test.js")); + if (files.length > 0) { + patterns.push(`Test directory: ${dir}/ (${files.length} test files)`); + // Read first test file for patterns + const samplePath = join(fullDir, files[0]); + const sample = readFileSync(samplePath, "utf-8").slice(0, 500); + patterns.push(`Sample pattern from ${files[0]}:\n${sample}`); + break; + } + } catch { + // non-fatal + } + } + } + + return patterns.length > 0 ? patterns.join("\n") : "No test framework detected. Use Node.js built-in test runner."; +} + +export async function handleAddTests( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + const basePath = process.cwd(); + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone.", "warning"); + return; + } + + const milestoneId = state.activeMilestone.id; + + // Determine target + const targetId = args.trim() || findLastCompletedSlice(state); + if (!targetId) { + ctx.ui.notify( + "No completed slices found. Specify a slice ID: /gsd add-tests S03", + "warning", + ); + return; + } + + // Gather context + const summary = readSliceSummary(basePath, milestoneId, targetId); + const testPatterns = detectTestPatterns(basePath); + + ctx.ui.notify(`Generating tests for ${targetId}: "${summary.title}"...`, "info"); + + try { + const prompt = loadPrompt("add-tests", { + sliceId: targetId, + sliceTitle: summary.title, + sliceSummary: summary.content, + existingTestPatterns: testPatterns, + workingDirectory: basePath, + }); + + pi.sendMessage( + { customType: "gsd-add-tests", content: prompt, display: false }, + { triggerTurn: true }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to dispatch test generation: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/commands-backlog.ts b/src/resources/extensions/gsd/commands-backlog.ts new file mode 100644 index 000000000..a72bee638 --- /dev/null +++ b/src/resources/extensions/gsd/commands-backlog.ts @@ -0,0 +1,184 @@ +/** + * GSD Command — /gsd backlog + * + * Structured backlog management with 999.x numbering. + * Items stored in .gsd/BACKLOG.md as markdown checklist. + * Items can be promoted to active slices via add-slice. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; + +import { gsdRoot } from "./paths.js"; + +interface BacklogItem { + id: string; + title: string; + done: boolean; + note: string; +} + +function backlogPath(basePath: string): string { + return join(gsdRoot(basePath), "BACKLOG.md"); +} + +function parseBacklog(basePath: string): BacklogItem[] { + const filePath = backlogPath(basePath); + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, "utf-8"); + const items: BacklogItem[] = []; + + for (const line of content.split("\n")) { + const match = line.match(/^- \[([ x])\] (999\.\d+) — (.+?)(?:\s*\((.+)\))?$/); + if (match) { + items.push({ + id: match[2], + title: match[3].trim(), + done: match[1] === "x", + note: match[4] ?? "", + }); + } + } + + return items; +} + +function writeBacklog(basePath: string, items: BacklogItem[]): void { + const filePath = backlogPath(basePath); + mkdirSync(dirname(filePath), { recursive: true }); + const lines = ["# Backlog\n"]; + for (const item of items) { + const check = item.done ? "x" : " "; + const note = item.note ? ` (${item.note})` : ""; + lines.push(`- [${check}] ${item.id} — ${item.title}${note}`); + } + lines.push(""); // trailing newline + writeFileSync(filePath, lines.join("\n"), "utf-8"); +} + +function nextBacklogId(items: BacklogItem[]): string { + let maxNum = 0; + for (const item of items) { + const match = item.id.match(/^999\.(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + return `999.${maxNum + 1}`; +} + +async function listBacklog(basePath: string, ctx: ExtensionCommandContext): Promise { + const items = parseBacklog(basePath); + if (items.length === 0) { + ctx.ui.notify("Backlog is empty. Add items with /gsd backlog add ", "info"); + return; + } + + const lines = ["Backlog:\n"]; + for (const item of items) { + const status = item.done ? "✓" : "○"; + const note = item.note ? ` (${item.note})` : ""; + lines.push(` ${status} ${item.id} — ${item.title}${note}`); + } + const pending = items.filter((i) => !i.done).length; + lines.push(`\n${pending} pending, ${items.length - pending} promoted/done`); + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function addBacklogItem(basePath: string, title: string, ctx: ExtensionCommandContext): Promise<void> { + if (!title) { + ctx.ui.notify("Usage: /gsd backlog add <title>", "warning"); + return; + } + + const items = parseBacklog(basePath); + const id = nextBacklogId(items); + const date = new Date().toISOString().slice(0, 10); + + items.push({ id, title: title.replace(/^['"]|['"]$/g, ""), done: false, note: `added ${date}` }); + writeBacklog(basePath, items); + + ctx.ui.notify(`Added ${id}: "${title}"`, "success"); +} + +async function promoteBacklogItem( + basePath: string, + itemId: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise<void> { + if (!itemId) { + ctx.ui.notify("Usage: /gsd backlog promote <id>\nExample: /gsd backlog promote 999.1", "warning"); + return; + } + + const items = parseBacklog(basePath); + const item = items.find((i) => i.id === itemId); + + if (!item) { + ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning"); + return; + } + + if (item.done) { + ctx.ui.notify(`${itemId} is already promoted/done.`, "info"); + return; + } + + // Promote via add-slice + const { handleAddSlice } = await import("./commands-slice-mutation.js"); + await handleAddSlice(item.title, ctx, pi); + + // Mark as done + item.done = true; + item.note = `promoted ${new Date().toISOString().slice(0, 10)}`; + writeBacklog(basePath, items); +} + +async function removeBacklogItem(basePath: string, itemId: string, ctx: ExtensionCommandContext): Promise<void> { + if (!itemId) { + ctx.ui.notify("Usage: /gsd backlog remove <id>", "warning"); + return; + } + + const items = parseBacklog(basePath); + const idx = items.findIndex((i) => i.id === itemId); + + if (idx === -1) { + ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning"); + return; + } + + const removed = items.splice(idx, 1)[0]; + writeBacklog(basePath, items); + ctx.ui.notify(`Removed ${removed.id}: "${removed.title}"`, "success"); +} + +export async function handleBacklog( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + const rest = parts.slice(1).join(" "); + + switch (sub) { + case "": + return listBacklog(basePath, ctx); + case "add": + return addBacklogItem(basePath, rest, ctx); + case "promote": + return promoteBacklogItem(basePath, rest.trim(), ctx, pi); + case "remove": + return removeBacklogItem(basePath, rest.trim(), ctx); + default: + // Treat as implicit add + return addBacklogItem(basePath, args, ctx); + } +} diff --git a/src/resources/extensions/gsd/commands-do.ts b/src/resources/extensions/gsd/commands-do.ts new file mode 100644 index 000000000..38fbade1b --- /dev/null +++ b/src/resources/extensions/gsd/commands-do.ts @@ -0,0 +1,112 @@ +/** + * GSD Command — /gsd do + * + * Routes freeform natural language to the correct /gsd subcommand + * using keyword matching. Falls back to /gsd quick for task-like input. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +interface Route { + keywords: string[]; + command: string; +} + +const ROUTES: Route[] = [ + { keywords: ["progress", "status", "dashboard", "how far", "where are we"], command: "status" }, + { keywords: ["auto", "autonomous", "run all", "keep going", "start auto"], command: "auto" }, + { keywords: ["stop", "halt", "abort"], command: "stop" }, + { keywords: ["pause", "break", "take a break"], command: "pause" }, + { keywords: ["history", "past", "what happened", "previous"], command: "history" }, + { keywords: ["doctor", "health", "diagnose", "check health"], command: "doctor" }, + { keywords: ["clean up", "cleanup", "remove old", "prune", "tidy"], command: "cleanup" }, + { keywords: ["export", "report", "share results"], command: "export" }, + { keywords: ["ship", "pull request", "create pr", "open pr", "merge"], command: "ship" }, + { keywords: ["discuss", "talk about", "architecture", "design"], command: "discuss" }, + { keywords: ["add slice", "new slice", "add scope", "expand scope"], command: "add-slice" }, + { keywords: ["remove slice", "delete slice", "drop slice"], command: "remove-slice" }, + { keywords: ["undo", "revert", "rollback", "take back"], command: "undo" }, + { keywords: ["skip", "skip task", "skip this"], command: "skip" }, + { keywords: ["queue", "reorder", "milestone order", "order milestones"], command: "queue" }, + { keywords: ["visualize", "viz", "graph", "chart", "show graph"], command: "visualize" }, + { keywords: ["capture", "note", "idea", "thought", "remember"], command: "capture" }, + { keywords: ["inspect", "database", "sqlite", "db state"], command: "inspect" }, + { keywords: ["knowledge", "rule", "pattern", "lesson"], command: "knowledge" }, + { keywords: ["session report", "session summary", "cost summary", "how much"], command: "session-report" }, + { keywords: ["backlog", "parking lot", "later", "someday"], command: "backlog" }, + { keywords: ["pr branch", "clean branch", "filter commits"], command: "pr-branch" }, + { keywords: ["add tests", "write tests", "generate tests", "test coverage"], command: "add-tests" }, + { keywords: ["map codebase", "analyze code", "codebase analysis", "explore code"], command: "map-codebase" }, + { keywords: ["next", "step", "next step", "what's next"], command: "next" }, + { keywords: ["migrate", "migration", "convert", "upgrade"], command: "migrate" }, + { keywords: ["steer", "change direction", "pivot", "redirect"], command: "steer" }, + { keywords: ["park", "shelve", "set aside"], command: "park" }, + { keywords: ["widget", "toggle widget"], command: "widget" }, + { keywords: ["logs", "debug logs", "log files"], command: "logs" }, +]; + +interface MatchResult { + command: string; + remainingArgs: string; + score: number; +} + +function matchRoute(input: string): MatchResult | null { + const lower = input.toLowerCase(); + let bestMatch: MatchResult | null = null; + + for (const route of ROUTES) { + for (const keyword of route.keywords) { + if (lower.includes(keyword)) { + const score = keyword.length; // Longer match = higher confidence + if (!bestMatch || score > bestMatch.score) { + // Strip the matched keyword from input to get remaining args + const idx = lower.indexOf(keyword); + const remaining = (input.slice(0, idx) + input.slice(idx + keyword.length)).trim(); + bestMatch = { command: route.command, remainingArgs: remaining, score }; + } + } + } + } + + return bestMatch; +} + +export async function handleDo( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise<void> { + if (!args.trim()) { + ctx.ui.notify( + "Usage: /gsd do <what you want to do>\n\n" + + "Examples:\n" + + " /gsd do show me progress\n" + + " /gsd do run autonomously\n" + + " /gsd do clean up old branches\n" + + " /gsd do fix the login bug", + "warning", + ); + return; + } + + const match = matchRoute(args); + + if (match) { + const fullCommand = match.remainingArgs + ? `${match.command} ${match.remainingArgs}` + : match.command; + + ctx.ui.notify(`→ /gsd ${fullCommand}`, "info"); + + // Re-dispatch through the main dispatcher + const { handleGSDCommand } = await import("./commands/dispatcher.js"); + await handleGSDCommand(fullCommand, ctx, pi); + return; + } + + // No keyword match → treat as quick task + ctx.ui.notify(`→ /gsd quick ${args}`, "info"); + const { handleQuick } = await import("./quick.js"); + await handleQuick(args, ctx, pi); +} diff --git a/src/resources/extensions/gsd/commands-map-codebase.ts b/src/resources/extensions/gsd/commands-map-codebase.ts new file mode 100644 index 000000000..117ceec7c --- /dev/null +++ b/src/resources/extensions/gsd/commands-map-codebase.ts @@ -0,0 +1,64 @@ +/** + * GSD Command — /gsd map-codebase + * + * Runs codebase analysis and produces structured documents in .gsd/codebase/. + * Supports focused analysis: tech, arch, quality, concerns. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { gsdRoot } from "./paths.js"; +import { loadPrompt } from "./prompt-loader.js"; + +const FOCUS_AREAS = ["tech", "arch", "quality", "concerns"] as const; +type FocusArea = typeof FOCUS_AREAS[number]; + +const FOCUS_DESCRIPTIONS: Record<FocusArea, string> = { + tech: "Technology stack: languages, frameworks, build tools, dependencies, and runtime environment", + arch: "Architecture patterns: module structure, data flow, design patterns, coupling, and boundaries", + quality: "Code quality: test coverage, linting, type safety, documentation, and technical debt", + concerns: "Risk areas: security vulnerabilities, performance bottlenecks, fragility, and maintenance burden", +}; + +export async function handleMapCodebase( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const focus = args.trim().toLowerCase() as FocusArea; + + const targets: FocusArea[] = FOCUS_AREAS.includes(focus) + ? [focus] + : [...FOCUS_AREAS]; + + // Ensure output directory + const outDir = join(gsdRoot(basePath), "codebase"); + mkdirSync(outDir, { recursive: true }); + + ctx.ui.notify( + `Mapping codebase: ${targets.join(", ")}...\nOutput: ${outDir}/`, + "info", + ); + + // Dispatch analysis + try { + const prompt = loadPrompt("map-codebase", { + focusAreas: targets.join(", "), + focusDescriptions: targets.map((a) => `- **${a.toUpperCase()}**: ${FOCUS_DESCRIPTIONS[a]}`).join("\n"), + outputDirectory: outDir, + workingDirectory: basePath, + }); + + pi.sendMessage( + { customType: "gsd-map-codebase", content: prompt, display: false }, + { triggerTurn: true }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to dispatch codebase analysis: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/commands-pr-branch.ts b/src/resources/extensions/gsd/commands-pr-branch.ts new file mode 100644 index 000000000..e4ea0300f --- /dev/null +++ b/src/resources/extensions/gsd/commands-pr-branch.ts @@ -0,0 +1,136 @@ +/** + * GSD Command — /gsd pr-branch + * + * Creates a clean PR branch by cherry-picking only commits that + * touch non-.gsd/ files. Useful for upstream PRs where .gsd/ + * planning artifacts should not be included. + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { execSync } from "node:child_process"; + +import { + nativeGetCurrentBranch, + nativeDetectMainBranch, + nativeBranchExists, +} from "./native-git-bridge.js"; + +function git(basePath: string, args: string): string { + return execSync(`git ${args}`, { cwd: basePath, encoding: "utf-8" }).trim(); +} + +function getCodeOnlyCommits(basePath: string, base: string, head: string): string[] { + // Get commits that have changes outside .gsd/ and .planning/ + try { + const allCommits = git(basePath, `log --format=%H ${base}..${head}`).split("\n").filter(Boolean); + const codeCommits: string[] = []; + + for (const sha of allCommits) { + // Get files changed in this commit + const files = git(basePath, `diff-tree --no-commit-id --name-only -r ${sha}`).split("\n").filter(Boolean); + // Check if any files are outside .gsd/ and .planning/ + const hasCodeChanges = files.some( + (f) => !f.startsWith(".gsd/") && !f.startsWith(".planning/") && f !== "PLAN.md", + ); + if (hasCodeChanges) { + codeCommits.push(sha); + } + } + + return codeCommits.reverse(); // Chronological order for cherry-picking + } catch { + return []; + } +} + +export async function handlePrBranch( + args: string, + ctx: ExtensionCommandContext, +): Promise<void> { + const basePath = process.cwd(); + const dryRun = args.includes("--dry-run"); + const nameMatch = args.match(/--name\s+(\S+)/); + + const currentBranch = nativeGetCurrentBranch(basePath); + const mainBranch = nativeDetectMainBranch(basePath); + + // Determine base ref (prefer upstream/main if available) + let baseRef: string; + try { + git(basePath, "rev-parse --verify upstream/main"); + baseRef = "upstream/main"; + } catch { + baseRef = mainBranch; + } + + // Find code-only commits + const commits = getCodeOnlyCommits(basePath, baseRef, "HEAD"); + + if (commits.length === 0) { + ctx.ui.notify("No code-only commits found (all commits only touch .gsd/ files).", "info"); + return; + } + + if (dryRun) { + const lines = [`Would create PR branch with ${commits.length} commits (filtering .gsd/ paths):\n`]; + for (const sha of commits) { + const msg = git(basePath, `log --format=%s -1 ${sha}`); + lines.push(` ${sha.slice(0, 8)} ${msg}`); + } + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + const prBranch = nameMatch?.[1] ?? `pr/${currentBranch}`; + + if (nativeBranchExists(basePath, prBranch)) { + ctx.ui.notify( + `Branch ${prBranch} already exists. Use --name to specify a different name, or delete it first.`, + "warning", + ); + return; + } + + try { + // Create clean branch from base + git(basePath, `checkout -b ${prBranch} ${baseRef}`); + + // Cherry-pick each code commit + let picked = 0; + for (const sha of commits) { + try { + git(basePath, `cherry-pick ${sha}`); + picked++; + } catch { + // If cherry-pick fails (conflict), abort and report + try { + git(basePath, "cherry-pick --abort"); + } catch { + // already aborted + } + ctx.ui.notify( + `Cherry-pick conflict at ${sha.slice(0, 8)}. Picked ${picked}/${commits.length} commits. Resolve manually.`, + "warning", + ); + // Switch back to original branch + git(basePath, `checkout ${currentBranch}`); + return; + } + } + + ctx.ui.notify( + `Created ${prBranch} with ${picked} commits (no .gsd/ artifacts).\nSwitch back: git checkout ${currentBranch}`, + "success", + ); + } catch (err) { + // Restore original branch on failure + try { + git(basePath, `checkout ${currentBranch}`); + } catch { + // best effort + } + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to create PR branch: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/commands-session-report.ts b/src/resources/extensions/gsd/commands-session-report.ts new file mode 100644 index 000000000..9994d40f4 --- /dev/null +++ b/src/resources/extensions/gsd/commands-session-report.ts @@ -0,0 +1,100 @@ +/** + * GSD Command — /gsd session-report + * + * Summarizes the current session: tasks completed, cost, tokens, + * duration, model usage breakdown. + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { getLedger, getProjectTotals, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk } from "./metrics.js"; +import type { UnitMetrics } from "./metrics.js"; +import { gsdRoot } from "./paths.js"; +import { formatDuration } from "../shared/format-utils.js"; + +function formatSessionReport(units: UnitMetrics[]): string { + const totals = getProjectTotals(units); + const byModel = aggregateByModel(units); + + const lines: string[] = []; + lines.push("╭─ Session Report ──────────────────────────────────────╮"); + + if (totals.totalDuration > 0) { + lines.push(`│ Duration: ${formatDuration(totals.totalDuration).padEnd(40)}│`); + } + lines.push(`│ Units: ${String(units.length).padEnd(40)}│`); + lines.push(`│ Cost: ${formatCost(totals.totalCost).padEnd(40)}│`); + lines.push(`│ Tokens: ${`${formatTokenCount(totals.totalInput)} in / ${formatTokenCount(totals.totalOutput)} out`.padEnd(40)}│`); + lines.push("│ │"); + + // Work completed + if (units.length > 0) { + lines.push("│ Work Completed: │"); + for (const unit of units) { + const status = unit.status === "completed" ? "✓" : unit.status === "skipped" ? "⊘" : "•"; + const label = ` ${status} ${unit.unitId ?? "unknown"}`; + lines.push(`│ ${label.padEnd(53)}│`); + } + lines.push("│ │"); + } + + // Model usage + if (byModel.length > 0) { + lines.push("│ Model Usage: │"); + for (const m of byModel) { + const label = ` ${m.model}: ${m.count} units (${formatCost(m.cost)})`; + lines.push(`│ ${label.padEnd(53)}│`); + } + } + + lines.push("╰───────────────────────────────────────────────────────╯"); + return lines.join("\n"); +} + +export async function handleSessionReport( + args: string, + ctx: ExtensionCommandContext, +): Promise<void> { + const basePath = process.cwd(); + + // Get units from in-memory ledger or disk + const ledger = getLedger(); + let units: UnitMetrics[]; + + if (ledger && ledger.units.length > 0) { + units = ledger.units; + } else { + const diskLedger = loadLedgerFromDisk(basePath); + if (!diskLedger || diskLedger.units.length === 0) { + ctx.ui.notify("No session data — no units have been executed yet.", "info"); + return; + } + units = diskLedger.units; + } + + // JSON output + if (args.includes("--json")) { + const totals = getProjectTotals(units); + const byModel = aggregateByModel(units); + ctx.ui.notify(JSON.stringify({ units: units.length, totals, byModel }, null, 2), "info"); + return; + } + + // Save to file + if (args.includes("--save")) { + const report = formatSessionReport(units); + const reportsDir = join(gsdRoot(basePath), "reports"); + mkdirSync(reportsDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const outPath = join(reportsDir, `session-${timestamp}.md`); + writeFileSync(outPath, `\`\`\`\n${report}\n\`\`\`\n`, "utf-8"); + ctx.ui.notify(`Report saved: ${outPath}`, "success"); + return; + } + + // Display + ctx.ui.notify(formatSessionReport(units), "info"); +} diff --git a/src/resources/extensions/gsd/commands-ship.ts b/src/resources/extensions/gsd/commands-ship.ts new file mode 100644 index 000000000..a289484ec --- /dev/null +++ b/src/resources/extensions/gsd/commands-ship.ts @@ -0,0 +1,179 @@ +/** + * GSD Command — /gsd ship + * + * Creates a PR from milestone artifacts: generates title + body from + * roadmap, slice summaries, and metrics, then opens via `gh pr create`. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { deriveState } from "./state.js"; +import { gsdRoot } from "./paths.js"; +import { getLedger, getProjectTotals, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk } from "./metrics.js"; +import { nativeGetCurrentBranch, nativeDetectMainBranch } from "./native-git-bridge.js"; +import { formatDuration } from "../shared/format-utils.js"; + +function git(basePath: string, args: string): string { + return execSync(`git ${args}`, { cwd: basePath, encoding: "utf-8" }).trim(); +} + +interface PRContent { + title: string; + body: string; +} + +function collectSliceSummaries(basePath: string, milestoneId: string): string[] { + const summaries: string[] = []; + const milestoneDir = join(gsdRoot(basePath), "milestones", milestoneId); + if (!existsSync(milestoneDir)) return summaries; + + try { + for (const entry of readdirSync(milestoneDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const summaryPath = join(milestoneDir, entry.name, "SUMMARY.md"); + if (existsSync(summaryPath)) { + const content = readFileSync(summaryPath, "utf-8").trim(); + if (content) summaries.push(`### ${entry.name}\n${content}`); + } + } + } catch { + // non-fatal + } + return summaries; +} + +function generatePRContent(basePath: string, milestoneId: string, milestoneTitle: string): PRContent { + const title = `feat: ${milestoneTitle || milestoneId}`; + + const sections: string[] = []; + + // TL;DR + sections.push("## TL;DR\n"); + sections.push(`**What:** Ship milestone ${milestoneId} — ${milestoneTitle || "(untitled)"}`); + sections.push(`**Why:** Milestone work complete, ready for review.`); + sections.push(`**How:** See slice summaries below.\n`); + + // What — slice summaries + const summaries = collectSliceSummaries(basePath, milestoneId); + if (summaries.length > 0) { + sections.push("## What\n"); + sections.push(summaries.join("\n\n")); + sections.push(""); + } + + // Roadmap status + const roadmapPath = join(gsdRoot(basePath), "milestones", milestoneId, "ROADMAP.md"); + if (existsSync(roadmapPath)) { + const roadmap = readFileSync(roadmapPath, "utf-8"); + const checkboxLines = roadmap.split("\n").filter((l) => /^\s*-\s*\[[ x]\]/.test(l)); + if (checkboxLines.length > 0) { + sections.push("## Roadmap\n"); + sections.push(checkboxLines.join("\n")); + sections.push(""); + } + } + + // Metrics + const ledger = getLedger(); + const units = ledger?.units ?? loadLedgerFromDisk(basePath)?.units ?? []; + if (units.length > 0) { + const totals = getProjectTotals(units); + const byModel = aggregateByModel(units); + sections.push("## Metrics\n"); + sections.push(`- **Units executed:** ${units.length}`); + sections.push(`- **Total cost:** ${formatCost(totals.totalCost)}`); + sections.push(`- **Tokens:** ${formatTokenCount(totals.totalInput)} input / ${formatTokenCount(totals.totalOutput)} output`); + if (totals.totalDuration > 0) { + sections.push(`- **Duration:** ${formatDuration(totals.totalDuration)}`); + } + if (byModel.length > 0) { + sections.push(`- **Models:** ${byModel.map((m) => `${m.model} (${m.count} units)`).join(", ")}`); + } + sections.push(""); + } + + // Change type checklist + sections.push("## Change type\n"); + sections.push("- [x] `feat` — New feature or capability"); + sections.push("- [ ] `fix` — Bug fix"); + sections.push("- [ ] `refactor` — Code restructuring"); + sections.push("- [ ] `test` — Adding or updating tests"); + sections.push("- [ ] `docs` — Documentation only"); + sections.push("- [ ] `chore` — Build, CI, or tooling changes\n"); + + // AI disclosure + sections.push("---\n"); + sections.push("*This PR was prepared with AI assistance (GSD auto-mode).*"); + + return { title, body: sections.join("\n") }; +} + +export async function handleShip( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const dryRun = args.includes("--dry-run"); + const draft = args.includes("--draft"); + const force = args.includes("--force"); + const baseMatch = args.match(/--base\s+(\S+)/); + const base = baseMatch?.[1] ?? nativeDetectMainBranch(basePath); + + // 1. Validate milestone state + const state = await deriveState(basePath); + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone to ship. Complete milestone work first.", "warning"); + return; + } + + const milestoneId = state.activeMilestone.id; + const milestoneTitle = state.activeMilestone.title ?? ""; + + // 2. Check for incomplete work + if (state.activeMilestone.phase !== "complete" && !force) { + ctx.ui.notify( + `Milestone ${milestoneId} is not complete (phase: ${state.activeMilestone.phase}). Use --force to ship anyway.`, + "warning", + ); + return; + } + + // 3. Generate PR content + const { title, body } = generatePRContent(basePath, milestoneId, milestoneTitle); + + // 4. Dry-run — just show the PR content + if (dryRun) { + ctx.ui.notify(`--- PR Preview ---\n\nTitle: ${title}\n\n${body}`, "info"); + return; + } + + // 5. Check git state + const currentBranch = nativeGetCurrentBranch(basePath); + if (currentBranch === base) { + ctx.ui.notify(`You're on ${base} — create a feature branch first.`, "warning"); + return; + } + + // 6. Push and create PR + try { + // Push current branch to origin + git(basePath, `push -u origin ${currentBranch}`); + + // Create PR via gh + const draftFlag = draft ? "--draft" : ""; + const prUrl = execSync( + `gh pr create --base ${base} --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} ${draftFlag}`, + { cwd: basePath, encoding: "utf-8" }, + ).trim(); + + ctx.ui.notify(`PR created: ${prUrl}`, "success"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to create PR: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/commands-slice-mutation.ts b/src/resources/extensions/gsd/commands-slice-mutation.ts new file mode 100644 index 000000000..28b8b0f28 --- /dev/null +++ b/src/resources/extensions/gsd/commands-slice-mutation.ts @@ -0,0 +1,226 @@ +/** + * GSD Commands — /gsd add-slice, /gsd insert-slice, /gsd remove-slice + * + * Thin CLI wrappers around the engine's updateRoadmap() command. + * All mutations go through the single-writer WorkflowEngine. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { deriveState } from "./state.js"; +import { _getAdapter, isDbAvailable } from "./gsd-db.js"; +import { updateRoadmap } from "./workflow-commands.js"; +import { renderAllProjections } from "./workflow-projections.js"; + +function parseFlag(args: string, flag: string): string | undefined { + const regex = new RegExp(`${flag}\\s+(\\S+)`); + const match = args.match(regex); + return match?.[1]; +} + +function stripFlags(args: string): string { + return args + .replace(/--\w+\s+\S+/g, "") + .replace(/--\w+/g, "") + .trim(); +} + +function generateNextSliceId(existingIds: string[]): string { + let maxNum = 0; + for (const id of existingIds) { + const match = id.match(/^S(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + return `S${String(maxNum + 1).padStart(2, "0")}`; +} + +export async function handleAddSlice( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone. Create one with /gsd new-milestone first.", "warning"); + return; + } + + const id = parseFlag(args, "--id"); + const risk = parseFlag(args, "--risk") ?? "medium"; + const dependsStr = parseFlag(args, "--depends"); + const depends = dependsStr ? dependsStr.split(",").map((d) => d.trim()) : []; + const title = stripFlags(args).replace(/^['"]|['"]$/g, ""); + + if (!title) { + ctx.ui.notify( + "Usage: /gsd add-slice [--id S99] [--risk high] [--depends S01,S02] <title>", + "warning", + ); + return; + } + + const milestoneId = state.activeMilestone.id; + + // Determine slice ID + const existingSliceIds = (state.activeMilestone.slices ?? []).map((s: { id: string }) => s.id); + const sliceId = id ?? generateNextSliceId(existingSliceIds); + + if (!isDbAvailable()) { + ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning"); + return; + } + + try { + const db = _getAdapter(); + const result = updateRoadmap(db, { + milestoneId, + addSlices: [{ id: sliceId, title, risk, depends, demo: "" }], + }); + renderAllProjections(db, basePath, milestoneId); + ctx.ui.notify( + `Added ${sliceId}: "${title}" (${result.totalSlices} total slices)`, + "success", + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to add slice: ${msg}`, "error"); + } +} + +export async function handleInsertSlice( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone. Create one with /gsd new-milestone first.", "warning"); + return; + } + + const parts = args.trim().split(/\s+/); + const afterId = parts[0]; + const title = parts.slice(1).join(" ").replace(/^['"]|['"]$/g, ""); + + if (!afterId || !title) { + ctx.ui.notify( + 'Usage: /gsd insert-slice <after-slice-id> <title>\nExample: /gsd insert-slice S03 "Auth middleware"', + "warning", + ); + return; + } + + const milestoneId = state.activeMilestone.id; + const existingSliceIds = (state.activeMilestone.slices ?? []).map((s: { id: string }) => s.id); + + if (!existingSliceIds.includes(afterId)) { + ctx.ui.notify( + `Slice ${afterId} not found. Available: ${existingSliceIds.join(", ")}`, + "warning", + ); + return; + } + + const sliceId = generateNextSliceId(existingSliceIds); + + if (!isDbAvailable()) { + ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning"); + return; + } + + try { + const db = _getAdapter(); + + // Add the new slice + updateRoadmap(db, { + milestoneId, + addSlices: [{ id: sliceId, title, risk: "medium", depends: [], demo: "" }], + }); + + // Reorder: insert after the specified slice + const reorder = [...existingSliceIds]; + const insertIdx = reorder.indexOf(afterId); + reorder.splice(insertIdx + 1, 0, sliceId); + + const result = updateRoadmap(db, { + milestoneId, + reorderSliceIds: reorder, + }); + + renderAllProjections(db, basePath, milestoneId); + ctx.ui.notify( + `Inserted ${sliceId}: "${title}" after ${afterId} (${result.totalSlices} total slices)`, + "success", + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to insert slice: ${msg}`, "error"); + } +} + +export async function handleRemoveSlice( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise<void> { + const basePath = process.cwd(); + const state = await deriveState(basePath); + + if (!state.activeMilestone) { + ctx.ui.notify("No active milestone.", "warning"); + return; + } + + const force = args.includes("--force"); + const sliceId = args.replace(/--force/g, "").trim(); + + if (!sliceId) { + ctx.ui.notify("Usage: /gsd remove-slice <slice-id> [--force]", "warning"); + return; + } + + const milestoneId = state.activeMilestone.id; + + if (!isDbAvailable()) { + ctx.ui.notify("Engine database not available. Run /gsd init first.", "warning"); + return; + } + + try { + const db = _getAdapter(); + + // If force, delete tasks first + if (force) { + db.prepare("DELETE FROM tasks WHERE milestone_id = ? AND slice_id = ?").run(milestoneId, sliceId); + } + + const result = updateRoadmap(db, { + milestoneId, + removeSliceIds: [sliceId], + }); + + if (result.removed === 0) { + ctx.ui.notify( + `Could not remove ${sliceId} — only pending slices can be removed. Use --force to remove slices with tasks.`, + "warning", + ); + return; + } + + renderAllProjections(db, basePath, milestoneId); + ctx.ui.notify( + `Removed ${sliceId} (${result.totalSlices} slices remaining)`, + "success", + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to remove slice: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 29858541e..2b3bc902d 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record<string, readonly GsdCommandDefinition[]>; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|add-slice|insert-slice|remove-slice|do|session-report|backlog|pr-branch|add-tests|map-codebase"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -74,6 +74,16 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" }, { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, { cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache (.gsd/CODEBASE.md)" }, + { cmd: "ship", desc: "Create PR from milestone artifacts and open for review" }, + { cmd: "add-slice", desc: "Append a new slice to the active milestone's roadmap" }, + { cmd: "insert-slice", desc: "Insert a slice after a specific position in the roadmap" }, + { cmd: "remove-slice", desc: "Remove a pending slice from the roadmap" }, + { cmd: "do", desc: "Route freeform text to the right GSD command" }, + { cmd: "session-report", desc: "Session cost, tokens, and work summary" }, + { cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" }, + { cmd: "pr-branch", desc: "Create clean PR branch filtering .gsd/ commits" }, + { cmd: "add-tests", desc: "Generate tests for completed slices" }, + { cmd: "map-codebase", desc: "Parallel codebase analysis (tech, arch, quality, concerns)" }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -244,6 +254,39 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "stats", desc: "Show file count, description coverage, and generation time" }, { cmd: "help", desc: "Show usage and available subcommands" }, ], + ship: [ + { cmd: "--dry-run", desc: "Preview PR without creating" }, + { cmd: "--draft", desc: "Open as draft PR" }, + { cmd: "--base", desc: "Override target branch (default: main)" }, + { cmd: "--force", desc: "Ship even with pending tasks" }, + ], + "add-slice": [ + { cmd: "--id", desc: "Explicit slice ID (default: auto-generated)" }, + { cmd: "--risk", desc: "Risk level: low, medium, high (default: medium)" }, + { cmd: "--depends", desc: "Comma-separated dependency slice IDs" }, + ], + "remove-slice": [ + { cmd: "--force", desc: "Remove even if slice has tasks (deletes them)" }, + ], + "session-report": [ + { cmd: "--json", desc: "Machine-readable JSON output" }, + { cmd: "--save", desc: "Save report to .gsd/reports/" }, + ], + backlog: [ + { cmd: "add", desc: "Add item to backlog" }, + { cmd: "promote", desc: "Promote backlog item to active slice" }, + { cmd: "remove", desc: "Remove backlog item" }, + ], + "pr-branch": [ + { cmd: "--dry-run", desc: "Preview what would be filtered" }, + { cmd: "--name", desc: "Custom branch name" }, + ], + "map-codebase": [ + { cmd: "tech", desc: "Technology stack analysis" }, + { cmd: "arch", desc: "Architecture patterns" }, + { cmd: "quality", desc: "Code quality assessment" }, + { cmd: "concerns", desc: "Risk areas and concerns" }, + ], }; function filterOptions( diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 532a4b4ec..bcce151b8 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -11,6 +11,9 @@ import { handleExport } from "../../export.js"; import { handleHistory } from "../../history.js"; import { handleUndo } from "../../undo.js"; import { handleRemote } from "../../../remote-questions/mod.js"; +import { handleShip } from "../../commands-ship.js"; +import { handleSessionReport } from "../../commands-session-report.js"; +import { handlePrBranch } from "../../commands-pr-branch.js"; import { projectRoot } from "../context.js"; export async function handleOpsCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { @@ -216,5 +219,27 @@ Examples: await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi); return true; } + if (trimmed === "ship" || trimmed.startsWith("ship ")) { + await handleShip(trimmed.replace(/^ship\s*/, "").trim(), ctx, pi); + return true; + } + if (trimmed === "session-report" || trimmed.startsWith("session-report ")) { + await handleSessionReport(trimmed.replace(/^session-report\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "pr-branch" || trimmed.startsWith("pr-branch ")) { + await handlePrBranch(trimmed.replace(/^pr-branch\s*/, "").trim(), ctx); + return true; + } + if (trimmed === "add-tests" || trimmed.startsWith("add-tests ")) { + const { handleAddTests } = await import("../../commands-add-tests.js"); + await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi); + return true; + } + if (trimmed === "map-codebase" || trimmed.startsWith("map-codebase ")) { + const { handleMapCodebase } = await import("../../commands-map-codebase.js"); + await handleMapCodebase(trimmed.replace(/^map-codebase\s*/, "").trim(), ctx, pi); + return true; + } return false; } diff --git a/src/resources/extensions/gsd/commands/handlers/workflow.ts b/src/resources/extensions/gsd/commands/handlers/workflow.ts index a755bf9ab..10eb14d28 100644 --- a/src/resources/extensions/gsd/commands/handlers/workflow.ts +++ b/src/resources/extensions/gsd/commands/handlers/workflow.ts @@ -221,6 +221,34 @@ async function handleCustomWorkflow( } export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<boolean> { + // ── /gsd do — natural language routing (must be early to route to other commands) ── + if (trimmed === "do" || trimmed.startsWith("do ")) { + const { handleDo } = await import("../../commands-do.js"); + await handleDo(trimmed.replace(/^do\s*/, "").trim(), ctx, pi); + return true; + } + // ── Slice mutation commands ── + if (trimmed === "add-slice" || trimmed.startsWith("add-slice ")) { + const { handleAddSlice } = await import("../../commands-slice-mutation.js"); + await handleAddSlice(trimmed.replace(/^add-slice\s*/, "").trim(), ctx, pi); + return true; + } + if (trimmed === "insert-slice" || trimmed.startsWith("insert-slice ")) { + const { handleInsertSlice } = await import("../../commands-slice-mutation.js"); + await handleInsertSlice(trimmed.replace(/^insert-slice\s*/, "").trim(), ctx, pi); + return true; + } + if (trimmed === "remove-slice" || trimmed.startsWith("remove-slice ")) { + const { handleRemoveSlice } = await import("../../commands-slice-mutation.js"); + await handleRemoveSlice(trimmed.replace(/^remove-slice\s*/, "").trim(), ctx, pi); + return true; + } + // ── Backlog management ── + if (trimmed === "backlog" || trimmed.startsWith("backlog ")) { + const { handleBacklog } = await import("../../commands-backlog.js"); + await handleBacklog(trimmed.replace(/^backlog\s*/, "").trim(), ctx, pi); + return true; + } // ── Custom workflow commands (`/gsd workflow ...`) ── if (trimmed === "workflow" || trimmed.startsWith("workflow ")) { const sub = trimmed.slice("workflow".length).trim(); diff --git a/src/resources/extensions/gsd/prompts/add-tests.md b/src/resources/extensions/gsd/prompts/add-tests.md new file mode 100644 index 000000000..771ffc447 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/add-tests.md @@ -0,0 +1,35 @@ +You are generating tests for recently completed GSD work. + +## Slice: {{sliceId}} — {{sliceTitle}} + +### Summary + +{{sliceSummary}} + +### Existing Test Patterns + +{{existingTestPatterns}} + +## Working Directory + +`{{workingDirectory}}` + +## Instructions + +1. Read the slice summary above to understand what was built +2. Identify the source files that were created or modified for this slice +3. Read the implementation code to understand behavior, edge cases, and error paths +4. Write comprehensive tests following the project's existing test patterns and framework +5. Run the tests to verify they pass +6. Fix any failures + +### Rules + +- Follow the project's existing test patterns (framework, assertions, file structure) +- Test behavior, not implementation details +- Cover: happy path, edge cases, error conditions, boundary values +- Do NOT modify implementation files — only create or update test files +- Name test files consistently with the project's conventions +- Keep tests focused and readable + +{{skillActivation}} diff --git a/src/resources/extensions/gsd/prompts/map-codebase.md b/src/resources/extensions/gsd/prompts/map-codebase.md new file mode 100644 index 000000000..9a568284c --- /dev/null +++ b/src/resources/extensions/gsd/prompts/map-codebase.md @@ -0,0 +1,62 @@ +You are analyzing a codebase to produce structured documentation. + +## Focus Areas + +{{focusDescriptions}} + +## Working Directory + +`{{workingDirectory}}` + +## Output Directory + +Write one markdown file per focus area to: `{{outputDirectory}}/` + +- `TECH.md` — Technology stack analysis +- `ARCH.md` — Architecture patterns analysis +- `QUALITY.md` — Code quality assessment +- `CONCERNS.md` — Risk areas and concerns + +Only produce files for the requested focus areas: {{focusAreas}} + +## Instructions + +1. Explore the codebase systematically: + - Read package.json, tsconfig.json, and build configs + - Examine the directory structure + - Read key source files to understand patterns + - Check test coverage and CI configuration +2. For each focus area, write a comprehensive markdown document with: + - Executive summary (3-5 bullet points) + - Detailed findings organized by topic + - Specific file references (path:line where relevant) + - Recommendations (if any) +3. Be factual — cite specific files, dependencies, and patterns you observe +4. Do not speculate about code you haven't read + +### Format + +Each output file should follow this structure: + +```markdown +# [Area] Analysis + +## Summary +- Key finding 1 +- Key finding 2 +- ... + +## Detailed Findings + +### [Topic] +[Description with file references] + +### [Topic] +[Description with file references] + +## Recommendations +- Recommendation 1 +- Recommendation 2 +``` + +{{skillActivation}} diff --git a/src/resources/extensions/gsd/tests/commands-backlog.test.ts b/src/resources/extensions/gsd/tests/commands-backlog.test.ts new file mode 100644 index 000000000..8ecbe80da --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-backlog.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-backlog-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +function backlogPath(base: string): string { + return join(base, ".gsd", "BACKLOG.md"); +} + +function writeBacklog(base: string, content: string): void { + writeFileSync(backlogPath(base), content, "utf-8"); +} + +function readBacklog(base: string): string { + return readFileSync(backlogPath(base), "utf-8"); +} + +// Test the parsing/writing logic inline since the handler requires runtime context + +interface BacklogItem { + id: string; + title: string; + done: boolean; + note: string; +} + +function parseBacklog(content: string): BacklogItem[] { + const items: BacklogItem[] = []; + for (const line of content.split("\n")) { + const match = line.match(/^- \[([ x])\] (999\.\d+) — (.+?)(?:\s*\((.+)\))?$/); + if (match) { + items.push({ + id: match[2], + title: match[3].trim(), + done: match[1] === "x", + note: match[4] ?? "", + }); + } + } + return items; +} + +function formatBacklog(items: BacklogItem[]): string { + const lines = ["# Backlog\n"]; + for (const item of items) { + const check = item.done ? "x" : " "; + const note = item.note ? ` (${item.note})` : ""; + lines.push(`- [${check}] ${item.id} — ${item.title}${note}`); + } + lines.push(""); + return lines.join("\n"); +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +test("backlog: parse empty file returns empty array", () => { + const items = parseBacklog(""); + assert.equal(items.length, 0); +}); + +test("backlog: parse valid entries", () => { + const content = `# Backlog + +- [ ] 999.1 — OAuth support (added 2026-03-23) +- [x] 999.2 — Rate limiting (promoted 2026-03-24) +- [ ] 999.3 — Dark mode`; + + const items = parseBacklog(content); + assert.equal(items.length, 3); + assert.equal(items[0].id, "999.1"); + assert.equal(items[0].title, "OAuth support"); + assert.equal(items[0].done, false); + assert.equal(items[0].note, "added 2026-03-23"); + + assert.equal(items[1].id, "999.2"); + assert.equal(items[1].done, true); + assert.equal(items[1].note, "promoted 2026-03-24"); + + assert.equal(items[2].id, "999.3"); + assert.equal(items[2].title, "Dark mode"); + assert.equal(items[2].note, ""); +}); + +test("backlog: format roundtrips correctly", () => { + const items: BacklogItem[] = [ + { id: "999.1", title: "OAuth support", done: false, note: "added 2026-03-23" }, + { id: "999.2", title: "Rate limiting", done: true, note: "promoted 2026-03-24" }, + ]; + + const formatted = formatBacklog(items); + const parsed = parseBacklog(formatted); + + assert.equal(parsed.length, 2); + assert.equal(parsed[0].id, "999.1"); + assert.equal(parsed[0].title, "OAuth support"); + assert.equal(parsed[1].done, true); +}); + +test("backlog: write and read from disk", () => { + const base = makeTmpBase(); + try { + const items: BacklogItem[] = [ + { id: "999.1", title: "Test item", done: false, note: "added 2026-03-23" }, + ]; + writeBacklog(base, formatBacklog(items)); + + assert.ok(existsSync(backlogPath(base))); + const content = readBacklog(base); + assert.ok(content.includes("999.1")); + assert.ok(content.includes("Test item")); + } finally { + cleanup(base); + } +}); + +test("backlog: next ID increments correctly", () => { + const items: BacklogItem[] = [ + { id: "999.1", title: "First", done: false, note: "" }, + { id: "999.2", title: "Second", done: false, note: "" }, + { id: "999.5", title: "Fifth", done: false, note: "" }, + ]; + + let maxNum = 0; + for (const item of items) { + const match = item.id.match(/^999\.(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + const nextId = `999.${maxNum + 1}`; + assert.equal(nextId, "999.6"); +}); + +test("backlog: empty backlog returns no items", () => { + const base = makeTmpBase(); + try { + // No BACKLOG.md exists + assert.ok(!existsSync(backlogPath(base))); + // Would return empty array + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/commands-do.test.ts b/src/resources/extensions/gsd/tests/commands-do.test.ts new file mode 100644 index 000000000..96c8ee918 --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-do.test.ts @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// ─── Mock dispatcher to capture routed commands ───────────────────────── + +let lastRouted: string | null = null; +let lastQuick: string | null = null; + +const mockCtx = { + ui: { + notify: (_msg: string, _level: string) => {}, + }, +} as any; + +// We test the keyword matching logic directly since the handler imports +// the dispatcher dynamically (which requires the full extension runtime). + +// Inline the route-matching logic from commands-do.ts for unit testing. +interface Route { + keywords: string[]; + command: string; +} + +const ROUTES: Route[] = [ + { keywords: ["progress", "status", "dashboard", "how far", "where are we"], command: "status" }, + { keywords: ["auto", "autonomous", "run all", "keep going", "start auto"], command: "auto" }, + { keywords: ["stop", "halt", "abort"], command: "stop" }, + { keywords: ["pause", "break", "take a break"], command: "pause" }, + { keywords: ["history", "past", "what happened", "previous"], command: "history" }, + { keywords: ["doctor", "health", "diagnose", "check health"], command: "doctor" }, + { keywords: ["clean up", "cleanup", "remove old", "prune", "tidy"], command: "cleanup" }, + { keywords: ["ship", "pull request", "create pr", "open pr", "merge"], command: "ship" }, + { keywords: ["discuss", "talk about", "architecture", "design"], command: "discuss" }, + { keywords: ["add slice", "new slice", "add scope", "expand scope"], command: "add-slice" }, + { keywords: ["undo", "revert", "rollback", "take back"], command: "undo" }, + { keywords: ["skip", "skip task", "skip this"], command: "skip" }, + { keywords: ["visualize", "viz", "graph", "chart", "show graph"], command: "visualize" }, + { keywords: ["capture", "note", "idea", "thought", "remember"], command: "capture" }, + { keywords: ["inspect", "database", "sqlite", "db state"], command: "inspect" }, + { keywords: ["session report", "session summary", "cost summary", "how much"], command: "session-report" }, + { keywords: ["backlog", "parking lot", "later", "someday"], command: "backlog" }, + { keywords: ["add tests", "write tests", "generate tests", "test coverage"], command: "add-tests" }, + { keywords: ["map codebase", "analyze code", "codebase analysis"], command: "map-codebase" }, + { keywords: ["next", "step", "next step", "what's next"], command: "next" }, +]; + +interface MatchResult { + command: string; + remainingArgs: string; + score: number; +} + +function matchRoute(input: string): MatchResult | null { + const lower = input.toLowerCase(); + let bestMatch: MatchResult | null = null; + + for (const route of ROUTES) { + for (const keyword of route.keywords) { + if (lower.includes(keyword)) { + const score = keyword.length; + if (!bestMatch || score > bestMatch.score) { + const idx = lower.indexOf(keyword); + const remaining = (input.slice(0, idx) + input.slice(idx + keyword.length)).trim(); + bestMatch = { command: route.command, remainingArgs: remaining, score }; + } + } + } + } + + return bestMatch; +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +test("/gsd do: routes 'show me progress' to status", () => { + const match = matchRoute("show me progress"); + assert.ok(match); + assert.equal(match.command, "status"); +}); + +test("/gsd do: routes 'run autonomously' to auto", () => { + const match = matchRoute("run autonomously"); + assert.ok(match); + assert.equal(match.command, "auto"); +}); + +test("/gsd do: routes 'clean up old branches' to cleanup", () => { + const match = matchRoute("clean up old branches"); + assert.ok(match); + assert.equal(match.command, "cleanup"); + assert.equal(match.remainingArgs, "old branches"); +}); + +test("/gsd do: routes 'create pr for milestone' to ship", () => { + const match = matchRoute("create pr for milestone"); + assert.ok(match); + assert.equal(match.command, "ship"); +}); + +test("/gsd do: routes 'add tests for S03' to add-tests", () => { + const match = matchRoute("add tests for S03"); + assert.ok(match); + assert.equal(match.command, "add-tests"); +}); + +test("/gsd do: routes 'what is next' to next", () => { + const match = matchRoute("what's next"); + assert.ok(match); + assert.equal(match.command, "next"); +}); + +test("/gsd do: returns null for unrecognized input", () => { + const match = matchRoute("florbinate the gizmo"); + assert.equal(match, null); +}); + +test("/gsd do: prefers longer keyword match", () => { + // "check health" (12 chars) should beat "health" (6 chars) + const match = matchRoute("check health of the system"); + assert.ok(match); + assert.equal(match.command, "doctor"); + assert.ok(match.score >= 12); +}); + +test("/gsd do: routes 'session report' to session-report", () => { + const match = matchRoute("show me the session report"); + assert.ok(match); + assert.equal(match.command, "session-report"); +}); + +test("/gsd do: routes 'add new slice' to add-slice", () => { + const match = matchRoute("add new slice for authentication"); + assert.ok(match); + assert.equal(match.command, "add-slice"); +}); diff --git a/src/resources/extensions/gsd/tests/commands-pr-branch.test.ts b/src/resources/extensions/gsd/tests/commands-pr-branch.test.ts new file mode 100644 index 000000000..e24434e6c --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-pr-branch.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Test the filtering logic used by /gsd pr-branch. +// Full integration requires git operations, so we test the path filtering. + +test("pr-branch: identifies .gsd/ paths", () => { + const files = [ + ".gsd/milestones/M001/ROADMAP.md", + ".gsd/metrics.json", + "src/main.ts", + "package.json", + ".planning/PLAN.md", + "PLAN.md", + ]; + + const codeFiles = files.filter( + (f) => !f.startsWith(".gsd/") && !f.startsWith(".planning/") && f !== "PLAN.md", + ); + + assert.deepEqual(codeFiles, ["src/main.ts", "package.json"]); +}); + +test("pr-branch: all .gsd/ files returns empty", () => { + const files = [ + ".gsd/milestones/M001/ROADMAP.md", + ".gsd/metrics.json", + ".gsd/BACKLOG.md", + ]; + + const codeFiles = files.filter( + (f) => !f.startsWith(".gsd/") && !f.startsWith(".planning/") && f !== "PLAN.md", + ); + + assert.equal(codeFiles.length, 0); +}); + +test("pr-branch: mixed commits with code changes", () => { + const files = [ + ".gsd/milestones/M001/ROADMAP.md", + "src/auth.ts", + "src/auth.test.ts", + ]; + + const hasCodeChanges = files.some( + (f) => !f.startsWith(".gsd/") && !f.startsWith(".planning/") && f !== "PLAN.md", + ); + + assert.ok(hasCodeChanges); +}); + +test("pr-branch: --dry-run flag", () => { + assert.ok("--dry-run".includes("--dry-run")); + assert.ok(!"--name my-branch".includes("--dry-run")); +}); + +test("pr-branch: --name flag parsing", () => { + const args = "--name my-clean-pr"; + const nameMatch = args.match(/--name\s+(\S+)/); + assert.ok(nameMatch); + assert.equal(nameMatch[1], "my-clean-pr"); +}); + +test("pr-branch: default branch name", () => { + const currentBranch = "feat/add-auth"; + const prBranch = `pr/${currentBranch}`; + assert.equal(prBranch, "pr/feat/add-auth"); +}); diff --git a/src/resources/extensions/gsd/tests/commands-session-report.test.ts b/src/resources/extensions/gsd/tests/commands-session-report.test.ts new file mode 100644 index 000000000..23946eff9 --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-session-report.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Test the formatting logic used by session-report. +// The actual handler requires runtime context (metrics module), so we +// test the core formatting and aggregation patterns. + +test("session-report: format cost correctly", () => { + // Simple cost formatting test + const formatCost = (cost: number): string => { + if (cost < 0.01) return "<$0.01"; + return `$${cost.toFixed(2)}`; + }; + + assert.equal(formatCost(0), "<$0.01"); + assert.equal(formatCost(0.005), "<$0.01"); + assert.equal(formatCost(1.5), "$1.50"); + assert.equal(formatCost(10.999), "$11.00"); +}); + +test("session-report: format token count", () => { + const formatTokenCount = (count: number): string => { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); + }; + + assert.equal(formatTokenCount(500), "500"); + assert.equal(formatTokenCount(1500), "1.5K"); + assert.equal(formatTokenCount(1_200_000), "1.2M"); +}); + +test("session-report: aggregate by model", () => { + interface UnitMetric { + model: string; + cost: number; + } + + const units: UnitMetric[] = [ + { model: "opus", cost: 1.0 }, + { model: "opus", cost: 0.8 }, + { model: "sonnet", cost: 0.3 }, + { model: "sonnet", cost: 0.5 }, + { model: "sonnet", cost: 0.2 }, + ]; + + const byModel = new Map<string, { count: number; cost: number }>(); + for (const u of units) { + const existing = byModel.get(u.model) ?? { count: 0, cost: 0 }; + existing.count++; + existing.cost += u.cost; + byModel.set(u.model, existing); + } + + const opus = byModel.get("opus")!; + assert.equal(opus.count, 2); + assert.ok(Math.abs(opus.cost - 1.8) < 0.01); + + const sonnet = byModel.get("sonnet")!; + assert.equal(sonnet.count, 3); + assert.ok(Math.abs(sonnet.cost - 1.0) < 0.01); +}); + +test("session-report: --json flag detection", () => { + const args1 = "--json"; + const args2 = "--save --json"; + const args3 = "something else"; + + assert.ok(args1.includes("--json")); + assert.ok(args2.includes("--json")); + assert.ok(!args3.includes("--json")); +}); + +test("session-report: --save flag detection", () => { + const args1 = "--save"; + const args2 = "--save --json"; + const args3 = ""; + + assert.ok(args1.includes("--save")); + assert.ok(args2.includes("--save")); + assert.ok(!args3.includes("--save")); +}); diff --git a/src/resources/extensions/gsd/tests/commands-ship.test.ts b/src/resources/extensions/gsd/tests/commands-ship.test.ts new file mode 100644 index 000000000..15ee5d781 --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-ship.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Test the PR content generation logic used by /gsd ship. +// Full integration requires gh CLI + git, so we test the text generation. + +test("ship: generates TL;DR format", () => { + // Simulate generatePRContent output structure + const milestoneId = "M001"; + const milestoneTitle = "User authentication system"; + + const title = `feat: ${milestoneTitle}`; + assert.equal(title, "feat: User authentication system"); + assert.ok(title.length < 80); // PR title should be short +}); + +test("ship: --dry-run flag detection", () => { + const args1 = "--dry-run"; + const args2 = "--draft --dry-run"; + const args3 = "--draft"; + + assert.ok(args1.includes("--dry-run")); + assert.ok(args2.includes("--dry-run")); + assert.ok(!args3.includes("--dry-run")); +}); + +test("ship: --base flag parsing", () => { + const args = "--base develop --draft"; + const baseMatch = args.match(/--base\s+(\S+)/); + assert.ok(baseMatch); + assert.equal(baseMatch[1], "develop"); +}); + +test("ship: --base flag absent defaults", () => { + const args = "--draft"; + const baseMatch = args.match(/--base\s+(\S+)/); + assert.equal(baseMatch, null); +}); + +test("ship: --force flag detection", () => { + const args1 = "--force"; + const args2 = ""; + + assert.ok(args1.includes("--force")); + assert.ok(!args2.includes("--force")); +}); + +test("ship: change type checklist format", () => { + const checklist = [ + "- [x] `feat` — New feature or capability", + "- [ ] `fix` — Bug fix", + "- [ ] `refactor` — Code restructuring", + "- [ ] `test` — Adding or updating tests", + "- [ ] `docs` — Documentation only", + "- [ ] `chore` — Build, CI, or tooling changes", + ]; + + // Verify format matches CONTRIBUTING.md expectations + for (const line of checklist) { + assert.match(line, /^- \[[ x]\] `\w+` — .+$/); + } +}); + +test("ship: PR body contains required sections", () => { + const requiredSections = ["## TL;DR", "## Change type"]; + const body = "## TL;DR\n\n**What:** Ship M001\n\n## Change type\n\n- [x] `feat`"; + + for (const section of requiredSections) { + assert.ok(body.includes(section), `Missing section: ${section}`); + } +}); diff --git a/src/resources/extensions/gsd/tests/commands-slice-mutation.test.ts b/src/resources/extensions/gsd/tests/commands-slice-mutation.test.ts new file mode 100644 index 000000000..d8af6fae1 --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-slice-mutation.test.ts @@ -0,0 +1,110 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Test the argument parsing logic used by slice mutation commands. +// Full integration tests require DB + engine runtime, so we test +// the parsing and ID generation utilities directly. + +// ─── Utilities from commands-slice-mutation.ts ────────────────────────── + +function parseFlag(args: string, flag: string): string | undefined { + const regex = new RegExp(`${flag}\\s+(\\S+)`); + const match = args.match(regex); + return match?.[1]; +} + +function stripFlags(args: string): string { + return args + .replace(/--\w+\s+\S+/g, "") + .replace(/--\w+/g, "") + .trim(); +} + +function generateNextSliceId(existingIds: string[]): string { + let maxNum = 0; + for (const id of existingIds) { + const match = id.match(/^S(\d+)$/); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + return `S${String(maxNum + 1).padStart(2, "0")}`; +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +test("add-slice: parse --id flag", () => { + assert.equal(parseFlag("--id S99 My title", "--id"), "S99"); + assert.equal(parseFlag("My title --id S05", "--id"), "S05"); + assert.equal(parseFlag("My title", "--id"), undefined); +}); + +test("add-slice: parse --risk flag", () => { + assert.equal(parseFlag("--risk high My title", "--risk"), "high"); + assert.equal(parseFlag("My title", "--risk"), undefined); +}); + +test("add-slice: parse --depends flag", () => { + assert.equal(parseFlag("--depends S01,S02 My title", "--depends"), "S01,S02"); + const deps = parseFlag("--depends S01,S02 My title", "--depends")?.split(","); + assert.deepEqual(deps, ["S01", "S02"]); +}); + +test("add-slice: strip flags leaves title", () => { + assert.equal(stripFlags("--id S99 --risk high My new slice"), "My new slice"); + assert.equal(stripFlags("Simple title"), "Simple title"); + assert.equal(stripFlags("--depends S01,S02 --risk low Auth middleware"), "Auth middleware"); +}); + +test("add-slice: empty after stripping flags", () => { + assert.equal(stripFlags("--id S99 --risk high"), ""); +}); + +test("add-slice: generate next slice ID from empty", () => { + assert.equal(generateNextSliceId([]), "S01"); +}); + +test("add-slice: generate next slice ID increments", () => { + assert.equal(generateNextSliceId(["S01", "S02", "S03"]), "S04"); +}); + +test("add-slice: generate next slice ID handles gaps", () => { + assert.equal(generateNextSliceId(["S01", "S05", "S03"]), "S06"); +}); + +test("add-slice: generate next slice ID pads to 2 digits", () => { + assert.equal(generateNextSliceId(["S09"]), "S10"); + assert.equal(generateNextSliceId(["S01"]), "S02"); +}); + +test("remove-slice: parse --force flag", () => { + const args1 = "S05 --force"; + const args2 = "S05"; + + assert.ok(args1.includes("--force")); + assert.ok(!args2.includes("--force")); + + assert.equal(args1.replace(/--force/g, "").trim(), "S05"); + assert.equal(args2.replace(/--force/g, "").trim(), "S05"); +}); + +test("insert-slice: parse after-id and title", () => { + const args = "S03 Auth middleware"; + const parts = args.trim().split(/\s+/); + const afterId = parts[0]; + const title = parts.slice(1).join(" "); + + assert.equal(afterId, "S03"); + assert.equal(title, "Auth middleware"); +}); + +test("insert-slice: quoted title", () => { + const args = 'S03 "Auth middleware with OAuth"'; + const parts = args.trim().split(/\s+/); + const afterId = parts[0]; + const title = parts.slice(1).join(" ").replace(/^['"]|['"]$/g, ""); + + assert.equal(afterId, "S03"); + assert.equal(title, "Auth middleware with OAuth"); +});