diff --git a/package-lock.json b/package-lock.json index 50385df48..c5766f7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.58.0", + "version": "2.64.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.58.0", + "version": "2.64.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -9534,7 +9534,7 @@ }, "packages/pi-coding-agent": { "name": "@gsd/pi-coding-agent", - "version": "2.58.0", + "version": "2.64.0", "dependencies": { "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 0bf69ddc9..ab22a72ca 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -1114,7 +1114,7 @@ function resolveVersionCatalogAccessors( return accessors; } -function scanProjectFiles(basePath: string): string[] { +export function scanProjectFiles(basePath: string): string[] { const files: string[] = []; const queue: Array<{ path: string; depth: number }> = [{ path: basePath, depth: 0 }]; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 3eb7eb243..877e61927 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -40,6 +40,28 @@ import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMiles import { parkMilestone, discardMilestone } from "./milestone-actions.js"; import { selectAndApplyModel } from "./auto-model-selection.js"; import { DISCUSS_TOOLS_ALLOWLIST } from "./constants.js"; +import { + runPreparation, + formatCodebaseBrief, + formatPriorContextBrief, + formatEcosystemBrief, + type PreparationResult, +} from "./preparation.js"; + +// ─── Preparation result storage ───────────────────────────────────────────── +// Stores the most recent preparation result for injection into discuss prompts. +// S02 will consume this when building the prepared discussion prompt. +let lastPreparationResult: PreparationResult | null = null; + +/** Get the most recent preparation result (for S02 prompt building). */ +export function getLastPreparationResult(): PreparationResult | null { + return lastPreparationResult; +} + +/** Clear the preparation result (called after discussion completes). */ +export function clearPreparationResult(): void { + lastPreparationResult = null; +} // ─── Re-exports (preserve public API for existing importers) ──────────────── export { @@ -421,6 +443,93 @@ function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePa }); } +/** + * Build the prepared discuss prompt with brief injection. + * Uses the discuss-prepared template which encodes the 4-layer discussion protocol. + * + * @param nextId - The milestone ID being discussed + * @param preamble - Preamble text for the discuss prompt + * @param _basePath - Root directory of the project (unused, kept for signature consistency) + * @param prepResult - Preparation result containing briefs to inject + * @returns The prepared discuss prompt string + */ +function buildPreparedPrompt( + nextId: string, + preamble: string, + _basePath: string, + prepResult: PreparationResult, +): string { + const milestoneRel = `.gsd/milestones/${nextId}`; + + // Use context-enhanced instead of context for prepared discussions + const inlinedTemplates = [ + inlineTemplate("project", "Project"), + inlineTemplate("requirements", "Requirements"), + inlineTemplate("context-enhanced", "Context Enhanced"), + inlineTemplate("roadmap", "Roadmap"), + inlineTemplate("decisions", "Decisions"), + ].join("\n\n---\n\n"); + + // Format the briefs from the preparation result + const codebaseBrief = prepResult.codebaseBrief || formatCodebaseBrief(prepResult.codebase); + const priorContextBrief = prepResult.priorContextBrief || formatPriorContextBrief(prepResult.priorContext); + const ecosystemBrief = prepResult.ecosystemBrief || formatEcosystemBrief(prepResult.ecosystem); + + return loadPrompt("discuss-prepared", { + milestoneId: nextId, + preamble, + codebaseBrief, + priorContextBrief, + ecosystemBrief, + contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`, + roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`, + inlinedTemplates, + commitInstruction: buildDocsCommitInstruction(`docs(${nextId}): context, requirements, and roadmap`), + multiMilestoneCommitInstruction: buildDocsCommitInstruction("docs: project plan — N milestones"), + }); +} + +/** + * Run preparation phase if enabled, then build the discuss prompt. + * This is the main entry point for new milestone discussions with preparation. + * Stores the preparation result for S02 to inject into the discuss prompt. + * + * When preparation succeeds, uses the discuss-prepared template with brief injection. + * Falls back to the standard discuss template when preparation is disabled or fails. + * + * @param ctx - Extension command context with UI for progress notifications + * @param nextId - The milestone ID being discussed + * @param preamble - Preamble text for the discuss prompt + * @param basePath - Root directory of the project + * @returns The discuss prompt string + */ +async function prepareAndBuildDiscussPrompt( + ctx: ExtensionCommandContext, + nextId: string, + preamble: string, + basePath: string, +): Promise { + const prefs = loadEffectiveGSDPreferences()?.preferences ?? {}; + + // Run preparation if enabled (default: true) + if (prefs.discuss_preparation !== false) { + const prepResult = await runPreparation(basePath, ctx.ui, { + discuss_preparation: prefs.discuss_preparation, + discuss_web_research: prefs.discuss_web_research, + discuss_depth: prefs.discuss_depth, + }); + lastPreparationResult = prepResult; + + // Use prepared prompt if preparation was enabled and produced results + if (prepResult.enabled) { + return buildPreparedPrompt(nextId, preamble, basePath, prepResult); + } + } + + // Fall back to standard discuss prompt for backward compatibility + return buildDiscussPrompt(nextId, preamble, basePath); +} + /** * Bootstrap a .gsd/ project from scratch for headless use. * Ensures git repo, .gsd/ structure, gitignore, and preferences all exist. @@ -677,8 +786,13 @@ export async function showDiscuss( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: false, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "discuss-milestone"); +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: false }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "discuss-milestone"); +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) } return; } @@ -1082,8 +1196,13 @@ async function handleMilestoneActions( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New milestone ${nextId}.`, basePath ), "gsd-run", ctx, "discuss-milestone"); @@ -1263,8 +1382,13 @@ export async function showSmartEntry( if (isFirst) { // First ever — skip wizard, just ask directly +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath ), "gsd-run", ctx, "discuss-milestone"); @@ -1284,8 +1408,13 @@ export async function showSmartEntry( }); if (choice === "new_milestone") { +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New milestone ${nextId}.`, basePath ), "gsd-run", ctx, "discuss-milestone"); @@ -1323,8 +1452,13 @@ export async function showSmartEntry( const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New milestone ${nextId}.`, basePath ), "gsd-run", ctx, "discuss-milestone"); @@ -1390,8 +1524,13 @@ export async function showSmartEntry( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New milestone ${nextId}.`, basePath ), "gsd-run", ctx, "discuss-milestone"); @@ -1487,8 +1626,13 @@ export async function showSmartEntry( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); +<<<<<<< HEAD pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, +======= + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, nextId, +>>>>>>> 179320ad (feat(gsd): add deep evidence-backed discussion system with preparation engine) `New milestone ${nextId}.`, basePath ), "gsd-run", ctx, "discuss-milestone"); diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 3452e34f3..58b847cc9 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -110,6 +110,9 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "enhanced_verification_pre", "enhanced_verification_post", "enhanced_verification_strict", + "discuss_preparation", + "discuss_web_research", + "discuss_depth", ]); /** Canonical list of all dispatch unit types. */ @@ -309,6 +312,7 @@ export interface GSDPreferences { timeout_scale_cap?: number; }; + // ─── Enhanced Verification ────────────────────────────────────────────────── /** * Enable enhanced verification (both pre-execution and post-execution checks). @@ -332,6 +336,27 @@ export interface GSDPreferences { * Default: false (warnings only for non-critical failures). */ enhanced_verification_strict?: boolean; + /** + * Enable the preparation phase before discussion sessions. + * Preparation analyzes the codebase, reviews prior context, and optionally researches the ecosystem. + * Default: true. + */ + discuss_preparation?: boolean; + /** + * Enable web research during preparation phase. + * When enabled, searches for best practices and known issues for the detected tech stack. + * Requires a search API key (TAVILY_API_KEY or BRAVE_API_KEY). + * Default: true. + */ + discuss_web_research?: boolean; + /** + * Depth of preparation analysis. + * - "quick": Minimal analysis, fastest (~10s) + * - "standard": Balanced analysis (~30s) + * - "thorough": Deep analysis with more file sampling (~60s) + * Default: "standard". + */ + discuss_depth?: "quick" | "standard" | "thorough"; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index ef80ef1d6..e4ac3d3d6 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -951,5 +951,33 @@ export function validatePreferences(preferences: GSDPreferences): { } } + // ─── Discuss Preparation ──────────────────────────────────────────── + if (preferences.discuss_preparation !== undefined) { + if (typeof preferences.discuss_preparation === "boolean") { + validated.discuss_preparation = preferences.discuss_preparation; + } else { + errors.push("discuss_preparation must be a boolean"); + } + } + + // ─── Discuss Web Research ─────────────────────────────────────────── + if (preferences.discuss_web_research !== undefined) { + if (typeof preferences.discuss_web_research === "boolean") { + validated.discuss_web_research = preferences.discuss_web_research; + } else { + errors.push("discuss_web_research must be a boolean"); + } + } + + // ─── Discuss Depth ────────────────────────────────────────────────── + if (preferences.discuss_depth !== undefined) { + const validDepths = new Set(["quick", "standard", "thorough"]); + if (typeof preferences.discuss_depth === "string" && validDepths.has(preferences.discuss_depth)) { + validated.discuss_depth = preferences.discuss_depth as GSDPreferences["discuss_depth"]; + } else { + errors.push(`discuss_depth must be one of: quick, standard, thorough`); + } + } + return { preferences: validated, errors, warnings }; } diff --git a/src/resources/extensions/gsd/preparation.ts b/src/resources/extensions/gsd/preparation.ts new file mode 100644 index 000000000..2c3a4c978 --- /dev/null +++ b/src/resources/extensions/gsd/preparation.ts @@ -0,0 +1,1675 @@ +/** + * GSD Preparation — Structured brief generation for discussion LLM sessions. + * + * Produces structured briefs (codebase, prior context, ecosystem) before + * the discussion LLM session starts. + * + * Pure functions, zero UI dependencies (except for runPreparation orchestrator). + */ + +import { readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "node:fs"; +import { join, relative } from "node:path"; +import { readdirSync as readdirSyncNode } from "node:fs"; +import { + detectProjectSignals, + scanProjectFiles, + PROJECT_FILES, + type ProjectSignals, +} from "./detection.js"; +import { loadFile } from "./files.js"; +import { PROVIDER_REGISTRY, type ProviderInfo } from "./key-manager.js"; + +// ─── Types ────────────────────────────────────────────────────────────────────── + +/** Detected patterns in the codebase. */ +export interface CodePatterns { + /** Primary async style: "async/await" | "callbacks" | "promises" | "mixed" */ + asyncStyle: "async/await" | "callbacks" | "promises" | "mixed" | "unknown"; + /** Primary error handling: "try/catch" | "error-callbacks" | "result-types" | "mixed" */ + errorHandling: "try/catch" | "error-callbacks" | "result-types" | "mixed" | "unknown"; + /** Primary naming convention: "camelCase" | "snake_case" | "PascalCase" | "mixed" */ + namingConvention: "camelCase" | "snake_case" | "PascalCase" | "mixed" | "unknown"; + /** Sample evidence strings for each pattern (for debugging/transparency) */ + evidence: { + asyncStyle: string[]; + errorHandling: string[]; + namingConvention: string[]; + }; + /** File counts for each pattern type (for formatted output) */ + fileCounts: { + asyncAwait: number; + promises: number; + callbacks: number; + tryCatch: number; + errorCallbacks: number; + resultTypes: number; + }; +} + +/** Language-specific pattern detection configuration. */ +export interface LanguagePatternEntry { + /** Display name for the language (e.g., "JavaScript/TypeScript") */ + displayName: string; + /** File extensions to sample for this language */ + extensions: string[]; + /** Async style detection patterns */ + asyncStyle: { + modern: RegExp; + modernLabel: string; + legacy: RegExp; + legacyLabel: string; + }; + /** Error handling detection patterns */ + errorHandling: { + structured: RegExp; + structuredLabel: string; + inline: RegExp; + inlineLabel: string; + }; +} + +/** Module structure detected in the codebase. */ +export interface ModuleStructure { + /** Top-level directories found (e.g., ["src", "lib", "test"]) */ + topLevelDirs: string[]; + /** Subdirectories within src/ or lib/ (e.g., ["components", "utils", "hooks"]) */ + srcSubdirs: string[]; + /** Total file count sampled */ + totalFilesSampled: number; +} + +/** A single decision entry parsed from DECISIONS.md. */ +export interface DecisionEntry { + id: string; + scope: string; + decision: string; + choice: string; + rationale: string; +} + +/** A single requirement entry parsed from REQUIREMENTS.md. */ +export interface RequirementEntry { + id: string; + description: string; + status: "active" | "validated" | "deferred" | "out-of-scope"; +} + +/** Prior context brief aggregated from GSD artifacts. */ +export interface PriorContextBrief { + /** Decisions grouped by scope. */ + decisions: { + byScope: Map; + totalCount: number; + }; + /** Requirements grouped by status. */ + requirements: { + active: RequirementEntry[]; + validated: RequirementEntry[]; + deferred: RequirementEntry[]; + totalCount: number; + }; + /** Knowledge entries (raw content, truncated). */ + knowledge: string; + /** Prior milestone summaries (combined, truncated). */ + summaries: string; +} + +/** Codebase analysis brief. */ +export interface CodebaseBrief { + /** Tech stack and language from detectProjectSignals */ + techStack: { + primaryLanguage?: string; + detectedFiles: string[]; + packageManager?: string; + isMonorepo: boolean; + hasTests: boolean; + hasCI: boolean; + }; + /** Module structure */ + moduleStructure: ModuleStructure; + /** Detected code patterns */ + patterns: CodePatterns; + /** Source files that were sampled for pattern extraction */ + sampledFiles: string[]; +} + +/** A single ecosystem research finding. */ +export interface EcosystemFinding { + /** Query that produced this finding */ + query: string; + /** Title or snippet from search result */ + title: string; + /** URL source */ + url?: string; + /** Brief content snippet */ + snippet: string; +} + +/** Ecosystem research brief from web search. */ +export interface EcosystemBrief { + /** Whether ecosystem research was performed */ + available: boolean; + /** Search queries that were executed */ + queries: string[]; + /** Aggregated findings from search results */ + findings: EcosystemFinding[]; + /** Reason why research was skipped (if available === false) */ + skippedReason?: string; + /** Which search provider was used */ + provider?: string; +} + +// ─── Constants ────────────────────────────────────────────────────────────────── + +/** Maximum characters for the codebase section. */ +const MAX_CODEBASE_BRIEF_CHARS = 3000; + +/** Number of files to sample for pattern extraction. */ +const SAMPLE_FILE_COUNT = 5; + +/** Maximum bytes to read from each sampled file. */ +const MAX_FILE_SAMPLE_BYTES = 8192; + +/** Directories to skip when sampling. */ +const SKIP_DIRS = new Set([ + "node_modules", + "dist", + "build", + ".git", + "coverage", + ".next", + ".nuxt", + "target", + ".turbo", + "vendor", + "__pycache__", + ".venv", + "venv", +]); + +/** File patterns to exclude when sampling. */ +const EXCLUDE_PATTERNS = [ + /\.test\.(ts|tsx|js|jsx|mjs|cjs)$/, + /\.spec\.(ts|tsx|js|jsx|mjs|cjs)$/, + /\.d\.ts$/, + /test-.*\.(ts|tsx|js|jsx)$/, + /.*\.min\.(js|css)$/, +]; + +/** File extensions to sample for pattern extraction (JS/TS default). */ +const SAMPLE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; + +/** Common source file extensions for universal pattern detection (naming convention). + * Used when the language is not in LANGUAGE_PATTERNS but we still want to detect camelCase/snake_case. */ +const UNIVERSAL_SOURCE_EXTENSIONS = [ + // JavaScript/TypeScript + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", + // Python + ".py", ".pyw", ".pyi", + // Ruby + ".rb", ".rake", ".gemspec", + // Go + ".go", + // Rust + ".rs", + // Java/Kotlin + ".java", ".kt", ".kts", + // C/C++ + ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", + // C# + ".cs", + // Swift + ".swift", + // PHP + ".php", + // Scala + ".scala", + // Elixir/Erlang + ".ex", ".exs", ".erl", + // Haskell + ".hs", ".lhs", + // Shell + ".sh", ".bash", ".zsh", + // Lua + ".lua", + // Dart + ".dart", +]; + +// ─── Pattern Detection Regexes ────────────────────────────────────────────────── + +/** Async/await usage patterns. */ +const ASYNC_AWAIT_RE = /\basync\s+function\b|\basync\s*\(|\bawait\s+/g; + +/** Callback-style patterns (common patterns like done, callback, cb). */ +const CALLBACK_RE = /\b(callback|cb|done)\s*\(|\bfunction\s*\([^)]*\bfunction\b/g; + +/** Promise patterns (.then, .catch, new Promise). */ +const PROMISE_RE = /\.then\s*\(|\.catch\s*\(|\bnew\s+Promise\s*\(/g; + +/** Try/catch patterns. */ +const TRY_CATCH_RE = /\btry\s*\{[\s\S]*?\bcatch\s*\(/g; + +/** Error-first callback patterns. */ +const ERROR_CALLBACK_RE = /\bif\s*\(\s*(err|error)\s*\)|\(err(or)?\s*,/g; + +/** Result type patterns (Rust-style, fp-ts, etc.). */ +const RESULT_TYPE_RE = /\bResult<|\bEither<|\bisOk\(|\bisErr\(|\b(Ok|Err)\(/g; + +/** camelCase identifier patterns. */ +const CAMEL_CASE_RE = /\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g; + +/** snake_case identifier patterns. */ +const SNAKE_CASE_RE = /\b[a-z][a-z0-9]*_[a-z0-9_]+\b/g; + +/** PascalCase identifier patterns (for types/classes). */ +const PASCAL_CASE_RE = /\bclass\s+[A-Z][a-zA-Z0-9]*|\binterface\s+[A-Z][a-zA-Z0-9]*|\btype\s+[A-Z][a-zA-Z0-9]*/g; + +// ─── Language Pattern Registry ────────────────────────────────────────────────── + +/** + * Registry of language-specific patterns for code analysis. + * Keys MUST match detection.ts LANGUAGE_MAP values exactly. + */ +export const LANGUAGE_PATTERNS: Record = { + "javascript/typescript": { + displayName: "JavaScript/TypeScript", + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], + asyncStyle: { + modern: /\basync\s+function\b|\basync\s*\(|\bawait\s+/g, + modernLabel: "async/await", + legacy: /\.then\s*\(|\.catch\s*\(|\bnew\s+Promise\s*\(/g, + legacyLabel: "promises", + }, + errorHandling: { + structured: /\btry\s*\{[\s\S]*?\bcatch\s*\(/g, + structuredLabel: "try/catch", + inline: /\bif\s*\(\s*(err|error)\s*\)|\(err(or)?\s*,/g, + inlineLabel: "error-callbacks", + }, + }, + python: { + displayName: "Python", + extensions: [".py", ".pyw", ".pyi"], + asyncStyle: { + modern: /\basync\s+def\b|\bawait\s+/g, + modernLabel: "async/await", + legacy: /\.add_done_callback\(|ThreadPoolExecutor|ProcessPoolExecutor/g, + legacyLabel: "futures/executors", + }, + errorHandling: { + structured: /\btry\s*:[\s\S]*?\bexcept\b/g, + structuredLabel: "try/except", + inline: /\braise\s+\w+Error|\bassert\s+/g, + inlineLabel: "raise/assert", + }, + }, + rust: { + displayName: "Rust", + extensions: [".rs"], + asyncStyle: { + modern: /\basync\s+fn\b|\.await\b/g, + modernLabel: "async/await", + legacy: /\bthread::spawn\(|\bmpsc::/g, + legacyLabel: "threads/channels", + }, + errorHandling: { + structured: /\bResult<|\bOption<|\?\s*;/g, + structuredLabel: "Result/Option", + inline: /\bunwrap\(\)|\bexpect\(/g, + inlineLabel: "unwrap/expect", + }, + }, + go: { + displayName: "Go", + extensions: [".go"], + asyncStyle: { + modern: /\bgo\s+func\b|\bgo\s+\w+\(/g, + modernLabel: "goroutines", + legacy: /\bchan\s+\w+|<-\s*\w+|\w+\s*<-/g, + legacyLabel: "channels", + }, + errorHandling: { + structured: /\bif\s+err\s*!=\s*nil\b/g, + structuredLabel: "if err != nil", + inline: /\bpanic\(|\brecover\(\)/g, + inlineLabel: "panic/recover", + }, + }, + java: { + displayName: "Java", + extensions: [".java"], + asyncStyle: { + modern: /\bCompletableFuture<|\bCompletionStage<|\bthenApply\(/g, + modernLabel: "CompletableFuture", + legacy: /\bThread\s+\w+\s*=|\bnew\s+Thread\(|\bExecutorService\b/g, + legacyLabel: "threads/executors", + }, + errorHandling: { + structured: /\btry\s*\{[\s\S]*?\bcatch\s*\(/g, + structuredLabel: "try/catch", + inline: /\bthrows\s+\w+Exception|\bthrow\s+new\s+\w+Exception/g, + inlineLabel: "throws/throw", + }, + }, + "java/kotlin": { + displayName: "Java/Kotlin", + extensions: [".java", ".kt", ".kts"], + asyncStyle: { + modern: /\bsuspend\s+fun\b|\blaunch\s*\{|\basync\s*\{|\bwithContext\(/g, + modernLabel: "coroutines", + legacy: /\bThread\s+\w+\s*=|\bnew\s+Thread\(|\bExecutorService\b|\bCompletableFuture { + // Get project signals from detection.ts + const signals = detectProjectSignals(basePath); + + // Detect module structure + const moduleStructure = detectModuleStructure(basePath); + + // Sample files and extract patterns, passing primary language for language-aware detection + const sampledFiles = sampleSourceFiles(basePath, signals.primaryLanguage); + const patterns = extractPatterns(basePath, sampledFiles, signals.primaryLanguage); + + return { + techStack: { + primaryLanguage: signals.primaryLanguage, + detectedFiles: signals.detectedFiles, + packageManager: signals.packageManager, + isMonorepo: signals.isMonorepo, + hasTests: signals.hasTests, + hasCI: signals.hasCI, + }, + moduleStructure, + patterns, + sampledFiles, + }; +} + +/** + * Detect the module structure of the codebase. + * + * @param basePath - Root directory of the project + * @returns ModuleStructure with top-level and src subdirs + */ +function detectModuleStructure(basePath: string): ModuleStructure { + const topLevelDirs: string[] = []; + const srcSubdirs: string[] = []; + + try { + const entries = readdirSync(basePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith(".") && !SKIP_DIRS.has(entry.name)) { + topLevelDirs.push(entry.name); + } + } + } catch { + // Directory not readable + } + + // Scan for subdirs in src/ or lib/ + for (const srcDir of ["src", "lib", "app"]) { + const srcPath = join(basePath, srcDir); + try { + const entries = readdirSync(srcPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith(".") && !SKIP_DIRS.has(entry.name)) { + srcSubdirs.push(entry.name); + } + } + } catch { + // Directory doesn't exist or not readable + } + } + + return { + topLevelDirs, + srcSubdirs: [...new Set(srcSubdirs)], // Dedupe + totalFilesSampled: 0, // Will be set after sampling + }; +} + +/** + * Sample source files from the codebase for pattern extraction. + * + * Prefers files in src/ directory, excludes test files and node_modules. + * Extension selection: + * - If language is in LANGUAGE_PATTERNS: use language-specific extensions + * - If language is undefined (no manifest): use JS/TS defaults (common case) + * - If language is set but not in LANGUAGE_PATTERNS: use UNIVERSAL_SOURCE_EXTENSIONS + * so we can still detect naming conventions even for unrecognized languages + * + * @param basePath - Root directory of the project + * @param primaryLanguage - Optional primary language identifier from detection.ts LANGUAGE_MAP + * @returns Array of relative file paths to sampled files + */ +function sampleSourceFiles(basePath: string, primaryLanguage?: string): string[] { + // Use scanProjectFiles from detection.ts for bounded recursion + const allFiles = scanProjectFiles(basePath); + + // Get extensions to sample based on language detection status + const languageEntry = primaryLanguage ? LANGUAGE_PATTERNS[primaryLanguage] : undefined; + let extensionsToSample: string[]; + + if (languageEntry) { + // Language is in registry — use its specific extensions + extensionsToSample = languageEntry.extensions; + } else if (primaryLanguage === undefined) { + // No language detected (no manifest) — use JS/TS defaults + extensionsToSample = SAMPLE_EXTENSIONS; + } else { + // Language detected but not in registry (e.g., Ruby, Haskell) + // Use universal extensions so we can still detect naming conventions + extensionsToSample = UNIVERSAL_SOURCE_EXTENSIONS; + } + + // Filter to target language files, excluding tests and dist + const candidates = allFiles.filter((file) => { + // Check extension + const hasValidExtension = extensionsToSample.some((ext) => file.endsWith(ext)); + if (!hasValidExtension) return false; + + // Check exclusion patterns + for (const pattern of EXCLUDE_PATTERNS) { + if (pattern.test(file)) return false; + } + + // Check for excluded directories in path + const parts = file.split(/[/\\]/); + for (const part of parts) { + if (SKIP_DIRS.has(part)) return false; + } + + return true; + }); + + // Prioritize files in src/ directory + const srcFiles = candidates.filter((f) => f.startsWith("src/") || f.startsWith("src\\")); + const otherFiles = candidates.filter((f) => !f.startsWith("src/") && !f.startsWith("src\\")); + + // Take SAMPLE_FILE_COUNT files, preferring src/ + const sampled: string[] = []; + + // First, add src files + for (const file of srcFiles) { + if (sampled.length >= SAMPLE_FILE_COUNT) break; + sampled.push(file); + } + + // Then add other files if needed + for (const file of otherFiles) { + if (sampled.length >= SAMPLE_FILE_COUNT) break; + sampled.push(file); + } + + return sampled; +} + +/** + * Extract code patterns from sampled files. + * + * Pattern detection behavior: + * 1. When primaryLanguage exists in LANGUAGE_PATTERNS → uses language-specific patterns + * 2. When primaryLanguage is undefined (no manifest) → falls back to JS/TS patterns + * since the sampled files are filtered by JS/TS extensions anyway + * 3. When primaryLanguage is a known value NOT in LANGUAGE_PATTERNS (e.g., "haskell", + * "elixir") → returns "unknown" for language-specific patterns instead of running + * JS/TS patterns which would produce misleading results + * + * Universal patterns (naming convention) always run regardless of language. + * + * @param basePath - Root directory of the project + * @param sampledFiles - Array of relative file paths + * @param primaryLanguage - Optional primary language identifier from detection.ts LANGUAGE_MAP + * @returns CodePatterns with detected patterns and evidence + */ +function extractPatterns(basePath: string, sampledFiles: string[], primaryLanguage?: string): CodePatterns { + const evidence = { + asyncStyle: [] as string[], + errorHandling: [] as string[], + namingConvention: [] as string[], + }; + + const counts = { + asyncAwait: 0, + callbacks: 0, + promises: 0, + tryCatch: 0, + errorCallbacks: 0, + resultTypes: 0, + camelCase: 0, + snakeCase: 0, + pascalCase: 0, + }; + + // Track how many files contain each pattern type (for formatted output) + const fileCounts = { + asyncAwait: 0, + promises: 0, + callbacks: 0, + tryCatch: 0, + errorCallbacks: 0, + resultTypes: 0, + }; + + // Get language-specific patterns if available + // When primaryLanguage is undefined, fall back to JS/TS (sampled files are JS/TS extensions) + // When primaryLanguage is set but not in registry, skip language-specific patterns entirely + const languageEntry = primaryLanguage + ? LANGUAGE_PATTERNS[primaryLanguage] + : LANGUAGE_PATTERNS["javascript/typescript"]; // Fallback for undefined only + + // Language is "unsupported" only when it's explicitly set but not in our registry + // undefined → use JS/TS fallback (the sampled files are .ts/.js anyway) + // "haskell" → unsupported, don't run JS patterns against Haskell code + const languageUnsupported = primaryLanguage !== undefined && !LANGUAGE_PATTERNS[primaryLanguage]; + + // If language is explicitly set but not in registry, add evidence explaining why patterns aren't available + if (languageUnsupported) { + evidence.asyncStyle.push(`Language "${primaryLanguage}" not in pattern registry — async style detection not available`); + evidence.errorHandling.push(`Language "${primaryLanguage}" not in pattern registry — error handling detection not available`); + } + + for (const file of sampledFiles) { + let content: string; + try { + const fullPath = join(basePath, file); + const buffer = Buffer.alloc(MAX_FILE_SAMPLE_BYTES); + const fd = openSync(fullPath, "r"); + try { + const bytesRead = readSync(fd, buffer, 0, MAX_FILE_SAMPLE_BYTES, 0); + content = buffer.toString("utf-8", 0, bytesRead); + } finally { + closeSync(fd); + } + } catch { + continue; // Skip unreadable files + } + + // Only run language-specific patterns if we have a valid language entry + // This prevents misleading results from running JS/TS patterns against Haskell, etc. + if (!languageUnsupported && languageEntry) { + // Count async patterns using language-appropriate patterns + // Use String.match() to avoid mutating lastIndex on regex with /g flag + const asyncModernMatches = content.match(languageEntry.asyncStyle.modern) || []; + counts.asyncAwait += asyncModernMatches.length; + if (asyncModernMatches.length > 0) { + fileCounts.asyncAwait++; + if (evidence.asyncStyle.length < 3) { + evidence.asyncStyle.push(`${file}: ${languageEntry.asyncStyle.modernLabel} (${asyncModernMatches.length} occurrences)`); + } + } + + // For JS/TS, also check callbacks (universal pattern) + if (primaryLanguage === "javascript/typescript") { + const callbackMatches = content.match(CALLBACK_RE) || []; + counts.callbacks += callbackMatches.length; + if (callbackMatches.length > 0) { + fileCounts.callbacks++; + if (evidence.asyncStyle.length < 3) { + evidence.asyncStyle.push(`${file}: callbacks (${callbackMatches.length} occurrences)`); + } + } + } + + const asyncLegacyMatches = content.match(languageEntry.asyncStyle.legacy) || []; + counts.promises += asyncLegacyMatches.length; + if (asyncLegacyMatches.length > 0) { + fileCounts.promises++; + if (evidence.asyncStyle.length < 3) { + evidence.asyncStyle.push(`${file}: ${languageEntry.asyncStyle.legacyLabel} (${asyncLegacyMatches.length} occurrences)`); + } + } + + // Count error handling patterns using language-appropriate patterns + const errorStructuredMatches = content.match(languageEntry.errorHandling.structured) || []; + counts.tryCatch += errorStructuredMatches.length; + if (errorStructuredMatches.length > 0) { + fileCounts.tryCatch++; + if (evidence.errorHandling.length < 3) { + evidence.errorHandling.push(`${file}: ${languageEntry.errorHandling.structuredLabel} (${errorStructuredMatches.length} occurrences)`); + } + } + + const errorInlineMatches = content.match(languageEntry.errorHandling.inline) || []; + counts.errorCallbacks += errorInlineMatches.length; + if (errorInlineMatches.length > 0) { + fileCounts.errorCallbacks++; + if (evidence.errorHandling.length < 3) { + evidence.errorHandling.push(`${file}: ${languageEntry.errorHandling.inlineLabel} (${errorInlineMatches.length} occurrences)`); + } + } + + // Result types are still useful for some languages (Rust, fp-ts) + const resultTypeMatches = content.match(RESULT_TYPE_RE) || []; + counts.resultTypes += resultTypeMatches.length; + if (resultTypeMatches.length > 0) { + fileCounts.resultTypes++; + if (evidence.errorHandling.length < 3) { + evidence.errorHandling.push(`${file}: result-types (${resultTypeMatches.length} occurrences)`); + } + } + } + + // Count naming convention patterns (universal across all languages) + // These patterns work regardless of whether the language is in the registry + const camelMatches = content.match(CAMEL_CASE_RE) || []; + counts.camelCase += camelMatches.length; + + const snakeMatches = content.match(SNAKE_CASE_RE) || []; + counts.snakeCase += snakeMatches.length; + + const pascalMatches = content.match(PASCAL_CASE_RE) || []; + counts.pascalCase += pascalMatches.length; + } + + // Add naming evidence + if (counts.camelCase > 0) { + evidence.namingConvention.push(`camelCase: ${counts.camelCase} occurrences`); + } + if (counts.snakeCase > 0) { + evidence.namingConvention.push(`snake_case: ${counts.snakeCase} occurrences`); + } + if (counts.pascalCase > 0) { + evidence.namingConvention.push(`PascalCase: ${counts.pascalCase} occurrences`); + } + + // For explicitly set but unrecognized languages, return "unknown" for language-specific patterns + // but still provide naming convention detection (which is universal) + if (languageUnsupported) { + return { + asyncStyle: "unknown", + errorHandling: "unknown", + namingConvention: determineNamingConvention(counts), + evidence, + fileCounts, + }; + } + + return { + asyncStyle: determineAsyncStyle(counts), + errorHandling: determineErrorHandling(counts), + namingConvention: determineNamingConvention(counts), + evidence, + fileCounts, + }; +} + +/** + * Determine the primary async style based on pattern counts. + */ +function determineAsyncStyle(counts: { + asyncAwait: number; + callbacks: number; + promises: number; +}): CodePatterns["asyncStyle"] { + const total = counts.asyncAwait + counts.callbacks + counts.promises; + if (total === 0) return "unknown"; + + const asyncAwaitRatio = counts.asyncAwait / total; + const callbackRatio = counts.callbacks / total; + const promiseRatio = counts.promises / total; + + // If one style dominates (>60%), report it + if (asyncAwaitRatio > 0.6) return "async/await"; + if (callbackRatio > 0.6) return "callbacks"; + if (promiseRatio > 0.6) return "promises"; + + return "mixed"; +} + +/** + * Determine the primary error handling style based on pattern counts. + */ +function determineErrorHandling(counts: { + tryCatch: number; + errorCallbacks: number; + resultTypes: number; +}): CodePatterns["errorHandling"] { + const total = counts.tryCatch + counts.errorCallbacks + counts.resultTypes; + if (total === 0) return "unknown"; + + const tryCatchRatio = counts.tryCatch / total; + const errorCallbackRatio = counts.errorCallbacks / total; + const resultTypeRatio = counts.resultTypes / total; + + if (tryCatchRatio > 0.6) return "try/catch"; + if (errorCallbackRatio > 0.6) return "error-callbacks"; + if (resultTypeRatio > 0.6) return "result-types"; + + return "mixed"; +} + +/** + * Determine the primary naming convention based on pattern counts. + */ +function determineNamingConvention(counts: { + camelCase: number; + snakeCase: number; + pascalCase: number; +}): CodePatterns["namingConvention"] { + const total = counts.camelCase + counts.snakeCase + counts.pascalCase; + if (total === 0) return "unknown"; + + // PascalCase is usually for types/classes, so we compare camelCase vs snake_case + const camelRatio = counts.camelCase / total; + const snakeRatio = counts.snakeCase / total; + + if (camelRatio > 0.6) return "camelCase"; + if (snakeRatio > 0.6) return "snake_case"; + if (counts.pascalCase > counts.camelCase && counts.pascalCase > counts.snakeCase) return "PascalCase"; + + return "mixed"; +} + +// ─── Formatting ───────────────────────────────────────────────────────────────── + +/** + * Format a CodebaseBrief as LLM-readable markdown. + * + * @param brief - The codebase brief to format + * @returns Markdown string capped at MAX_CODEBASE_BRIEF_CHARS + */ +export function formatCodebaseBrief(brief: CodebaseBrief): string { + const sections: string[] = []; + + // Tech Stack section + sections.push("## Tech Stack"); + if (brief.techStack.primaryLanguage) { + sections.push(`- **Language:** ${brief.techStack.primaryLanguage}`); + } + if (brief.techStack.packageManager) { + sections.push(`- **Package Manager:** ${brief.techStack.packageManager}`); + } + if (brief.techStack.detectedFiles.length > 0) { + const files = brief.techStack.detectedFiles.slice(0, 10).join(", "); + sections.push(`- **Project Files:** ${files}`); + } + sections.push(`- **Monorepo:** ${brief.techStack.isMonorepo ? "Yes" : "No"}`); + sections.push(`- **Has Tests:** ${brief.techStack.hasTests ? "Yes" : "No"}`); + sections.push(`- **Has CI:** ${brief.techStack.hasCI ? "Yes" : "No"}`); + + // Module Structure section + sections.push(""); + sections.push("## Module Structure"); + if (brief.moduleStructure.topLevelDirs.length > 0) { + sections.push(`- **Top-level dirs:** ${brief.moduleStructure.topLevelDirs.join(", ")}`); + } + if (brief.moduleStructure.srcSubdirs.length > 0) { + sections.push(`- **Source subdirs:** ${brief.moduleStructure.srcSubdirs.join(", ")}`); + } + + // Code Patterns section + sections.push(""); + sections.push("## Code Patterns"); + + // Format async style with file counts + const fc = brief.patterns.fileCounts; + if (brief.patterns.asyncStyle === "unknown") { + sections.push(`- **Async Style:** ${brief.patterns.asyncStyle}`); + } else { + const asyncParts: string[] = []; + if (fc.asyncAwait > 0) asyncParts.push(`${fc.asyncAwait} async/await`); + if (fc.promises > 0) asyncParts.push(`${fc.promises} .then()`); + if (fc.callbacks > 0) asyncParts.push(`${fc.callbacks} callback`); + const asyncDetail = asyncParts.length > 0 ? ` (${asyncParts.map(p => p + " files").join(" vs ")})` : ""; + sections.push(`- **Async Style:** ${brief.patterns.asyncStyle}${asyncDetail}`); + } + + // Format error handling with file counts + if (brief.patterns.errorHandling === "unknown") { + sections.push(`- **Error Handling:** ${brief.patterns.errorHandling}`); + } else { + const errorParts: string[] = []; + if (fc.tryCatch > 0) errorParts.push(`${fc.tryCatch} try/catch`); + if (fc.errorCallbacks > 0) errorParts.push(`${fc.errorCallbacks} error-callback`); + if (fc.resultTypes > 0) errorParts.push(`${fc.resultTypes} result-type`); + const errorDetail = errorParts.length > 0 ? ` (${errorParts.map(p => p + " files").join(" vs ")})` : ""; + sections.push(`- **Error Handling:** ${brief.patterns.errorHandling}${errorDetail}`); + } + + sections.push(`- **Naming Convention:** ${brief.patterns.namingConvention}`); + + let result = sections.join("\n"); + + // Truncate if necessary + if (result.length > MAX_CODEBASE_BRIEF_CHARS) { + result = result.slice(0, MAX_CODEBASE_BRIEF_CHARS - 3) + "..."; + } + + return result; +} + +// ─── Prior Context Aggregation ────────────────────────────────────────────────── + +/** Maximum characters per section in the prior context brief. */ +const MAX_SECTION_CHARS = 2000; + +/** Maximum total characters for the prior context brief. */ +const MAX_PRIOR_CONTEXT_CHARS = 6000; + +/** + * Aggregate prior context from GSD artifacts. + * + * Reads DECISIONS.md, REQUIREMENTS.md, KNOWLEDGE.md from the .gsd directory + * and milestone summaries from each milestone's MILESTONE-SUMMARY.md file. + * + * @param basePath - Root directory of the project (contains .gsd/) + * @returns PriorContextBrief with aggregated context + */ +export async function aggregatePriorContext(basePath: string): Promise { + const gsdPath = join(basePath, ".gsd"); + + // Load decisions + const decisionsContent = await loadFile(join(gsdPath, "DECISIONS.md")); + const decisions = parseDecisions(decisionsContent); + + // Load requirements + const requirementsContent = await loadFile(join(gsdPath, "REQUIREMENTS.md")); + const requirements = parseRequirements(requirementsContent); + + // Load knowledge + const knowledgeContent = await loadFile(join(gsdPath, "KNOWLEDGE.md")); + const knowledge = truncateSection(knowledgeContent || "", MAX_SECTION_CHARS); + + // Load milestone summaries + const summaries = await loadMilestoneSummaries(gsdPath); + + return { + decisions, + requirements, + knowledge: knowledge || "No prior knowledge recorded.", + summaries: summaries || "No prior milestone summaries.", + }; +} + +/** + * Parse decisions from DECISIONS.md content. + * + * Groups decisions by scope (e.g., "pattern", "architecture"). + */ +function parseDecisions(content: string | null): PriorContextBrief["decisions"] { + const byScope = new Map(); + + if (!content) { + return { byScope, totalCount: 0 }; + } + + // Parse table rows: | D001 | M001/S01 | pattern | ... | + // Skip header rows (start with | # or |---) + const lines = content.split("\n"); + let totalCount = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip non-table lines, header, and separator rows + if (!trimmed.startsWith("|")) continue; + if (trimmed.startsWith("| #") || trimmed.startsWith("|---") || trimmed.startsWith("| -")) continue; + + // Parse: | D001 | M001/S01 | pattern | Decision | Choice | Rationale | Revisable? | Made By | + const cells = trimmed + .split("|") + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + if (cells.length < 6) continue; + + const id = cells[0]; // D001 + if (!id.match(/^D\d+$/)) continue; // Must be a decision ID + + const scope = cells[2]; // pattern, architecture, etc. + const decision = cells[3]; + const choice = cells[4]; + const rationale = cells[5]; + + const entry: DecisionEntry = { id, scope, decision, choice, rationale }; + + if (!byScope.has(scope)) { + byScope.set(scope, []); + } + byScope.get(scope)!.push(entry); + totalCount++; + } + + return { byScope, totalCount }; +} + +/** + * Parse requirements from REQUIREMENTS.md content. + * + * Groups requirements by status (active, validated, deferred). + */ +function parseRequirements(content: string | null): PriorContextBrief["requirements"] { + const result: PriorContextBrief["requirements"] = { + active: [], + validated: [], + deferred: [], + totalCount: 0, + }; + + if (!content) { + return result; + } + + // Parse requirement entries: ### R101 — Description + // Look for Status: line to determine status + const reqBlocks = content.split(/(?=^### R\d+)/m); + + for (const block of reqBlocks) { + const idMatch = block.match(/^### (R\d+)\s*—\s*(.+)/m); + if (!idMatch) continue; + + const id = idMatch[1]; + const description = idMatch[2].trim(); + + // Extract status from "- Status: active" line + const statusMatch = block.match(/^-\s*Status:\s*(\w+)/m); + const statusRaw = statusMatch ? statusMatch[1].toLowerCase() : "active"; + + let status: RequirementEntry["status"] = "active"; + if (statusRaw === "validated") status = "validated"; + else if (statusRaw === "deferred") status = "deferred"; + else if (statusRaw === "out-of-scope" || statusRaw === "outofscope") status = "out-of-scope"; + + const entry: RequirementEntry = { id, description, status }; + + if (status === "active") result.active.push(entry); + else if (status === "validated") result.validated.push(entry); + else if (status === "deferred") result.deferred.push(entry); + + result.totalCount++; + } + + return result; +} + +/** + * Load and combine milestone summaries from each milestone directory. + * + * Returns combined content, truncated to MAX_SECTION_CHARS. + */ +async function loadMilestoneSummaries(gsdPath: string): Promise { + const milestonesPath = join(gsdPath, "milestones"); + const summaries: string[] = []; + + try { + const entries = readdirSyncNode(milestonesPath, { withFileTypes: true }); + const milestoneIds = entries + .filter((e) => e.isDirectory() && e.name.match(/^M\d+/)) + .map((e) => e.name) + .sort(); // Sort by milestone ID + + for (const mid of milestoneIds) { + const summaryPath = join(milestonesPath, mid, "MILESTONE-SUMMARY.md"); + const content = await loadFile(summaryPath); + if (content) { + // Extract the one-liner and first section for brevity + const oneLiner = extractOneLiner(content); + summaries.push(`### ${mid}\n${oneLiner}`); + } + } + } catch { + // Milestones directory doesn't exist or not readable + } + + if (summaries.length === 0) { + return ""; + } + + return truncateSection(summaries.join("\n\n"), MAX_SECTION_CHARS); +} + +/** + * Extract the one-liner summary from a MILESTONE-SUMMARY.md. + * + * Looks for bold text on a line by itself (e.g., "**Completed X and Y**"). + */ +function extractOneLiner(content: string): string { + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + // Look for **bold text** that's the whole line + if (trimmed.startsWith("**") && trimmed.endsWith("**") && trimmed.length > 4) { + return trimmed.slice(2, -2); + } + } + // Fallback: return first non-empty, non-heading line + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("---")) { + return trimmed.slice(0, 200); + } + } + return "Summary available"; +} + +/** + * Truncate content to maxChars without cutting mid-section. + * + * Prefers to cut at section boundaries (## headings) or paragraph breaks. + */ +function truncateSection(content: string, maxChars: number): string { + if (content.length <= maxChars) { + return content; + } + + const SECTION_SUFFIX = "\n\n[truncated]"; // 14 chars + const WORD_SUFFIX = "... [truncated]"; // 15 chars + + // Reserve space for suffix in all slicing operations + const sectionMaxSlice = maxChars - SECTION_SUFFIX.length; + const wordMaxSlice = maxChars - WORD_SUFFIX.length; + + // Try to cut at a section boundary + const truncated = content.slice(0, sectionMaxSlice); + const lastSection = truncated.lastIndexOf("\n## "); + if (lastSection > sectionMaxSlice * 0.5) { + return truncated.slice(0, lastSection).trim() + SECTION_SUFFIX; + } + + // Try to cut at a paragraph break + const lastPara = truncated.lastIndexOf("\n\n"); + if (lastPara > sectionMaxSlice * 0.5) { + return truncated.slice(0, lastPara).trim() + SECTION_SUFFIX; + } + + // Last resort: cut at word boundary + const wordTruncated = content.slice(0, wordMaxSlice); + const lastSpace = wordTruncated.lastIndexOf(" "); + if (lastSpace > wordMaxSlice * 0.8) { + return wordTruncated.slice(0, lastSpace).trim() + WORD_SUFFIX; + } + + return content.slice(0, wordMaxSlice) + WORD_SUFFIX; +} + +/** + * Format a PriorContextBrief as LLM-readable markdown. + * + * @param brief - The prior context brief to format + * @returns Markdown string capped at MAX_PRIOR_CONTEXT_CHARS + */ +export function formatPriorContextBrief(brief: PriorContextBrief): string { + const sections: string[] = []; + + // Decisions section + sections.push("## Prior Decisions"); + if (brief.decisions.totalCount === 0) { + sections.push("No prior decisions recorded."); + } else { + sections.push(`${brief.decisions.totalCount} decisions recorded.`); + sections.push(""); + + // Group by scope + for (const [scope, entries] of brief.decisions.byScope) { + sections.push(`### ${scope}`); + for (const entry of entries.slice(0, 5)) { // Limit per scope + sections.push(`- **${entry.id}:** ${entry.decision} → ${entry.choice}`); + } + if (entries.length > 5) { + sections.push(`- _(${entries.length - 5} more in this scope)_`); + } + sections.push(""); + } + } + + // Requirements section + sections.push("## Prior Requirements"); + const reqTotal = brief.requirements.totalCount; + if (reqTotal === 0) { + sections.push("No prior requirements recorded."); + } else { + sections.push( + `${reqTotal} requirements: ${brief.requirements.active.length} active, ` + + `${brief.requirements.validated.length} validated, ` + + `${brief.requirements.deferred.length} deferred.`, + ); + sections.push(""); + + // Show active requirements (most relevant) + if (brief.requirements.active.length > 0) { + sections.push("### Active"); + for (const req of brief.requirements.active.slice(0, 10)) { + sections.push(`- **${req.id}:** ${req.description}`); + } + if (brief.requirements.active.length > 10) { + sections.push(`- _(${brief.requirements.active.length - 10} more active)_`); + } + sections.push(""); + } + + // Show validated (recently completed) + if (brief.requirements.validated.length > 0) { + sections.push("### Validated"); + for (const req of brief.requirements.validated.slice(0, 5)) { + sections.push(`- **${req.id}:** ${req.description}`); + } + if (brief.requirements.validated.length > 5) { + sections.push(`- _(${brief.requirements.validated.length - 5} more validated)_`); + } + sections.push(""); + } + } + + // Knowledge section + sections.push("## Prior Knowledge"); + if (brief.knowledge === "No prior knowledge recorded.") { + sections.push(brief.knowledge); + } else { + sections.push(truncateSection(brief.knowledge, MAX_SECTION_CHARS)); + } + sections.push(""); + + // Summaries section + sections.push("## Prior Milestone Summaries"); + if (brief.summaries === "No prior milestone summaries.") { + sections.push(brief.summaries); + } else { + sections.push(truncateSection(brief.summaries, MAX_SECTION_CHARS)); + } + + let result = sections.join("\n"); + + // Final truncation if total exceeds max + if (result.length > MAX_PRIOR_CONTEXT_CHARS) { + result = truncateSection(result, MAX_PRIOR_CONTEXT_CHARS); + } + + return result; +} + +// ─── Ecosystem Research ───────────────────────────────────────────────────────── + +/** Maximum characters for the ecosystem brief. */ +const MAX_ECOSYSTEM_BRIEF_CHARS = 4000; + +/** Timeout per search query in milliseconds. */ +const QUERY_TIMEOUT_MS = 5000; + +/** Total timeout for all ecosystem research in milliseconds. */ +const TOTAL_ECOSYSTEM_TIMEOUT_MS = 15000; + +/** Search provider IDs that can be used for ecosystem research. */ +const SEARCH_PROVIDER_IDS = ["tavily", "brave"] as const; + +/** + * Check if a search API key is available for ecosystem research. + * + * Checks for Tavily or Brave API keys via PROVIDER_REGISTRY and environment variables. + * Does NOT require a live AuthStorage instance — checks env vars directly. + * + * @returns Object with available: boolean and provider ID if found + */ +export function hasSearchApiKey(): { available: boolean; provider?: string; envVar?: string } { + for (const providerId of SEARCH_PROVIDER_IDS) { + const provider = PROVIDER_REGISTRY.find((p) => p.id === providerId); + if (!provider?.envVar) continue; + + const envValue = process.env[provider.envVar]; + if (envValue && envValue.trim().length > 0) { + return { available: true, provider: provider.id, envVar: provider.envVar }; + } + } + + return { available: false }; +} + +/** + * Build search queries based on detected tech stack. + * + * @param techStack - Array of technology names (e.g., ["Next.js", "TypeScript"]) + * @returns Array of search query strings + */ +function buildEcosystemQueries(techStack: string[]): string[] { + const queries: string[] = []; + const currentYear = new Date().getFullYear(); + + // Filter out generic terms, keep specific technologies + const relevantTech = techStack.filter((t) => { + const lower = t.toLowerCase(); + // Skip generic terms + if (lower === "javascript" || lower === "typescript" || lower === "javascript/typescript") { + return false; + } + return t.length > 1; // Skip single-char entries + }); + + // If no specific tech, use the generic stack name + if (relevantTech.length === 0 && techStack.length > 0) { + const primary = techStack[0]; + if (primary) { + queries.push(`${primary} best practices ${currentYear}`); + queries.push(`${primary} common issues and solutions`); + } + return queries.slice(0, 3); + } + + // Build queries for the most relevant tech (limit to top 2) + const topTech = relevantTech.slice(0, 2); + for (const tech of topTech) { + queries.push(`${tech} best practices ${currentYear}`); + } + + // Add a combined query if we have multiple tech + if (topTech.length >= 2) { + queries.push(`${topTech.join(" ")} common issues`); + } else if (topTech.length === 1) { + queries.push(`${topTech[0]} known issues and gotchas`); + } + + return queries.slice(0, 3); // Cap at 3 queries +} + +/** + * Execute a single search query with timeout. + * + * This is a stub implementation that returns a graceful "no results" response. + * In production, this would call the actual search API (Tavily/Brave). + * + * @param query - Search query string + * @param provider - Provider ID to use + * @param timeoutMs - Timeout in milliseconds + * @returns Array of findings or empty on timeout/error + */ +async function executeSearchQuery( + query: string, + provider: string, + timeoutMs: number, +): Promise { + // Stub implementation: return empty results + // Real implementation would use the search API here + // + // NOTE: This is intentionally a stub. The actual search functionality + // requires integration with the web-search tool infrastructure which + // is not available as a direct import. The graceful degradation pattern + // is the primary contract for this function. + + // Simulate async behavior + await Promise.resolve(); + + return []; +} + +/** + * Research the ecosystem for best practices and known issues. + * + * Performs optional web search with graceful fallback when API keys are unavailable. + * Applies timeout constraints: 5s per query, 15s total. + * + * @param techStack - Array of technology names from codebase analysis + * @param _basePath - Root directory of the project (unused but included for future use) + * @returns EcosystemBrief with findings or graceful skip message + */ +export async function researchEcosystem( + techStack: string[], + _basePath: string, +): Promise { + // Check for search API key availability + const keyStatus = hasSearchApiKey(); + + if (!keyStatus.available) { + return { + available: false, + queries: [], + findings: [], + skippedReason: "No search API key configured. Set TAVILY_API_KEY or BRAVE_API_KEY to enable ecosystem research.", + }; + } + + // Build search queries based on tech stack + const queries = buildEcosystemQueries(techStack); + + if (queries.length === 0) { + return { + available: false, + queries: [], + findings: [], + skippedReason: "No technology stack detected to research.", + }; + } + + const findings: EcosystemFinding[] = []; + const startTime = Date.now(); + + // Execute queries with timeout constraints + for (const query of queries) { + // Check total timeout + const elapsed = Date.now() - startTime; + if (elapsed >= TOTAL_ECOSYSTEM_TIMEOUT_MS) { + break; // Total timeout exceeded + } + + const remainingTime = TOTAL_ECOSYSTEM_TIMEOUT_MS - elapsed; + const queryTimeout = Math.min(QUERY_TIMEOUT_MS, remainingTime); + + try { + // Execute with timeout + const queryFindings = await Promise.race([ + executeSearchQuery(query, keyStatus.provider!, queryTimeout), + new Promise((resolve) => + setTimeout(() => resolve([]), queryTimeout), + ), + ]); + + findings.push(...queryFindings); + } catch { + // Swallow errors — graceful degradation + continue; + } + } + + return { + available: true, + queries, + findings, + provider: keyStatus.provider, + }; +} + +/** + * Format an EcosystemBrief as LLM-readable markdown. + * + * @param brief - The ecosystem brief to format + * @returns Markdown string capped at MAX_ECOSYSTEM_BRIEF_CHARS + */ +// ─── Preparation Result ───────────────────────────────────────────────────────── + +/** + * Combined result from the preparation phase. + * Includes briefs from all three analyzers, plus metadata about the run. + */ +export interface PreparationResult { + /** Codebase analysis brief. */ + codebase: CodebaseBrief; + /** Formatted codebase brief as markdown. */ + codebaseBrief: string; + /** Prior context brief. */ + priorContext: PriorContextBrief; + /** Formatted prior context brief as markdown. */ + priorContextBrief: string; + /** Ecosystem research brief. */ + ecosystem: EcosystemBrief; + /** Formatted ecosystem brief as markdown. */ + ecosystemBrief: string; + /** Whether preparation was enabled. */ + enabled: boolean; + /** Whether ecosystem research was performed. */ + ecosystemResearchPerformed: boolean; + /** Total duration of preparation in milliseconds. */ + durationMs: number; +} + +/** + * Minimal UI context interface for preparation phase. + * Mirrors the notify method from ExtensionUIContext. + */ +export interface PreparationUIContext { + notify(message: string, type?: "info" | "warning" | "error" | "success"): void; +} + +/** + * Minimal preferences interface for preparation phase. + * Only includes the preferences needed by runPreparation. + */ +export interface PreparationPreferences { + /** Enable the preparation phase. Default: true. */ + discuss_preparation?: boolean; + /** Enable web research during preparation. Default: true. */ + discuss_web_research?: boolean; + /** Depth of analysis. Default: "standard". */ + discuss_depth?: "quick" | "standard" | "thorough"; +} + +/** + * Run the preparation phase before a discussion session. + * + * Orchestrates all three analyzers (codebase, prior context, ecosystem) + * with TUI progress updates. Returns early if preparation is disabled. + * + * @param basePath - Root directory of the project + * @param ui - UI context for progress notifications (null = silent mode) + * @param prefs - Preferences controlling preparation behavior + * @returns PreparationResult with all briefs and metadata + */ +export async function runPreparation( + basePath: string, + ui: PreparationUIContext | null, + prefs: PreparationPreferences, +): Promise { + const startTime = performance.now(); + + // Check if preparation is disabled + const preparationEnabled = prefs.discuss_preparation !== false; // Default: true + + if (!preparationEnabled) { + // Return minimal result with empty briefs + const emptyCodebase: CodebaseBrief = { + techStack: { + primaryLanguage: undefined, + detectedFiles: [], + packageManager: undefined, + isMonorepo: false, + hasTests: false, + hasCI: false, + }, + moduleStructure: { + topLevelDirs: [], + srcSubdirs: [], + totalFilesSampled: 0, + }, + patterns: { + asyncStyle: "unknown", + errorHandling: "unknown", + namingConvention: "unknown", + evidence: { + asyncStyle: [], + errorHandling: [], + namingConvention: [], + }, + fileCounts: { + asyncAwait: 0, + promises: 0, + callbacks: 0, + tryCatch: 0, + errorCallbacks: 0, + resultTypes: 0, + }, + }, + sampledFiles: [], + }; + + const emptyPriorContext: PriorContextBrief = { + decisions: { + byScope: new Map(), + totalCount: 0, + }, + requirements: { + active: [], + validated: [], + deferred: [], + totalCount: 0, + }, + knowledge: "No prior knowledge recorded.", + summaries: "No prior milestone summaries.", + }; + + const emptyEcosystem: EcosystemBrief = { + available: false, + queries: [], + findings: [], + skippedReason: "Preparation phase disabled.", + }; + + return { + codebase: emptyCodebase, + codebaseBrief: "", + priorContext: emptyPriorContext, + priorContextBrief: "", + ecosystem: emptyEcosystem, + ecosystemBrief: "", + enabled: false, + ecosystemResearchPerformed: false, + durationMs: performance.now() - startTime, + }; + } + + // --- Phase 1: Analyze codebase --- + ui?.notify("Analyzing codebase...", "info"); + const codebase = await analyzeCodebase(basePath); + const codebaseBrief = formatCodebaseBrief(codebase); + ui?.notify("✓ Analyzed codebase", "success"); + + // --- Phase 2: Review prior context --- + ui?.notify("Reviewing prior context...", "info"); + const priorContext = await aggregatePriorContext(basePath); + const priorContextBrief = formatPriorContextBrief(priorContext); + ui?.notify("✓ Reviewed prior context", "success"); + + // --- Phase 3: Ecosystem research (optional) --- + const webResearchEnabled = prefs.discuss_web_research !== false; // Default: true + let ecosystem: EcosystemBrief; + let ecosystemResearchPerformed = false; + + if (webResearchEnabled) { + ui?.notify("Researching ecosystem...", "info"); + + // Build tech stack from codebase analysis + const techStack: string[] = []; + if (codebase.techStack.primaryLanguage) { + techStack.push(codebase.techStack.primaryLanguage); + } + // Add detected framework signals from files + for (const file of codebase.techStack.detectedFiles) { + if (file === "next.config.js" || file === "next.config.mjs" || file === "next.config.ts") { + techStack.push("Next.js"); + } else if (file === "nuxt.config.js" || file === "nuxt.config.ts") { + techStack.push("Nuxt.js"); + } else if (file === "svelte.config.js") { + techStack.push("Svelte"); + } else if (file === "vue.config.js") { + techStack.push("Vue.js"); + } else if (file === "angular.json") { + techStack.push("Angular"); + } else if (file === "remix.config.js") { + techStack.push("Remix"); + } else if (file === "astro.config.mjs") { + techStack.push("Astro"); + } + } + + ecosystem = await researchEcosystem(techStack, basePath); + ecosystemResearchPerformed = ecosystem.available; + + if (ecosystem.available) { + ui?.notify("✓ Researched ecosystem", "success"); + } else { + ui?.notify("⚠ Ecosystem research skipped", "warning"); + } + } else { + ecosystem = { + available: false, + queries: [], + findings: [], + skippedReason: "Web research disabled in preferences.", + }; + } + + const ecosystemBrief = formatEcosystemBrief(ecosystem); + + return { + codebase, + codebaseBrief, + priorContext, + priorContextBrief, + ecosystem, + ecosystemBrief, + enabled: true, + ecosystemResearchPerformed, + durationMs: performance.now() - startTime, + }; +} + +export function formatEcosystemBrief(brief: EcosystemBrief): string { + const sections: string[] = []; + + sections.push("## Ecosystem Research"); + + if (!brief.available) { + sections.push(""); + sections.push(`⚠️ ${brief.skippedReason || "Ecosystem research was skipped."}`); + sections.push(""); + sections.push("_FYI: Ecosystem research provides best practices and known issues for your tech stack. Configure a search API key to enable._"); + return sections.join("\n"); + } + + if (brief.queries.length > 0) { + sections.push(""); + sections.push("**Queries performed:**"); + for (const query of brief.queries) { + sections.push(`- "${query}"`); + } + } + + if (brief.findings.length === 0) { + sections.push(""); + sections.push("_No relevant findings returned. This may be due to API limitations or timeout._"); + sections.push(""); + sections.push("_FYI: Results are informational context only, not hard constraints._"); + } else { + sections.push(""); + sections.push("**Key findings:**"); + sections.push(""); + + // Group findings by query for readability + const findingsByQuery = new Map(); + for (const finding of brief.findings) { + const existing = findingsByQuery.get(finding.query) || []; + existing.push(finding); + findingsByQuery.set(finding.query, existing); + } + + for (const [query, queryFindings] of findingsByQuery) { + sections.push(`### ${query}`); + for (const finding of queryFindings.slice(0, 5)) { // Limit per query + sections.push(`- **${finding.title}**`); + if (finding.snippet) { + sections.push(` ${finding.snippet}`); + } + if (finding.url) { + sections.push(` _Source: ${finding.url}_`); + } + } + sections.push(""); + } + + sections.push("_FYI: These findings are informational context only, not hard constraints._"); + } + + let result = sections.join("\n"); + + // Truncate if necessary + if (result.length > MAX_ECOSYSTEM_BRIEF_CHARS) { + result = truncateSection(result, MAX_ECOSYSTEM_BRIEF_CHARS); + } + + return result; +} diff --git a/src/resources/extensions/gsd/prompt-validation.ts b/src/resources/extensions/gsd/prompt-validation.ts new file mode 100644 index 000000000..d2b33f9fb --- /dev/null +++ b/src/resources/extensions/gsd/prompt-validation.ts @@ -0,0 +1,83 @@ +/** + * GSD Prompt Validation — Validates enhanced context output before writing. + * + * Implements R109 validation requirement: CONTEXT.md must have required sections + * before being written to disk. + */ + +/** + * Result of validating enhanced context output. + */ +export interface ValidationResult { + /** Whether all required sections are present. */ + valid: boolean; + /** List of missing required sections. */ + missing: string[]; +} + +/** + * Validate that enhanced context content has all required sections. + * + * Required sections per R109: + * - Scope section (## Scope, ## Milestone Scope, or ## Why This Milestone) + * - Architectural Decisions section (## Architectural Decisions) + * - Acceptance Criteria section (## Acceptance Criteria or ## Final Integrated Acceptance) + * + * Additionally validates that the Architectural Decisions section contains + * at least one decision entry (### heading or **Decision marker). + * + * @param content - The enhanced context markdown content + * @returns ValidationResult with valid flag and list of missing sections + */ +export function validateEnhancedContext(content: string): ValidationResult { + const missing: string[] = []; + + // Required section 1: Scope (multiple acceptable header variants) + const hasScopeSection = + /^## Scope\b/m.test(content) || + /^## Milestone Scope\b/m.test(content) || + /^## Why This Milestone\b/m.test(content); + + if (!hasScopeSection) { + missing.push("Milestone Scope or Why This Milestone"); + } + + // Required section 2: Architectural Decisions + const hasArchitecturalDecisions = /^## Architectural Decisions\b/m.test(content); + if (!hasArchitecturalDecisions) { + missing.push("Architectural Decisions"); + } + + // Required section 3: Acceptance Criteria (multiple acceptable header variants) + const hasAcceptanceCriteria = + /^## Acceptance Criteria\b/m.test(content) || + /^## Final Integrated Acceptance\b/m.test(content); + + if (!hasAcceptanceCriteria) { + missing.push("Acceptance Criteria"); + } + + // Additional validation: Architectural Decisions must have at least one entry + if (hasArchitecturalDecisions) { + // Extract the section content between ## Architectural Decisions and the next ## heading + const sectionMatch = content.match( + /^## Architectural Decisions\b[\s\S]*?(?=^## |\z)/m, + ); + const sectionContent = sectionMatch ? sectionMatch[0] : ""; + + // Check for actual decision entries: + // - ### heading (subsection per decision) + // - **Decision marker (inline decision format) + const hasDecisionEntry = + /^### /m.test(sectionContent) || /^\*\*Decision/m.test(sectionContent); + + if (!hasDecisionEntry) { + missing.push("At least one architectural decision entry"); + } + } + + return { + valid: missing.length === 0, + missing, + }; +} diff --git a/src/resources/extensions/gsd/prompts/discuss-prepared.md b/src/resources/extensions/gsd/prompts/discuss-prepared.md new file mode 100644 index 000000000..835edf96b --- /dev/null +++ b/src/resources/extensions/gsd/prompts/discuss-prepared.md @@ -0,0 +1,411 @@ +{{preamble}} + +You are conducting a **prepared discussion** — the system has already analyzed the codebase, gathered prior context, and researched the ecosystem. Your job is to present these findings, make recommendations, and gather the user's input through a structured 4-layer protocol. + +## Preparation Briefs + +The following briefs were generated during the preparation phase. Use them to ground your recommendations. + +### Codebase Brief + +{{codebaseBrief}} + +### Prior Context Brief + +{{priorContextBrief}} + +### Ecosystem Brief + +{{ecosystemBrief}} + +--- + +## 4-Layer Discussion Protocol + +This discussion proceeds through four mandatory layers. At each layer: +1. **Present findings** — share what the preparation revealed +2. **Make a recommendation** — take a position based on the evidence +3. **Ask clarifying questions** — fill gaps the preparation couldn't answer +4. **Gate** — use `ask_user_questions` to get explicit sign-off before advancing + +**Do NOT skip layers.** Each layer builds on the previous. The user must explicitly approve each layer before you proceed. + +--- + +## Depth Adaptation + +The depth of questioning at each layer should match THIS milestone's work type. Do not apply a fixed checklist — reason from first principles about what matters for this specific work. + +**Work-type reasoning:** +- **API/service work** — Focus Layer 2 questions on contracts, versioning, backwards compatibility, authentication boundaries. Layer 3 must cover rate limiting, timeout cascades, and partial failure states. +- **CLI/developer tools** — Focus Layer 1 on user mental model and command grammar. Layer 4 needs shell compatibility, error message clarity, and exit code semantics. +- **ML/data pipelines** — Focus Layer 2 on data flow, reproducibility, and intermediate state. Layer 3 must cover data corruption, training divergence, and checkpoint recovery. +- **UI/frontend work** — Focus Layer 2 on component boundaries and state management. Layer 3 needs loading states, optimistic updates, and offline behavior. Layer 4 must include visual regression criteria. +- **Infrastructure/platform** — Focus Layer 2 on deployment topology and failure domains. Layer 3 must cover cascading failures, resource exhaustion, and rollback paths. +- **Refactoring/migration** — Focus Layer 1 on what changes vs what must stay identical. Layer 4 needs behavioral equivalence tests, not just code coverage. + +**Adaptation principle:** Ask "What would cause this milestone to fail silently or succeed incorrectly?" The answer shapes which questions deserve deep exploration vs quick confirmation. + +--- + +## Layer 1 — Scope (What are we building?) + +### Identify Work Type + +**Before presenting findings, identify the primary work type and state it explicitly:** + +"Based on [user's request and codebase analysis], this milestone is primarily **[work type]** work (e.g., API/backend, UI/frontend, CLI tool, data pipeline, simulation, infrastructure)." + +This classification determines the depth and focus of questioning at each layer. If the work type spans multiple categories, state the dominant type and note the secondary types. The user can correct this classification. + +### Present Findings + +Start by presenting what you learned from the preparation: + +1. **From the Codebase Brief:** Summarize the technology stack, key modules, and established patterns. Call out anything that constrains or enables the proposed work. + +2. **From the Prior Context Brief:** Surface existing decisions, requirements, and knowledge that are relevant. Note any prior commitments or constraints. + +3. **Scope implications:** Based on the above, explain what scope makes sense and what would conflict with the existing codebase. + +### Make a Recommendation + +Take a clear position: "Based on [specific findings], I recommend the milestone scope as [concrete description]." + +Include: +- What the milestone will deliver (user-visible outcome) +- What it explicitly excludes (to prevent scope creep) +- Rough size estimate (number of slices, complexity) + +### Resolve Scope — Mandatory Rounds + +After presenting your recommendation, you MUST complete these rounds in order. Each round uses `ask_user_questions` or direct questions. Do NOT skip rounds. Do NOT combine rounds. Do NOT jump to the Layer 1 Gate until all rounds are complete. + +**Complexity calibration:** If the milestone is simple (1-2 slices, well-understood patterns, no ambiguity), you may compress rounds — but you must still explicitly address each round's topic, even if briefly. You may NOT skip rounds entirely. For complex milestones (3+ slices, novel architecture, significant ambiguity), give each round full treatment. + +**Round 1 — Feature boundaries:** +For each feature in your recommendation, state what it includes and excludes. Ask the user to confirm or adjust each boundary. Example: "Signup — I'm including email/password registration. I'm excluding OAuth, email verification, and phone number signup. Correct?" + +**Round 2 — Ambiguity resolution:** +Identify every term or concept in the scope that could be interpreted multiple ways. For each one, state the two most likely interpretations and ask which the user intends. Example: "'User authentication' — do you mean just login/signup, or also session management, token refresh, and logout?" + +**Round 3 — Dependencies and constraints:** +Ask about external dependencies (APIs, services, databases), existing code that will be affected, and constraints the user hasn't mentioned. Reference specific findings from the codebase brief. Example: "Your db.ts already has a getUser() function — should signup create users compatible with this existing model?" + +**Round 4 — Priority and ordering:** +If the scope has multiple features, ask the user to rank them by priority. Ask what's the minimum viable version if the milestone needs to be cut short. Example: "If we had to ship with only 2 of the 3 slices, which two matter most?" + +After completing all 4 rounds, proceed to the Layer 1 Gate. + +### Layer 1 Gate + +Before advancing, use `ask_user_questions` with question ID containing `layer1_scope_gate`: + +``` +Header: "Scope Gate" +Question: "Does this scope capture what you want to build?" +Options: + - "Yes, scope is correct (Recommended)" — proceed to Layer 2 + - "Needs adjustment" — user will clarify, then re-present scope +``` + +**Do NOT proceed to Layer 2 until the user explicitly approves the scope.** + +--- + +## Layer 2 — Architecture (How will it work?) + +### Present Findings + +Now present architectural recommendations grounded in evidence: + +1. **From the Ecosystem Brief:** Share relevant best practices, known issues, library recommendations, and integration patterns discovered during research. + +2. **From the Codebase Brief:** Identify existing architectural patterns that should be followed or deliberately broken from. + +3. **Synthesis:** Explain how the ecosystem research applies to this specific codebase context. + +### Make a Recommendation + +Take a clear position: "I'd suggest [approach] because [evidence-based rationale]." + +Cover: +- Overall architectural approach (new module? extend existing? separate service?) +- Key technical decisions (which libraries, patterns, data flow) +- Integration points with existing code +- What you'd avoid and why + +### Resolve Architecture — Mandatory Rounds + +After presenting your recommendation, you MUST complete these rounds in order. Do NOT skip rounds. Do NOT jump to the Layer 2 Gate until all rounds are complete. + +**Complexity calibration:** If the milestone is simple (1-2 slices, well-understood patterns, no ambiguity), you may compress rounds — but you must still explicitly address each round's topic, even if briefly. You may NOT skip rounds entirely. For complex milestones (3+ slices, novel architecture, significant ambiguity), give each round full treatment. + +**Round 1 — Per-slice technical decisions:** +For each slice in your decomposition, state the specific technical approach. Ask the user to confirm or adjust. Don't just say "build the signup endpoint" — state which library handles password hashing, where the route file lives, what the request/response schema looks like. + +**Round 2 — Inter-slice contracts:** +For each dependency between slices, state explicitly what the upstream slice produces and what the downstream slice expects. Ask the user to confirm the interface. Example: "S01 produces a User model with {id, email, hashedPassword}. S02's login endpoint will query by email and compare password. Does this contract work?" + +**Round 3 — Library and pattern decisions:** +For each library or pattern choice, present at least one alternative with tradeoffs. Ask the user to confirm. Example: "bcrypt vs argon2 for password hashing — bcrypt is more common in Node, argon2 is newer and more resistant to GPU attacks. I recommend bcrypt for simplicity. Agree?" + +**Round 4 — Integration with existing code:** +Walk through how the new code connects to existing files and patterns. Ask about anything that might conflict. Reference specific files from the codebase brief. Example: "The new auth routes will mount at /api/auth alongside your existing /api router in routes.ts. Should they share the same router file or get their own auth-routes.ts?" + +After completing all 4 rounds, proceed to the Layer 2 Gate. + +### Layer 2 Gate + +Before advancing, use `ask_user_questions` with question ID containing `layer2_architecture_gate`: + +``` +Header: "Architecture Gate" +Question: "Ready to move to error handling, or want to adjust the architecture?" +Options: + - "Architecture looks good (Recommended)" — proceed to Layer 3 + - "Want to adjust" — user will clarify, then re-present architecture +``` + +**Do NOT proceed to Layer 3 until the user explicitly approves the architecture.** + +--- + +## Layer 3 — Error States (What can go wrong?) + +### Present Findings + +Identify failure modes based on the scope and architecture: + +1. **From the Ecosystem Brief:** Known issues, common pitfalls, edge cases that trip up similar implementations. + +2. **From the Architecture:** Failure points at integration boundaries, async operations, external dependencies, user input handling. + +3. **From the Codebase Brief:** How existing code handles errors — patterns to follow, gaps to fill. + +### Make a Recommendation + +Take a clear position: "The critical error paths are [X, Y, Z]. I recommend handling them by [approach]." + +Cover: +- **Must-handle errors:** Failures that would break the user experience or corrupt data +- **Should-handle errors:** Degraded experiences that are acceptable with good messaging +- **Edge cases:** Boundary conditions, malformed input, timing issues +- **Recovery strategy:** Retry logic, fallback behavior, user notification + +### Resolve Error Handling — Mandatory Rounds + +After presenting your recommendation, ask the user: + +**"Do you want to go deep on error handling, or accept the defaults I recommended?"** + +Use `ask_user_questions` with options: "Go deep" / "Accept defaults" + +If they accept defaults, record your recommendations as decisions and proceed to the Layer 3 Gate. + +If they want to go deep, complete these rounds: + +**Complexity calibration:** If the milestone is simple, you may compress rounds — but you must still explicitly address each round's topic. You may NOT skip rounds entirely. + +**Round 1 — Input validation:** +For each endpoint or entry point, state what input validation happens and what error the user sees for invalid input. Ask the user to confirm. Example: "Signup with missing email returns 400 with {error: 'Email is required'}. Signup with invalid email format returns 400 with {error: 'Invalid email format'}. Right?" + +**Round 2 — Authentication/authorization failures:** +For each protected operation, state what happens when auth fails. Ask the user to confirm. Example: "Expired JWT returns 401. Missing JWT returns 401. Malformed JWT returns 401. All three use the same generic message to avoid information leakage. Correct?" + +**Round 3 — System failures:** +For each external dependency (database, API, file system), state what happens when it's unavailable. Ask the user to confirm. Example: "If Prisma can't connect to the database, all endpoints return 500 with a generic message. We log the real error server-side but never expose it to the client." + +After completing all rounds (or accepting defaults), proceed to the Layer 3 Gate. + +### Layer 3 Gate + +Before advancing, use `ask_user_questions` with question ID containing `layer3_error_gate`: + +``` +Header: "Error Handling Gate" +Question: "Error handling strategy captured. Ready to define the quality bar?" +Options: + - "Yes, move to quality bar (Recommended)" — proceed to Layer 4 + - "Want to adjust error handling" — user will clarify, then re-present errors +``` + +**Do NOT proceed to Layer 4 until the user explicitly approves error handling.** + +--- + +## Layer 4 — Quality Bar (What does done mean?) + +### Present Findings + +Define what "done" looks like based on everything discussed: + +1. **Testing requirements:** What must be tested? Unit tests, integration tests, E2E tests? Based on the architecture's complexity and risk profile. + +2. **Acceptance criteria:** Concrete, observable outcomes that prove the milestone is complete. Derived from the scope discussion. + +3. **Performance/quality constraints:** Based on ecosystem research and codebase patterns — response times, error rates, accessibility requirements. + +### Make a Recommendation + +Take a clear position: "For this scope, I'd suggest these acceptance criteria: [list]." + +Include: +- **Definition of done:** What conditions must be true for the milestone to be complete? +- **Test coverage expectations:** What must be tested vs nice-to-have? +- **Quality gates:** What would block shipping? + +### Resolve Quality — Mandatory Rounds + +After presenting your recommendation, you MUST complete these rounds in order. Do NOT skip rounds. + +**Complexity calibration:** If the milestone is simple, you may compress rounds — but you must still explicitly address each round's topic, even if briefly. You may NOT skip rounds entirely. + +**Round 1 — Per-slice acceptance criteria:** +For each slice, state 3-5 specific, testable acceptance criteria. Ask the user to confirm each slice's criteria. These must be concrete enough that the planner can use them directly. "Tests pass" is NOT an acceptance criterion. "POST /api/auth/signup with {email, password} returns 201 with {id, email}" IS an acceptance criterion. + +**Round 2 — Test strategy:** +For each slice, state what type of tests are needed (unit, integration, e2e) and what specifically gets tested. Ask the user to confirm. Example: "S01 needs: unit test for password hashing, integration test for signup endpoint with valid and invalid inputs. No e2e needed for this slice." + +**Round 3 — Definition of done:** +State the end-to-end scenario that proves the milestone works. Ask the user to confirm. Example: "Done means: a new user can sign up, log in, receive a JWT, and use that JWT to access a protected endpoint — all verified by running the sequence manually or via integration test." + +After completing all 3 rounds, proceed to the Layer 4 Gate. + +### Layer 4 Gate + +Before advancing, use `ask_user_questions` with question ID containing `layer4_quality_gate`: + +``` +Header: "Quality Gate" +Question: "Quality bar defined. Ready to write context and roadmap?" +Options: + - "Yes, write the artifacts (Recommended)" — proceed to Output Phase + - "Want to adjust the quality bar" — user will clarify, then re-present quality +``` + +**Do NOT proceed to Output Phase until the user explicitly approves the quality bar.** + +--- + +## Output Phase + +Once all four layers are complete, you have gathered: +- Confirmed scope (Layer 1) +- Approved architecture (Layer 2) +- Error handling strategy (Layer 3) +- Quality bar and acceptance criteria (Layer 4) + +### Capability Contract + +Before writing a roadmap, produce or update `.gsd/REQUIREMENTS.md`. + +Use it as the project's explicit capability contract. Requirements discovered during the 4-layer discussion should be captured here with source `user` or `inferred` as appropriate. + +**Print the requirements in chat before writing the roadmap.** Print a markdown table with columns: ID, Title, Status, Owner, Source. Group by status (Active, Deferred, Out of Scope). After the table, ask: "Confirm, adjust, or add?" + +### Roadmap Preview + +Before writing any files, **print the planned roadmap in chat** so the user can see and approve it. Print a markdown table with columns: Slice, Title, Risk, Depends, Demo. One row per slice. Below the table, print the milestone definition of done as a bullet list. + +If the user raises a substantive objection, adjust the roadmap. Otherwise, present the roadmap and ask: "Ready to write, or want to adjust?" — one gate, not two. + +### Naming Convention + +Directories use bare IDs. Files use ID-SUFFIX format. Titles live inside file content, not in names. +- Milestone dir: `.gsd/milestones/{{milestoneId}}/` +- Milestone files: `{{milestoneId}}-CONTEXT.md`, `{{milestoneId}}-ROADMAP.md` +- Slice dirs: `S01/`, `S02/`, etc. + +### Single Milestone + +Once the user is satisfied, in a single pass: +1. `mkdir -p .gsd/milestones/{{milestoneId}}/slices` +2. Write or update `.gsd/PROJECT.md` — use the **Project** output template below. Describe what the project is, its current state, and list the milestone sequence. +3. Write or update `.gsd/REQUIREMENTS.md` — use the **Requirements** output template below. Confirm requirement states, ownership, and traceability before roadmap creation. + +**Depth-Preservation Guidance for context.md:** +When writing context.md, preserve the user's exact terminology, emphasis, and specific framing from the discussion. Do not paraphrase user nuance into generic summaries. If the user said "craft feel," write "craft feel" — not "high-quality user experience." If they emphasized a specific constraint or negative requirement, carry that emphasis through verbatim. The context file is downstream agents' only window into this conversation — flattening specifics into generics loses the signal that shaped every decision. + +**Enhanced Context Requirement:** Because this is a prepared discussion, use the `context-enhanced` template which includes sections for Codebase Brief, Architectural Decisions, Interface Contracts, Error Handling Strategy, Testing Requirements, Acceptance Criteria, and Ecosystem Notes. Populate these from the 4-layer discussion: +- Codebase Brief: from Layer 1 presentation +- Architectural Decisions: from Layer 2 — each decision with rationale, evidence, alternatives +- Error Handling Strategy: from Layer 3 +- Testing Requirements and Acceptance Criteria: from Layer 4 +- Ecosystem Notes: key findings from the ecosystem brief + +4. Write `{{contextPath}}` — use the **Context Enhanced** output template below. Preserve key risks, unknowns, existing codebase constraints, integration points, and relevant requirements surfaced during discussion. +5. Call `gsd_plan_milestone` to create the roadmap. Decompose into demoable vertical slices with risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. Use the **Roadmap** output template below to structure the tool call parameters. +6. For each architectural or pattern decision made during discussion, call `gsd_decision_save` — the tool auto-assigns IDs and regenerates `.gsd/DECISIONS.md` automatically. +7. {{commitInstruction}} + +After writing the files, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Auto-mode will start automatically. + +### Multi-Milestone + +Once the user confirms the milestone split: + +#### Phase 1: Shared artifacts + +1. For each milestone, call `gsd_milestone_generate_id` to get its ID — never invent milestone IDs manually. Then `mkdir -p .gsd/milestones//slices`. +2. Write `.gsd/PROJECT.md` — use the **Project** output template below. +3. Write `.gsd/REQUIREMENTS.md` — use the **Requirements** output template below. Capture Active, Deferred, Out of Scope, and any already Validated requirements. Later milestones may have provisional ownership where slice plans do not exist yet. +4. For any architectural or pattern decisions made during discussion, call `gsd_decision_save` — the tool auto-assigns IDs and regenerates `.gsd/DECISIONS.md` automatically. + +#### Phase 2: Primary milestone + +5. Write a full enhanced `CONTEXT.md` for the primary milestone (the one discussed in depth). Use the `context-enhanced` template. +6. Call `gsd_plan_milestone` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. + +#### MANDATORY: depends_on Frontmatter in CONTEXT.md + +Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The auto-mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't. + +```yaml +--- +depends_on: [M001, M002] +--- + +# M003: Title +``` + +If a milestone has no dependencies, omit the frontmatter. The dependency chain from the milestone confirmation gate MUST be reflected in each CONTEXT.md frontmatter. Do NOT rely on QUEUE.md or PROJECT.md for dependency tracking — the state machine only reads CONTEXT.md frontmatter. + +#### Phase 3: Sequential readiness gate for remaining milestones + +For each remaining milestone **one at a time, in sequence**, decide the most likely readiness mode from the evidence you already have, then use `ask_user_questions` to let the user correct that recommendation. Present three options: + +- **"Discuss now"** — The user wants to conduct a focused discussion for this milestone in the current session, while the context from the broader discussion is still fresh. Proceed with a focused discussion for this milestone (Layer 1-4 protocol). When the discussion concludes, write a full enhanced `CONTEXT.md`. Then move to the gate for the next milestone. +- **"Write draft for later"** — This milestone has seed material from the current conversation but needs its own dedicated discussion in a future session. Write a `CONTEXT-DRAFT.md` capturing the seed material (what was discussed, key ideas, provisional scope, open questions). Mark it clearly as a draft, not a finalized context. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user: "M00x has draft context — needs discussion. Run /gsd." The `/gsd` wizard shows a "Discuss from draft" option that seeds the new discussion with this draft, so nothing from the current conversation is lost. After the dedicated discussion produces a full CONTEXT.md, the draft file is automatically deleted. +- **"Just queue it"** — This milestone is identified but intentionally left without context. No context file is written — the directory already exists from Phase 1. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user to run /gsd. The wizard starts a full discussion from scratch. + +**When "Discuss now" is chosen:** Run the full 4-layer protocol for that milestone using fresh preparation briefs scoped to that milestone. + +#### Milestone Gate Tracking (MANDATORY for multi-milestone) + +After EVERY Phase 3 gate decision, immediately write or update `.gsd/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before auto-mode starts — if gates are incomplete, auto-mode will NOT start. + +```json +{ + "primary": "M001", + "milestones": { + "M001": { "gate": "discussed", "context": "full" }, + "M002": { "gate": "discussed", "context": "full" }, + "M003": { "gate": "queued", "context": "none" } + }, + "total": 3, + "gates_completed": 3 +} +``` + +Write this file AFTER each gate decision, not just at the end. Update `gates_completed` incrementally. The system reads this file and BLOCKS auto-start if `gates_completed < total`. + +For single-milestone projects, do NOT write this file — it is only for multi-milestone discussions. + +#### Phase 4: Finalize + +7. {{multiMilestoneCommitInstruction}} + +After writing the files, say exactly: "Milestone M001 ready." — nothing else. Auto-mode will start automatically. + +{{inlinedTemplates}} diff --git a/src/resources/extensions/gsd/templates/context-enhanced.md b/src/resources/extensions/gsd/templates/context-enhanced.md new file mode 100644 index 000000000..503ffaf17 --- /dev/null +++ b/src/resources/extensions/gsd/templates/context-enhanced.md @@ -0,0 +1,138 @@ +# {{milestoneId}}: {{milestoneTitle}} + +**Gathered:** {{date}} +**Status:** Ready for planning + +## Project Description + +{{description}} + +## Why This Milestone + +{{whatProblemThisSolves_AND_whyNow}} + +## Codebase Brief + +### Technology Stack + +{{techStack}} + +### Key Modules + +{{keyModules}} + +### Patterns in Use + +{{patternsInUse}} + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- {{literalUserActionInRealEnvironment}} +- {{literalUserActionInRealEnvironment}} + +### Entry point / environment + +- Entry point: {{CLI command / URL / bot / extension / service / workflow}} +- Environment: {{local dev / browser / mobile / launchd / CI / production-like}} +- Live dependencies involved: {{telegram / database / webhook / rpc subprocess / none}} + +## Completion Class + +- Contract complete means: {{what can be proven by tests / fixtures / artifacts}} +- Integration complete means: {{what must work across real subsystems}} +- Operational complete means: {{what must work under real lifecycle conditions, or none}} + +## Architectural Decisions + +### {{decisionTitle}} + +**Decision:** {{decisionStatement}} + +**Rationale:** {{rationale}} + +**Evidence:** {{evidence}} + +**Alternatives Considered:** +- {{alternative1}} — {{whyNotChosen1}} +- {{alternative2}} — {{whyNotChosen2}} + +--- + +> Add additional decisions as separate `### Decision Title` blocks following the same structure above. + +## Interface Contracts + +{{interfaceContracts}} + +> Document API boundaries, function signatures, data shapes, or protocol agreements that must be honored. Leave blank or remove if not applicable to this milestone. + +## Error Handling Strategy + +{{errorHandlingStrategy}} + +> Describe the approach for handling failures, edge cases, and error propagation. Include retry policies, fallback behaviors, and user-facing error messages where relevant. + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- {{one real end-to-end scenario}} +- {{one real end-to-end scenario}} +- {{what cannot be simulated if this milestone is to be considered truly done}} + +## Testing Requirements + +{{testingRequirements}} + +> Specify test types (unit, integration, e2e), coverage expectations, and any specific test scenarios that must pass. + +## Acceptance Criteria + +{{acceptanceCriteria}} + +> Per-slice acceptance criteria gathered during discussion. Each slice should have clear, testable criteria. + +## Risks and Unknowns + +- {{riskOrUnknown}} — {{whyItMatters}} + +## Existing Codebase / Prior Art + +- `{{fileOrModule}}` — {{howItRelates}} +- `{{fileOrModule}}` — {{howItRelates}} + +> See `.gsd/DECISIONS.md` for all architectural and pattern decisions — it is an append-only register; read it during planning, append to it during execution. + +## Relevant Requirements + +- {{requirementId}} — {{howThisMilestoneAdvancesIt}} + +## Scope + +### In Scope + +- {{inScopeItem}} + +### Out of Scope / Non-Goals + +- {{outOfScopeItem}} + +## Technical Constraints + +- {{constraint}} + +## Integration Points + +- {{systemOrService}} — {{howThisMilestoneInteractsWithIt}} + +## Ecosystem Notes + +{{ecosystemNotes}} + +> Research findings, best practices, known issues, and relevant external documentation discovered during preparation. + +## Open Questions + +- {{question}} — {{currentThinking}} diff --git a/src/resources/extensions/gsd/tests/integration-prepared-discussion.test.ts b/src/resources/extensions/gsd/tests/integration-prepared-discussion.test.ts new file mode 100644 index 000000000..b991eb4b4 --- /dev/null +++ b/src/resources/extensions/gsd/tests/integration-prepared-discussion.test.ts @@ -0,0 +1,525 @@ +/** + * Integration tests for the prepared discussion system. + * + * Exercises the full preparation pipeline against the real GSD-2 codebase: + * - runPreparation() produces valid briefs + * - TypeScript is detected as primary language + * - Module structure includes top-level directories + * - Completes within R112 timing requirement (<60s) + * - prepareAndBuildDiscussPrompt() uses discuss-prepared template when enabled + * - Fallback to standard prompt when preparation is disabled + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { + runPreparation, + formatCodebaseBrief, + formatPriorContextBrief, + formatEcosystemBrief, + type PreparationUIContext, + type PreparationPreferences, + type PreparationResult, +} from "../preparation.ts"; +import { validateEnhancedContext } from "../prompt-validation.ts"; +import { getLastPreparationResult, clearPreparationResult } from "../guided-flow.ts"; + +// ─── Test Helpers ─────────────────────────────────────────────────────────────── + +/** + * Mock UI context that captures notifications for testing. + * Follows the pattern from preparation.test.ts. + */ +function createMockUI(): PreparationUIContext & { notifications: Array<{ message: string; type?: string }> } { + const notifications: Array<{ message: string; type?: string }> = []; + return { + notifications, + notify(message: string, type?: "info" | "warning" | "error" | "success") { + notifications.push({ message, type }); + }, + }; +} + +/** + * Get the GSD extension source directory for integration testing. + * This is the real codebase we'll analyze. + */ +function getGsdExtensionDir(): string { + // Navigate from tests/ up to gsd/ directory + return join(import.meta.dirname, ".."); +} + +/** + * Get the GSD-2 project root for full codebase analysis. + */ +function getProjectRoot(): string { + // Navigate from tests/ up to the project root + // tests/ -> gsd/ -> extensions/ -> resources/ -> src/ -> gsd-2/ + return join(import.meta.dirname, "..", "..", "..", "..", ".."); +} + +// ─── R111 Validation: runPreparation against real codebase ────────────────────── + +test("R111: runPreparation() produces valid codebase brief for GSD extension", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, // Skip web research to avoid API key requirement + discuss_depth: "standard", + }; + + const result = await runPreparation(dir, ui, prefs); + + // Verify preparation completed successfully + assert.equal(result.enabled, true, "preparation should be enabled"); + assert.ok(result.codebase, "should have codebase brief"); + assert.ok(result.codebaseBrief, "should have formatted codebase brief"); + + // Verify TypeScript is detected as primary language + assert.equal( + result.codebase.techStack.primaryLanguage, + "javascript/typescript", + "should detect TypeScript as primary language", + ); + + // Verify module structure includes top-level directories + const topLevelDirs = result.codebase.moduleStructure.topLevelDirs; + assert.ok(topLevelDirs.length > 0, "should detect top-level directories"); + + // Common directories in the GSD extension + const expectedDirs = ["tests", "prompts", "templates", "migrate"]; + const foundExpected = expectedDirs.filter(d => topLevelDirs.includes(d)); + assert.ok( + foundExpected.length >= 2, + `should detect common directories, found: ${topLevelDirs.join(", ")}`, + ); + + // Verify sampled files exist + assert.ok(result.codebase.sampledFiles.length > 0, "should sample source files"); +}); + +test("R111: runPreparation() produces valid prior context brief", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, ui, prefs); + + // Verify prior context brief structure + assert.ok(result.priorContext, "should have prior context"); + assert.ok(result.priorContextBrief, "should have formatted prior context brief"); + + // Prior context aggregates decisions, requirements, knowledge, summaries + assert.ok("decisions" in result.priorContext, "should have decisions"); + assert.ok("requirements" in result.priorContext, "should have requirements"); + assert.ok("knowledge" in result.priorContext, "should have knowledge"); + assert.ok("summaries" in result.priorContext, "should have summaries"); +}); + +test("R111: runPreparation() produces valid ecosystem brief (skipped without API key)", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, // Explicitly disable + }; + + const result = await runPreparation(dir, ui, prefs); + + // Verify ecosystem brief structure + assert.ok(result.ecosystem, "should have ecosystem brief"); + assert.ok(result.ecosystemBrief, "should have formatted ecosystem brief"); + assert.equal(result.ecosystem.available, false, "ecosystem should be unavailable when web research disabled"); + assert.ok(result.ecosystem.skippedReason, "should have skip reason"); +}); + +test("R112: runPreparation() completes within 60s requirement", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + discuss_depth: "standard", + }; + + const startTime = performance.now(); + const result = await runPreparation(dir, null, prefs); + const elapsed = performance.now() - startTime; + + // R112 requirement: preparation must complete within 60 seconds + assert.ok(result.durationMs < 60000, `should complete within 60s, took ${result.durationMs}ms`); + assert.ok(elapsed < 60000, `wall-clock time should be under 60s, was ${elapsed}ms`); + + // Should be much faster for a local directory analysis + assert.ok(result.durationMs < 10000, `should typically complete within 10s, took ${result.durationMs}ms`); +}); + +// ─── Codebase Pattern Detection ───────────────────────────────────────────────── + +test("runPreparation() detects code patterns from GSD extension", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, null, prefs); + + // The GSD extension uses async/await extensively + assert.ok( + result.codebase.patterns.asyncStyle === "async/await" || result.codebase.patterns.asyncStyle === "mixed", + `should detect async/await or mixed, got ${result.codebase.patterns.asyncStyle}`, + ); + + // The GSD extension uses try/catch for error handling + assert.ok( + result.codebase.patterns.errorHandling === "try/catch" || result.codebase.patterns.errorHandling === "mixed", + `should detect try/catch or mixed, got ${result.codebase.patterns.errorHandling}`, + ); + + // TypeScript uses camelCase or mixed naming + assert.ok( + result.codebase.patterns.namingConvention === "camelCase" || result.codebase.patterns.namingConvention === "mixed", + `should detect camelCase or mixed, got ${result.codebase.patterns.namingConvention}`, + ); + + // Evidence should be populated + assert.ok(result.codebase.patterns.evidence.asyncStyle.length > 0, "should have async style evidence"); +}); + +test("runPreparation() samples TypeScript files from src/ or project root", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, null, prefs); + + // Should sample TypeScript files + const tsFiles = result.codebase.sampledFiles.filter( + f => f.endsWith(".ts") || f.endsWith(".tsx"), + ); + assert.ok(tsFiles.length > 0, "should sample TypeScript files"); + + // Should exclude test files + const testFiles = result.codebase.sampledFiles.filter( + f => f.includes(".test.") || f.includes(".spec."), + ); + assert.equal(testFiles.length, 0, "should not sample test files"); +}); + +// ─── Brief Formatting ─────────────────────────────────────────────────────────── + +test("formatCodebaseBrief() produces LLM-readable markdown", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, null, prefs); + const formatted = formatCodebaseBrief(result.codebase); + + // Should contain expected sections + assert.ok(formatted.includes("## Tech Stack"), "should have Tech Stack section"); + assert.ok(formatted.includes("## Module Structure"), "should have Module Structure section"); + assert.ok(formatted.includes("## Code Patterns"), "should have Code Patterns section"); + + // Should contain detected tech + assert.ok(formatted.includes("javascript/typescript"), "should include detected language"); + + // Should be within character limit + assert.ok(formatted.length <= 3000, `should cap at 3000 chars, got ${formatted.length}`); +}); + +test("formatPriorContextBrief() produces structured prior context output", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, null, prefs); + const formatted = formatPriorContextBrief(result.priorContext); + + // Should contain expected sections + assert.ok(formatted.includes("## Prior Decisions"), "should have Prior Decisions section"); + assert.ok(formatted.includes("## Prior Requirements"), "should have Prior Requirements section"); + assert.ok(formatted.includes("## Prior Knowledge"), "should have Prior Knowledge section"); + assert.ok(formatted.includes("## Prior Milestone Summaries"), "should have Prior Milestone Summaries section"); + + // Should be within character limit + assert.ok(formatted.length <= 6000, `should cap at 6000 chars, got ${formatted.length}`); +}); + +test("formatEcosystemBrief() handles skipped research gracefully", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, null, prefs); + const formatted = formatEcosystemBrief(result.ecosystem); + + // Should contain section header + assert.ok(formatted.includes("## Ecosystem Research"), "should have Ecosystem Research section"); + + // Should indicate research was skipped + assert.ok(formatted.includes("⚠️"), "should have warning indicator"); + assert.ok(formatted.includes("FYI"), "should frame as informational"); + + // Should be within character limit + assert.ok(formatted.length <= 4000, `should cap at 4000 chars, got ${formatted.length}`); +}); + +// ─── Preparation Result Storage ───────────────────────────────────────────────── + +test("getLastPreparationResult() returns null initially", async (t) => { + // Clear any existing state + clearPreparationResult(); + + const result = getLastPreparationResult(); + assert.equal(result, null, "should return null when no preparation has run"); +}); + +test("clearPreparationResult() clears stored result", async (t) => { + // This test verifies the clear function works + // We can't easily test the set behavior without running the full guided-flow + clearPreparationResult(); + const result = getLastPreparationResult(); + assert.equal(result, null, "should be null after clear"); +}); + +// ─── TUI Progress Notifications ───────────────────────────────────────────────── + +test("runPreparation() emits TUI progress notifications", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + await runPreparation(dir, ui, prefs); + + // Should have notifications for each phase + assert.ok(ui.notifications.length > 0, "should have notifications"); + + // Verify codebase analysis notifications + assert.ok( + ui.notifications.some(n => n.message.includes("Analyzing codebase")), + "should show codebase analysis start", + ); + assert.ok( + ui.notifications.some(n => n.message.includes("✓ Analyzed codebase")), + "should show codebase analysis complete", + ); + + // Verify prior context notifications + assert.ok( + ui.notifications.some(n => n.message.includes("Reviewing prior context")), + "should show prior context start", + ); + assert.ok( + ui.notifications.some(n => n.message.includes("✓ Reviewed prior context")), + "should show prior context complete", + ); +}); + +test("runPreparation() works in silent mode (no UI)", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + // Pass null for UI + const result = await runPreparation(dir, null, prefs); + + // Should complete without error + assert.equal(result.enabled, true, "should work without UI"); + assert.ok(result.codebase, "should have codebase"); + assert.ok(result.priorContext, "should have priorContext"); + assert.ok(result.durationMs > 0, "should have duration"); +}); + +// ─── Preference-Controlled Behavior ───────────────────────────────────────────── + +test("runPreparation() returns early when discuss_preparation is false", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: false, + }; + + const result = await runPreparation(dir, ui, prefs); + + assert.equal(result.enabled, false, "should indicate preparation disabled"); + assert.equal(result.codebaseBrief, "", "should have empty codebase brief"); + assert.equal(result.priorContextBrief, "", "should have empty prior context brief"); + assert.equal(result.ecosystemBrief, "", "should have empty ecosystem brief"); + assert.equal(ui.notifications.length, 0, "should not show any notifications"); +}); + +test("runPreparation() skips ecosystem research when discuss_web_research is false", async (t) => { + const dir = getGsdExtensionDir(); + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, ui, prefs); + + assert.equal(result.enabled, true); + assert.equal(result.ecosystemResearchPerformed, false, "should not perform ecosystem research"); + assert.equal(result.ecosystem.available, false); + assert.ok( + result.ecosystem.skippedReason?.includes("Web research disabled"), + "should indicate disabled in preferences", + ); + + // Should NOT have ecosystem research notifications + assert.ok( + !ui.notifications.some(n => n.message.includes("Researching ecosystem")), + "should not show ecosystem research notification", + ); +}); + +// ─── validateEnhancedContext Integration ──────────────────────────────────────── + +test("validateEnhancedContext() validates required sections", async (t) => { + // Test with valid enhanced context + const validContext = `# M001 — Test Milestone + +## Scope + +This milestone covers X, Y, Z. + +## Architectural Decisions + +### Decision 1: Use TypeScript + +We will use TypeScript for type safety. + +## Acceptance Criteria + +- [ ] Feature A works +- [ ] Feature B works +`; + + const validResult = validateEnhancedContext(validContext); + assert.equal(validResult.valid, true, "should validate complete context"); + assert.deepEqual(validResult.missing, [], "should have no missing sections"); + + // Test with missing sections + const invalidContext = `# M001 — Test Milestone + +## Scope + +This milestone covers X, Y, Z. +`; + + const invalidResult = validateEnhancedContext(invalidContext); + assert.equal(invalidResult.valid, false, "should reject incomplete context"); + assert.ok(invalidResult.missing.length > 0, "should list missing sections"); + assert.ok( + invalidResult.missing.some(m => m.includes("Architectural Decisions")), + "should report missing Architectural Decisions", + ); + assert.ok( + invalidResult.missing.some(m => m.includes("Acceptance Criteria")), + "should report missing Acceptance Criteria", + ); +}); + +test("validateEnhancedContext() requires decision entries in Architectural Decisions", async (t) => { + // Empty architectural decisions section + const emptyDecisions = `# M001 — Test Milestone + +## Scope + +This milestone covers X, Y, Z. + +## Architectural Decisions + +(No decisions yet) + +## Acceptance Criteria + +- [ ] Feature A works +`; + + const result = validateEnhancedContext(emptyDecisions); + assert.equal(result.valid, false, "should reject empty decisions section"); + assert.ok( + result.missing.some(m => m.includes("decision entry")), + "should report missing decision entry", + ); +}); + +// ─── Full Pipeline Integration ────────────────────────────────────────────────── + +test("Full pipeline: preparation produces consistent results across runs", async (t) => { + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + // Run preparation twice + const result1 = await runPreparation(dir, null, prefs); + const result2 = await runPreparation(dir, null, prefs); + + // Results should be consistent (same codebase, same analysis) + assert.equal( + result1.codebase.techStack.primaryLanguage, + result2.codebase.techStack.primaryLanguage, + "primary language should be consistent", + ); + + assert.deepEqual( + result1.codebase.moduleStructure.topLevelDirs.sort(), + result2.codebase.moduleStructure.topLevelDirs.sort(), + "top-level directories should be consistent", + ); + + assert.equal( + result1.codebase.patterns.asyncStyle, + result2.codebase.patterns.asyncStyle, + "async style should be consistent", + ); +}); + +test("Full pipeline: preparation handles empty .gsd directory gracefully", async (t) => { + // The GSD extension directory may or may not have a .gsd subdirectory + // Either way, preparation should not crash + const dir = getGsdExtensionDir(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + let result: PreparationResult | undefined; + let error: unknown; + + try { + result = await runPreparation(dir, null, prefs); + } catch (e) { + error = e; + } + + assert.equal(error, undefined, "should not throw"); + assert.ok(result, "should return result"); + assert.equal(result!.enabled, true, "should be enabled"); + + // Prior context should gracefully handle missing files + assert.ok(result!.priorContext, "should have prior context even if files missing"); +}); diff --git a/src/resources/extensions/gsd/tests/preparation.test.ts b/src/resources/extensions/gsd/tests/preparation.test.ts new file mode 100644 index 000000000..d71471112 --- /dev/null +++ b/src/resources/extensions/gsd/tests/preparation.test.ts @@ -0,0 +1,1486 @@ +/** + * Unit tests for GSD Preparation — codebase analysis and brief generation. + * + * Exercises the pure preparation functions: + * - analyzeCodebase() with various project layouts + * - formatCodebaseBrief() output format and truncation + * - Pattern extraction from sampled files + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + analyzeCodebase, + formatCodebaseBrief, + aggregatePriorContext, + formatPriorContextBrief, + hasSearchApiKey, + researchEcosystem, + formatEcosystemBrief, + runPreparation, + type CodebaseBrief, + type PriorContextBrief, + type EcosystemBrief, + type EcosystemFinding, + type PreparationUIContext, + type PreparationPreferences, + type PreparationResult, +} from "../preparation.ts"; +import { PROJECT_FILES } from "../detection.ts"; + +// ─── Test Helpers ─────────────────────────────────────────────────────────────── + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-preparation-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +// ─── analyzeCodebase ──────────────────────────────────────────────────────────── + +test("analyzeCodebase: empty directory returns valid brief structure", async (t) => { + const dir = makeTempDir("empty"); + t.after(() => cleanup(dir)); + + const brief = await analyzeCodebase(dir); + + assert.ok(brief, "should return a brief"); + assert.ok(brief.techStack, "should have techStack"); + assert.ok(brief.moduleStructure, "should have moduleStructure"); + assert.ok(brief.patterns, "should have patterns"); + assert.ok(Array.isArray(brief.sampledFiles), "should have sampledFiles array"); + assert.equal(brief.sampledFiles.length, 0, "empty dir should have no sampled files"); +}); + +test("analyzeCodebase: detects package.json in PROJECT_FILES", async (t) => { + const dir = makeTempDir("pkg-json"); + t.after(() => cleanup(dir)); + + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "test-project", scripts: { test: "jest" } }), + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.ok( + brief.techStack.detectedFiles.includes("package.json"), + "should detect package.json", + ); + assert.equal(brief.techStack.primaryLanguage, "javascript/typescript"); +}); + +test("analyzeCodebase: detects module structure from src/ directory", async (t) => { + const dir = makeTempDir("module-struct"); + t.after(() => cleanup(dir)); + + // Create src directory with subdirs + mkdirSync(join(dir, "src", "components"), { recursive: true }); + mkdirSync(join(dir, "src", "utils"), { recursive: true }); + mkdirSync(join(dir, "src", "hooks"), { recursive: true }); + mkdirSync(join(dir, "test"), { recursive: true }); + + const brief = await analyzeCodebase(dir); + + assert.ok( + brief.moduleStructure.topLevelDirs.includes("src"), + "should detect src as top-level dir", + ); + assert.ok( + brief.moduleStructure.topLevelDirs.includes("test"), + "should detect test as top-level dir", + ); + assert.ok( + brief.moduleStructure.srcSubdirs.includes("components"), + "should detect components subdir", + ); + assert.ok( + brief.moduleStructure.srcSubdirs.includes("utils"), + "should detect utils subdir", + ); + assert.ok( + brief.moduleStructure.srcSubdirs.includes("hooks"), + "should detect hooks subdir", + ); +}); + +test("analyzeCodebase: samples TypeScript files from src/", async (t) => { + const dir = makeTempDir("sample-ts"); + t.after(() => cleanup(dir)); + + // Create src directory with TypeScript files + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync( + join(dir, "src", "index.ts"), + `export async function main() { await fetch('/api'); }`, + "utf-8", + ); + writeFileSync( + join(dir, "src", "utils.ts"), + `export function helper() { try { return 1; } catch (e) { throw e; } }`, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.ok(brief.sampledFiles.length > 0, "should sample at least one file"); + assert.ok( + brief.sampledFiles.some((f) => f.startsWith("src/")), + "should prefer src/ files", + ); +}); + +test("analyzeCodebase: excludes test files from sampling", async (t) => { + const dir = makeTempDir("exclude-tests"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "index.ts"), `export const x = 1;`, "utf-8"); + writeFileSync( + join(dir, "src", "index.test.ts"), + `import test from 'node:test'; test('x', () => {});`, + "utf-8", + ); + writeFileSync( + join(dir, "src", "utils.spec.ts"), + `describe('utils', () => { it('works', () => {}); });`, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + // Should only have index.ts, not test/spec files + for (const file of brief.sampledFiles) { + assert.ok(!file.endsWith(".test.ts"), `should not sample ${file}`); + assert.ok(!file.endsWith(".spec.ts"), `should not sample ${file}`); + } +}); + +test("analyzeCodebase: excludes node_modules from sampling", async (t) => { + const dir = makeTempDir("exclude-nm"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, "node_modules", "some-pkg"), { recursive: true }); + writeFileSync(join(dir, "src", "index.ts"), `export const x = 1;`, "utf-8"); + writeFileSync( + join(dir, "node_modules", "some-pkg", "index.js"), + `module.exports = {};`, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + for (const file of brief.sampledFiles) { + assert.ok(!file.includes("node_modules"), `should not sample ${file}`); + } +}); + +test("analyzeCodebase: extracts async/await pattern", async (t) => { + const dir = makeTempDir("async-await"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync( + join(dir, "src", "api.ts"), + ` +export async function fetchData() { + const res = await fetch('/api'); + const data = await res.json(); + return data; +} + +export async function saveData(data: any) { + await fetch('/api', { method: 'POST', body: JSON.stringify(data) }); +} + `, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.equal( + brief.patterns.asyncStyle, + "async/await", + "should detect async/await as primary style", + ); +}); + +test("analyzeCodebase: extracts try/catch error handling", async (t) => { + const dir = makeTempDir("try-catch"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync( + join(dir, "src", "handler.ts"), + ` +export function handleError() { + try { + doSomething(); + } catch (error) { + console.error(error); + } +} + +export function anotherHandler() { + try { + doOther(); + } catch (e) { + throw new Error('wrapped'); + } +} + `, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.equal( + brief.patterns.errorHandling, + "try/catch", + "should detect try/catch as primary error handling", + ); +}); + +test("analyzeCodebase: extracts camelCase naming convention", async (t) => { + const dir = makeTempDir("camel-case"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync( + join(dir, "src", "utils.ts"), + ` +export function getUserById(userId: string) { + return fetchUser(userId); +} + +export function calculateTotalPrice(itemPrices: number[]) { + return itemPrices.reduce((a, b) => a + b, 0); +} + +export function formatDisplayName(firstName: string, lastName: string) { + return \`\${firstName} \${lastName}\`; +} + `, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + // camelCase should be detected (getUserById, userId, fetchUser, etc.) + assert.ok( + brief.patterns.namingConvention === "camelCase" || brief.patterns.namingConvention === "mixed", + `should detect camelCase or mixed, got ${brief.patterns.namingConvention}`, + ); +}); + +test("analyzeCodebase: gracefully handles empty directories", async (t) => { + const dir = makeTempDir("empty-src"); + t.after(() => cleanup(dir)); + + // Create empty src directory + mkdirSync(join(dir, "src"), { recursive: true }); + + const brief = await analyzeCodebase(dir); + + // Should not throw, should return valid structure + assert.ok(brief.patterns, "should have patterns"); + assert.equal(brief.patterns.asyncStyle, "unknown", "should return unknown for empty"); + assert.equal(brief.patterns.errorHandling, "unknown", "should return unknown for empty"); + assert.equal(brief.patterns.namingConvention, "unknown", "should return unknown for empty"); +}); + +test("analyzeCodebase: returns unknown for unrecognized language patterns (Ruby)", async (t) => { + // Ruby is detected by LANGUAGE_MAP but not in LANGUAGE_PATTERNS registry + // This tests the graceful fallback behavior: naming convention still works, + // but language-specific patterns (async/error) should return "unknown" + const dir = makeTempDir("ruby-project"); + t.after(() => cleanup(dir)); + + // Create a Ruby project with Gemfile (detected as "ruby" in LANGUAGE_MAP) + writeFileSync(join(dir, "Gemfile"), `source "https://rubygems.org"\ngem "rails"`, "utf-8"); + + // Add a Ruby file with patterns that would match JS/TS regexes incorrectly + mkdirSync(join(dir, "lib"), { recursive: true }); + writeFileSync( + join(dir, "lib", "service.rb"), + ` +class UserService + def fetch_user(user_id) + user = User.find(user_id) + user + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("User not found: #{e.message}") + nil + end + + def async_task(&block) + # Ruby doesn't have async/await but has yield and blocks + Thread.new { yield } + end +end + `, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + // Language should be detected as Ruby + assert.equal(brief.techStack.primaryLanguage, "ruby", "should detect ruby from Gemfile"); + + // Language-specific patterns should return "unknown" (not JS/TS patterns) + assert.equal( + brief.patterns.asyncStyle, + "unknown", + "should return unknown for async style in unrecognized language", + ); + assert.equal( + brief.patterns.errorHandling, + "unknown", + "should return unknown for error handling in unrecognized language", + ); + + // But naming convention detection should still work (it's universal) + // The Ruby code uses snake_case (fetch_user, user_id) and camelCase (UserService) + assert.ok( + brief.patterns.namingConvention !== "unknown", + "naming convention should still be detected for unrecognized languages", + ); + + // Evidence should explain why patterns aren't available + assert.ok( + brief.patterns.evidence.asyncStyle.some((e) => e.includes("not in pattern registry")), + "evidence should explain async style is not available", + ); + assert.ok( + brief.patterns.evidence.errorHandling.some((e) => e.includes("not in pattern registry")), + "evidence should explain error handling is not available", + ); +}); + +// ─── formatCodebaseBrief ──────────────────────────────────────────────────────── + +test("formatCodebaseBrief: produces markdown output", async (t) => { + const brief: CodebaseBrief = { + techStack: { + primaryLanguage: "javascript/typescript", + detectedFiles: ["package.json", "tsconfig.json"], + packageManager: "npm", + isMonorepo: false, + hasTests: true, + hasCI: true, + }, + moduleStructure: { + topLevelDirs: ["src", "test"], + srcSubdirs: ["components", "utils"], + totalFilesSampled: 5, + }, + patterns: { + asyncStyle: "async/await", + errorHandling: "try/catch", + namingConvention: "camelCase", + evidence: { + asyncStyle: ["src/api.ts: async/await (5 occurrences)"], + errorHandling: ["src/handler.ts: try/catch (3 occurrences)"], + namingConvention: ["camelCase: 50 occurrences"], + }, + fileCounts: { + asyncAwait: 3, + promises: 0, + callbacks: 0, + tryCatch: 2, + errorCallbacks: 0, + resultTypes: 0, + }, + }, + sampledFiles: ["src/index.ts", "src/utils.ts"], + }; + + const formatted = formatCodebaseBrief(brief); + + assert.ok(formatted.includes("## Tech Stack"), "should have Tech Stack section"); + assert.ok(formatted.includes("## Module Structure"), "should have Module Structure section"); + assert.ok(formatted.includes("## Code Patterns"), "should have Code Patterns section"); + assert.ok(formatted.includes("javascript/typescript"), "should include language"); + assert.ok(formatted.includes("npm"), "should include package manager"); + assert.ok(formatted.includes("async/await"), "should include async style"); + assert.ok(formatted.includes("try/catch"), "should include error handling"); + assert.ok(formatted.includes("camelCase"), "should include naming convention"); + assert.ok(formatted.includes("3 async/await files"), "should include file counts for async style"); + assert.ok(formatted.includes("2 try/catch files"), "should include file counts for error handling"); +}); + +test("formatCodebaseBrief: caps output at 3000 chars", async (t) => { + // Create a brief with many files to exceed the limit + const manyFiles = Array.from({ length: 100 }, (_, i) => `file-${i}.ts`); + + const brief: CodebaseBrief = { + techStack: { + primaryLanguage: "javascript/typescript", + detectedFiles: manyFiles, + packageManager: "npm", + isMonorepo: false, + hasTests: true, + hasCI: true, + }, + moduleStructure: { + topLevelDirs: Array.from({ length: 50 }, (_, i) => `dir-${i}`), + srcSubdirs: Array.from({ length: 50 }, (_, i) => `subdir-${i}`), + totalFilesSampled: 100, + }, + patterns: { + asyncStyle: "async/await", + errorHandling: "try/catch", + namingConvention: "camelCase", + evidence: { + asyncStyle: manyFiles.map((f) => `${f}: async/await (10 occurrences)`), + errorHandling: manyFiles.map((f) => `${f}: try/catch (5 occurrences)`), + namingConvention: ["camelCase: 500 occurrences"], + }, + fileCounts: { + asyncAwait: 50, + promises: 10, + callbacks: 5, + tryCatch: 30, + errorCallbacks: 5, + resultTypes: 0, + }, + }, + sampledFiles: manyFiles, + }; + + const formatted = formatCodebaseBrief(brief); + + assert.ok( + formatted.length <= 3000, + `should cap at 3000 chars, got ${formatted.length}`, + ); + if (formatted.length === 3000) { + assert.ok(formatted.endsWith("..."), "should end with ellipsis when truncated"); + } +}); + +test("formatCodebaseBrief: handles minimal brief", async (t) => { + const brief: CodebaseBrief = { + techStack: { + primaryLanguage: undefined, + detectedFiles: [], + packageManager: undefined, + isMonorepo: false, + hasTests: false, + hasCI: false, + }, + moduleStructure: { + topLevelDirs: [], + srcSubdirs: [], + totalFilesSampled: 0, + }, + patterns: { + asyncStyle: "unknown", + errorHandling: "unknown", + namingConvention: "unknown", + evidence: { + asyncStyle: [], + errorHandling: [], + namingConvention: [], + }, + fileCounts: { + asyncAwait: 0, + promises: 0, + callbacks: 0, + tryCatch: 0, + errorCallbacks: 0, + resultTypes: 0, + }, + }, + sampledFiles: [], + }; + + const formatted = formatCodebaseBrief(brief); + + assert.ok(formatted.includes("## Tech Stack"), "should still have sections"); + assert.ok(formatted.includes("**Monorepo:** No"), "should show monorepo status"); + assert.ok(formatted.includes("unknown"), "should show unknown patterns"); +}); + +// ─── Integration: Brief includes PROJECT_FILES markers ────────────────────────── + +test("analyzeCodebase: brief includes detected files from PROJECT_FILES", async (t) => { + const dir = makeTempDir("project-files"); + t.after(() => cleanup(dir)); + + // Create several PROJECT_FILES markers + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + writeFileSync(join(dir, "tsconfig.json"), '{}', "utf-8"); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(dir, ".github", "workflows", "ci.yml"), + "name: CI", + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.ok( + brief.techStack.detectedFiles.includes("package.json"), + "should detect package.json", + ); + assert.ok( + brief.techStack.hasCI, + "should detect CI from .github/workflows", + ); +}); + +test("analyzeCodebase: brief includes sampled file patterns", async (t) => { + const dir = makeTempDir("sampled-patterns"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, "src"), { recursive: true }); + + // Write files with distinct patterns + writeFileSync( + join(dir, "src", "async-heavy.ts"), + ` +async function one() { await fetch('/a'); } +async function two() { await fetch('/b'); } +async function three() { await fetch('/c'); } + `, + "utf-8", + ); + + const brief = await analyzeCodebase(dir); + + assert.ok(brief.sampledFiles.length > 0, "should have sampled files"); + assert.ok( + brief.patterns.evidence.asyncStyle.length > 0, + "should have async style evidence", + ); +}); + +// ─── aggregatePriorContext ────────────────────────────────────────────────────── + +test("aggregatePriorContext: handles missing files gracefully", async (t) => { + const dir = makeTempDir("no-gsd"); + t.after(() => cleanup(dir)); + + // Create .gsd directory but no files + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + const brief = await aggregatePriorContext(dir); + + assert.equal(brief.decisions.totalCount, 0, "should have no decisions"); + assert.equal(brief.requirements.totalCount, 0, "should have no requirements"); + assert.equal(brief.knowledge, "No prior knowledge recorded.", "should indicate no knowledge"); + assert.equal(brief.summaries, "No prior milestone summaries.", "should indicate no summaries"); +}); + +test("aggregatePriorContext: handles completely empty directory", async (t) => { + const dir = makeTempDir("empty-project"); + t.after(() => cleanup(dir)); + + const brief = await aggregatePriorContext(dir); + + assert.equal(brief.decisions.totalCount, 0); + assert.equal(brief.requirements.totalCount, 0); + assert.equal(brief.knowledge, "No prior knowledge recorded."); + assert.equal(brief.summaries, "No prior milestone summaries."); +}); + +test("aggregatePriorContext: parses DECISIONS.md and groups by scope", async (t) => { + const dir = makeTempDir("decisions"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync( + join(dir, ".gsd", "DECISIONS.md"), + `# Decisions Register + +| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By | +|---|------|-------|----------|--------|-----------|------------|---------| +| D001 | M001/S01 | pattern | Async style | async/await | Modern standard | Yes | agent | +| D002 | M001/S02 | architecture | Data layer | SQLite | Simple, embedded | No | human | +| D003 | M001/S03 | pattern | Error handling | try/catch | Consistency | Yes | agent | +`, + "utf-8", + ); + + const brief = await aggregatePriorContext(dir); + + assert.equal(brief.decisions.totalCount, 3, "should parse all decisions"); + assert.equal(brief.decisions.byScope.get("pattern")?.length, 2, "should group pattern scope"); + assert.equal(brief.decisions.byScope.get("architecture")?.length, 1, "should group architecture scope"); + + const patternDecisions = brief.decisions.byScope.get("pattern")!; + assert.equal(patternDecisions[0].id, "D001"); + assert.equal(patternDecisions[0].decision, "Async style"); + assert.equal(patternDecisions[0].choice, "async/await"); +}); + +test("aggregatePriorContext: parses REQUIREMENTS.md and groups by status", async (t) => { + const dir = makeTempDir("requirements"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync( + join(dir, ".gsd", "REQUIREMENTS.md"), + `# Requirements + +## Active + +### R001 — First requirement +- Status: active +- Description: Something active + +### R002 — Second requirement +- Status: active +- Description: Also active + +## Validated + +### R003 — Validated requirement +- Status: validated +- Description: This was validated + +## Deferred + +### R004 — Deferred requirement +- Status: deferred +- Description: Postponed for later +`, + "utf-8", + ); + + const brief = await aggregatePriorContext(dir); + + assert.equal(brief.requirements.totalCount, 4, "should parse all requirements"); + assert.equal(brief.requirements.active.length, 2, "should have 2 active"); + assert.equal(brief.requirements.validated.length, 1, "should have 1 validated"); + assert.equal(brief.requirements.deferred.length, 1, "should have 1 deferred"); + + assert.equal(brief.requirements.active[0].id, "R001"); + assert.equal(brief.requirements.active[0].description, "First requirement"); +}); + +test("aggregatePriorContext: loads KNOWLEDGE.md content", async (t) => { + const dir = makeTempDir("knowledge"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync( + join(dir, ".gsd", "KNOWLEDGE.md"), + `# Knowledge Base + +## Rules + +| # | Scope | Rule | Why | Added | +|---|-------|------|-----|-------| +| K001 | global | Always use TypeScript | Type safety | manual | + +## Patterns + +**Pattern X:** Do this for better Y. +`, + "utf-8", + ); + + const brief = await aggregatePriorContext(dir); + + assert.ok(brief.knowledge.includes("Rules"), "should include knowledge content"); + assert.ok(brief.knowledge.includes("TypeScript"), "should include rule text"); +}); + +test("aggregatePriorContext: truncates oversized content without cutting mid-section", async (t) => { + const dir = makeTempDir("large-knowledge"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + // Create large knowledge file + const largeContent = `# Knowledge Base + +## Section One + +${"Lorem ipsum dolor sit amet. ".repeat(100)} + +## Section Two + +${"More content here. ".repeat(100)} + +## Section Three + +${"Even more content. ".repeat(100)} +`; + + writeFileSync(join(dir, ".gsd", "KNOWLEDGE.md"), largeContent, "utf-8"); + + const brief = await aggregatePriorContext(dir); + + assert.ok(brief.knowledge.length <= 2000, "should truncate to 2K chars"); + assert.ok(brief.knowledge.includes("[truncated]"), "should indicate truncation"); + // Should try to preserve section boundaries + assert.ok( + brief.knowledge.includes("## Section"), + "should keep section headings intact", + ); +}); + +test("aggregatePriorContext: loads milestone summaries", async (t) => { + const dir = makeTempDir("milestones"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(dir, ".gsd", "milestones", "M002"), { recursive: true }); + + writeFileSync( + join(dir, ".gsd", "milestones", "M001", "MILESTONE-SUMMARY.md"), + `# M001 — First Milestone + +**Implemented core functionality and established patterns.** + +## What Happened +Did stuff. +`, + "utf-8", + ); + + writeFileSync( + join(dir, ".gsd", "milestones", "M002", "MILESTONE-SUMMARY.md"), + `# M002 — Second Milestone + +**Extended the system with new features.** + +## What Happened +Did more stuff. +`, + "utf-8", + ); + + const brief = await aggregatePriorContext(dir); + + assert.ok(brief.summaries.includes("M001"), "should include M001 summary"); + assert.ok(brief.summaries.includes("M002"), "should include M002 summary"); + assert.ok( + brief.summaries.includes("core functionality"), + "should extract one-liner from M001", + ); + assert.ok( + brief.summaries.includes("new features"), + "should extract one-liner from M002", + ); +}); + +// ─── formatPriorContextBrief ──────────────────────────────────────────────────── + +test("formatPriorContextBrief: produces markdown with all sections", async (t) => { + const brief: PriorContextBrief = { + decisions: { + byScope: new Map([ + [ + "pattern", + [ + { id: "D001", scope: "pattern", decision: "Async", choice: "await", rationale: "Modern" }, + ], + ], + [ + "architecture", + [ + { id: "D002", scope: "architecture", decision: "DB", choice: "SQLite", rationale: "Simple" }, + ], + ], + ]), + totalCount: 2, + }, + requirements: { + active: [{ id: "R001", description: "Core feature", status: "active" }], + validated: [], + deferred: [], + totalCount: 1, + }, + knowledge: "Some knowledge here.", + summaries: "### M001\nDid things.", + }; + + const formatted = formatPriorContextBrief(brief); + + assert.ok(formatted.includes("## Prior Decisions"), "should have decisions section"); + assert.ok(formatted.includes("## Prior Requirements"), "should have requirements section"); + assert.ok(formatted.includes("## Prior Knowledge"), "should have knowledge section"); + assert.ok(formatted.includes("## Prior Milestone Summaries"), "should have summaries section"); + assert.ok(formatted.includes("D001"), "should include decision ID"); + assert.ok(formatted.includes("R001"), "should include requirement ID"); + assert.ok(formatted.includes("pattern"), "should include scope heading"); +}); + +test("formatPriorContextBrief: handles empty brief", async (t) => { + const brief: PriorContextBrief = { + decisions: { + byScope: new Map(), + totalCount: 0, + }, + requirements: { + active: [], + validated: [], + deferred: [], + totalCount: 0, + }, + knowledge: "No prior knowledge recorded.", + summaries: "No prior milestone summaries.", + }; + + const formatted = formatPriorContextBrief(brief); + + assert.ok(formatted.includes("No prior decisions recorded"), "should indicate no decisions"); + assert.ok(formatted.includes("No prior requirements recorded"), "should indicate no requirements"); + assert.ok(formatted.includes("No prior knowledge recorded"), "should indicate no knowledge"); + assert.ok(formatted.includes("No prior milestone summaries"), "should indicate no summaries"); +}); + +test("formatPriorContextBrief: caps total output at 6K chars", async (t) => { + // Create a brief with lots of content + const manyDecisions: Array<{ + id: string; + scope: string; + decision: string; + choice: string; + rationale: string; + }> = []; + for (let i = 0; i < 100; i++) { + manyDecisions.push({ + id: `D${String(i).padStart(3, "0")}`, + scope: "pattern", + decision: `Decision number ${i} with some extra text for length`, + choice: `Choice ${i} with more text to make it longer`, + rationale: `Rationale ${i}`, + }); + } + + const manyRequirements: Array<{ + id: string; + description: string; + status: "active"; + }> = []; + for (let i = 0; i < 100; i++) { + manyRequirements.push({ + id: `R${String(i).padStart(3, "0")}`, + description: `Requirement ${i} with a long description that takes up space`, + status: "active", + }); + } + + const brief: PriorContextBrief = { + decisions: { + byScope: new Map([["pattern", manyDecisions]]), + totalCount: 100, + }, + requirements: { + active: manyRequirements, + validated: [], + deferred: [], + totalCount: 100, + }, + knowledge: "A ".repeat(1000), + summaries: "B ".repeat(1000), + }; + + const formatted = formatPriorContextBrief(brief); + + assert.ok(formatted.length <= 6000, `should cap at 6000 chars, got ${formatted.length}`); +}); + +// ─── hasSearchApiKey ──────────────────────────────────────────────────────────── + +test("hasSearchApiKey: returns false when no search API keys configured", async (t) => { + // Save original env values + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + // Clear the env vars + delete process.env.TAVILY_API_KEY; + delete process.env.BRAVE_API_KEY; + + t.after(() => { + // Restore original values + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const result = hasSearchApiKey(); + + assert.equal(result.available, false, "should return available: false"); + assert.equal(result.provider, undefined, "should not have provider"); +}); + +test("hasSearchApiKey: returns true when TAVILY_API_KEY is set", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + process.env.TAVILY_API_KEY = "test-tavily-key"; + delete process.env.BRAVE_API_KEY; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const result = hasSearchApiKey(); + + assert.equal(result.available, true, "should return available: true"); + assert.equal(result.provider, "tavily", "should identify tavily provider"); +}); + +test("hasSearchApiKey: returns true when BRAVE_API_KEY is set", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + delete process.env.TAVILY_API_KEY; + process.env.BRAVE_API_KEY = "test-brave-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const result = hasSearchApiKey(); + + assert.equal(result.available, true, "should return available: true"); + assert.equal(result.provider, "brave", "should identify brave provider"); +}); + +test("hasSearchApiKey: prefers tavily over brave when both set", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + process.env.TAVILY_API_KEY = "test-tavily-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const result = hasSearchApiKey(); + + assert.equal(result.available, true, "should return available: true"); + assert.equal(result.provider, "tavily", "should prefer tavily (first in list)"); +}); + +// ─── researchEcosystem ────────────────────────────────────────────────────────── + +test("researchEcosystem: returns graceful skip when no API keys", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + delete process.env.TAVILY_API_KEY; + delete process.env.BRAVE_API_KEY; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const dir = makeTempDir("ecosystem-no-key"); + t.after(() => cleanup(dir)); + + const brief = await researchEcosystem(["Next.js", "TypeScript"], dir); + + assert.equal(brief.available, false, "should indicate research not available"); + assert.ok(brief.skippedReason, "should have skipped reason"); + assert.ok( + brief.skippedReason!.includes("No search API key"), + "should explain missing API key", + ); + assert.deepEqual(brief.queries, [], "should have empty queries"); + assert.deepEqual(brief.findings, [], "should have empty findings"); +}); + +test("researchEcosystem: returns valid structure when API key is set", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + const originalBrave = process.env.BRAVE_API_KEY; + + process.env.TAVILY_API_KEY = "test-key"; + delete process.env.BRAVE_API_KEY; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + if (originalBrave !== undefined) process.env.BRAVE_API_KEY = originalBrave; + else delete process.env.BRAVE_API_KEY; + }); + + const dir = makeTempDir("ecosystem-with-key"); + t.after(() => cleanup(dir)); + + const brief = await researchEcosystem(["Next.js", "TypeScript"], dir); + + assert.equal(brief.available, true, "should indicate research available"); + assert.equal(brief.skippedReason, undefined, "should not have skipped reason"); + assert.ok(brief.queries.length > 0, "should have queries"); + assert.ok(Array.isArray(brief.findings), "should have findings array"); + assert.equal(brief.provider, "tavily", "should identify provider"); +}); + +test("researchEcosystem: builds appropriate queries for tech stack", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + + process.env.TAVILY_API_KEY = "test-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + }); + + const dir = makeTempDir("ecosystem-queries"); + t.after(() => cleanup(dir)); + + const brief = await researchEcosystem(["Next.js", "React"], dir); + + assert.ok(brief.queries.length > 0, "should have queries"); + assert.ok(brief.queries.length <= 3, "should cap at 3 queries"); + // Should include tech names in queries + const allQueriesText = brief.queries.join(" "); + assert.ok( + allQueriesText.includes("Next.js") || allQueriesText.includes("React"), + "should include tech names in queries", + ); +}); + +test("researchEcosystem: handles empty tech stack gracefully", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + + process.env.TAVILY_API_KEY = "test-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + }); + + const dir = makeTempDir("ecosystem-empty"); + t.after(() => cleanup(dir)); + + const brief = await researchEcosystem([], dir); + + // Should gracefully handle empty tech stack + assert.equal(brief.available, false, "should indicate research skipped"); + assert.ok(brief.skippedReason, "should have skipped reason"); + assert.ok( + brief.skippedReason!.includes("No technology stack"), + "should explain no tech stack", + ); +}); + +test("researchEcosystem: does not throw on timeout", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + + process.env.TAVILY_API_KEY = "test-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + }); + + const dir = makeTempDir("ecosystem-timeout"); + t.after(() => cleanup(dir)); + + // This should complete quickly and not throw + const startTime = Date.now(); + const brief = await researchEcosystem(["Node.js"], dir); + const elapsed = Date.now() - startTime; + + assert.ok(brief, "should return a brief"); + assert.ok(elapsed < 1000, "should complete quickly (stub implementation)"); +}); + +// ─── formatEcosystemBrief ─────────────────────────────────────────────────────── + +test("formatEcosystemBrief: formats skipped research correctly", async (t) => { + const brief: EcosystemBrief = { + available: false, + queries: [], + findings: [], + skippedReason: "No search API key configured.", + }; + + const formatted = formatEcosystemBrief(brief); + + assert.ok(formatted.includes("## Ecosystem Research"), "should have section header"); + assert.ok(formatted.includes("⚠️"), "should have warning indicator"); + assert.ok(formatted.includes("No search API key"), "should include skip reason"); + assert.ok(formatted.includes("FYI"), "should frame as informational"); +}); + +test("formatEcosystemBrief: formats available research with no findings", async (t) => { + const brief: EcosystemBrief = { + available: true, + queries: ["Next.js best practices 2026"], + findings: [], + provider: "tavily", + }; + + const formatted = formatEcosystemBrief(brief); + + assert.ok(formatted.includes("## Ecosystem Research"), "should have section header"); + assert.ok(formatted.includes("Queries performed"), "should list queries"); + assert.ok(formatted.includes("Next.js best practices"), "should include query text"); + assert.ok(formatted.includes("No relevant findings"), "should indicate no findings"); + assert.ok(formatted.includes("FYI"), "should frame as informational"); +}); + +test("formatEcosystemBrief: formats findings correctly", async (t) => { + const brief: EcosystemBrief = { + available: true, + queries: ["React best practices"], + findings: [ + { + query: "React best practices", + title: "Using React Server Components", + snippet: "Server Components allow you to render on the server...", + url: "https://example.com/react-rsc", + }, + { + query: "React best practices", + title: "React 19 Features", + snippet: "New features in React 19 include...", + url: "https://example.com/react-19", + }, + ], + provider: "tavily", + }; + + const formatted = formatEcosystemBrief(brief); + + assert.ok(formatted.includes("## Ecosystem Research"), "should have section header"); + assert.ok(formatted.includes("Key findings"), "should have findings header"); + assert.ok(formatted.includes("Using React Server Components"), "should include finding title"); + assert.ok(formatted.includes("Server Components allow"), "should include snippet"); + assert.ok(formatted.includes("example.com"), "should include source URL"); + assert.ok(formatted.includes("FYI"), "should frame as informational"); +}); + +test("formatEcosystemBrief: caps output at 4000 chars", async (t) => { + // Create a brief with many findings to exceed the limit + const manyFindings: EcosystemFinding[] = []; + for (let i = 0; i < 50; i++) { + manyFindings.push({ + query: "Test query", + title: `Finding ${i} with a long title that takes up space`, + snippet: `This is a detailed snippet for finding ${i} that contains lots of text to simulate real search results. `.repeat( + 5, + ), + url: `https://example.com/finding-${i}`, + }); + } + + const brief: EcosystemBrief = { + available: true, + queries: ["Test query"], + findings: manyFindings, + provider: "tavily", + }; + + const formatted = formatEcosystemBrief(brief); + + assert.ok( + formatted.length <= 4000, + `should cap at 4000 chars, got ${formatted.length}`, + ); +}); + +// ─── runPreparation (Orchestrator) ────────────────────────────────────────────── + +/** + * Mock UI context that captures notifications for testing. + */ +function createMockUI(): PreparationUIContext & { notifications: Array<{ message: string; type?: string }> } { + const notifications: Array<{ message: string; type?: string }> = []; + return { + notifications, + notify(message: string, type?: "info" | "warning" | "error" | "success") { + notifications.push({ message, type }); + }, + }; +} + +test("runPreparation: returns complete result with all briefs populated", async (t) => { + const dir = makeTempDir("runprep-full"); + t.after(() => cleanup(dir)); + + // Set up a minimal project + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test-project"}', "utf-8"); + writeFileSync(join(dir, "src", "index.ts"), 'export const x = 1;', "utf-8"); + + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, // Skip web research to avoid API key requirement + discuss_depth: "standard", + }; + + const result = await runPreparation(dir, ui, prefs); + + // Check result structure + assert.equal(result.enabled, true, "should be enabled"); + assert.ok(result.codebase, "should have codebase"); + assert.ok(result.priorContext, "should have priorContext"); + assert.ok(result.ecosystem, "should have ecosystem"); + assert.ok(typeof result.codebaseBrief === "string", "should have codebaseBrief"); + assert.ok(typeof result.priorContextBrief === "string", "should have priorContextBrief"); + assert.ok(typeof result.ecosystemBrief === "string", "should have ecosystemBrief"); + assert.ok(result.durationMs > 0, "should have positive duration"); + assert.equal(result.ecosystemResearchPerformed, false, "should not have performed ecosystem research"); + + // Check TUI progress notifications + assert.ok(ui.notifications.length > 0, "should have notifications"); + assert.ok( + ui.notifications.some((n) => n.message.includes("Analyzing codebase")), + "should show codebase analysis start", + ); + assert.ok( + ui.notifications.some((n) => n.message.includes("✓ Analyzed codebase")), + "should show codebase analysis complete", + ); + assert.ok( + ui.notifications.some((n) => n.message.includes("Reviewing prior context")), + "should show prior context start", + ); + assert.ok( + ui.notifications.some((n) => n.message.includes("✓ Reviewed prior context")), + "should show prior context complete", + ); +}); + +test("runPreparation: returns early when discuss_preparation is false", async (t) => { + const dir = makeTempDir("runprep-disabled"); + t.after(() => cleanup(dir)); + + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: false, + }; + + const result = await runPreparation(dir, ui, prefs); + + assert.equal(result.enabled, false, "should indicate preparation disabled"); + assert.equal(result.codebaseBrief, "", "should have empty codebase brief"); + assert.equal(result.priorContextBrief, "", "should have empty prior context brief"); + assert.equal(result.ecosystemBrief, "", "should have empty ecosystem brief"); + assert.equal(ui.notifications.length, 0, "should not show any notifications"); + assert.ok(result.durationMs >= 0, "should have non-negative duration"); +}); + +test("runPreparation: skips ecosystem research when discuss_web_research is false", async (t) => { + const dir = makeTempDir("runprep-no-web"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + const result = await runPreparation(dir, ui, prefs); + + assert.equal(result.enabled, true); + assert.equal(result.ecosystemResearchPerformed, false, "should not perform ecosystem research"); + assert.equal(result.ecosystem.available, false); + assert.ok( + result.ecosystem.skippedReason?.includes("Web research disabled"), + "should indicate disabled in preferences", + ); + + // Should NOT have ecosystem research notifications + assert.ok( + !ui.notifications.some((n) => n.message.includes("Researching ecosystem")), + "should not show ecosystem research notification", + ); +}); + +test("runPreparation: performs ecosystem research when enabled with API key", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + process.env.TAVILY_API_KEY = "test-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + }); + + const dir = makeTempDir("runprep-with-web"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + + const ui = createMockUI(); + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: true, + }; + + const result = await runPreparation(dir, ui, prefs); + + assert.equal(result.enabled, true); + assert.equal(result.ecosystemResearchPerformed, true, "should perform ecosystem research"); + assert.equal(result.ecosystem.available, true, "ecosystem should be available"); + + // Should have ecosystem research notifications + assert.ok( + ui.notifications.some((n) => n.message.includes("Researching ecosystem")), + "should show ecosystem research start", + ); + assert.ok( + ui.notifications.some((n) => n.message.includes("✓ Researched ecosystem")), + "should show ecosystem research complete", + ); +}); + +test("runPreparation: works without UI context (silent mode)", async (t) => { + const dir = makeTempDir("runprep-silent"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + }; + + // Pass null for UI to test silent mode + const result = await runPreparation(dir, null, prefs); + + assert.equal(result.enabled, true, "should work without UI"); + assert.ok(result.codebase, "should have codebase"); + assert.ok(result.priorContext, "should have priorContext"); + assert.ok(result.durationMs > 0, "should have duration"); +}); + +test("runPreparation: completes within 60s requirement (R112)", async (t) => { + const dir = makeTempDir("runprep-timing"); + t.after(() => cleanup(dir)); + + // Create a project with some content to analyze + mkdirSync(join(dir, "src"), { recursive: true }); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + writeFileSync(join(dir, "tsconfig.json"), '{}', "utf-8"); + + for (let i = 0; i < 10; i++) { + writeFileSync( + join(dir, "src", `file${i}.ts`), + `export async function fn${i}() { await Promise.resolve(); }\n`.repeat(50), + "utf-8", + ); + } + + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: false, + discuss_depth: "standard", + }; + + const startTime = performance.now(); + const result = await runPreparation(dir, null, prefs); + const elapsed = performance.now() - startTime; + + assert.ok(result.durationMs < 60000, `should complete within 60s, took ${result.durationMs}ms`); + assert.ok(elapsed < 60000, `elapsed time should be under 60s, was ${elapsed}ms`); +}); + +test("runPreparation: does not throw on any input", async (t) => { + const dir = makeTempDir("runprep-robust"); + t.after(() => cleanup(dir)); + + // Test with completely empty directory + const prefs: PreparationPreferences = {}; + + let result: PreparationResult | undefined; + let error: unknown; + + try { + result = await runPreparation(dir, null, prefs); + } catch (e) { + error = e; + } + + assert.equal(error, undefined, "should not throw"); + assert.ok(result, "should return result"); + assert.equal(result!.enabled, true, "should be enabled by default"); +}); + +test("runPreparation: detects framework from config files", async (t) => { + const originalTavily = process.env.TAVILY_API_KEY; + process.env.TAVILY_API_KEY = "test-key"; + + t.after(() => { + if (originalTavily !== undefined) process.env.TAVILY_API_KEY = originalTavily; + else delete process.env.TAVILY_API_KEY; + }); + + const dir = makeTempDir("runprep-framework"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync(join(dir, "package.json"), '{"name": "test"}', "utf-8"); + writeFileSync(join(dir, "next.config.mjs"), 'export default {};', "utf-8"); + + const prefs: PreparationPreferences = { + discuss_preparation: true, + discuss_web_research: true, + }; + + const result = await runPreparation(dir, null, prefs); + + // Should detect Next.js and include it in ecosystem queries + assert.ok(result.ecosystem.queries.length > 0, "should have queries"); + const queriesText = result.ecosystem.queries.join(" "); + assert.ok( + queriesText.includes("Next.js"), + "should include Next.js in queries", + ); +}); + +test("runPreparation: default preferences enable preparation and web research", async (t) => { + const dir = makeTempDir("runprep-defaults"); + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd"), { recursive: true }); + + const ui = createMockUI(); + const prefs: PreparationPreferences = {}; // All defaults + + const result = await runPreparation(dir, ui, prefs); + + // With defaults, preparation should be enabled + assert.equal(result.enabled, true, "should be enabled by default"); + // Notifications should be shown + assert.ok(ui.notifications.length > 0, "should show notifications"); +}); diff --git a/src/resources/extensions/gsd/tests/prompt-builder.test.ts b/src/resources/extensions/gsd/tests/prompt-builder.test.ts new file mode 100644 index 000000000..e44d0feef --- /dev/null +++ b/src/resources/extensions/gsd/tests/prompt-builder.test.ts @@ -0,0 +1,655 @@ +/** + * Prompt Builder Tests — Comprehensive tests for S02 components. + * + * Tests cover: + * 1. Template validation (context-enhanced.md, discuss-prepared.md) + * 2. Prompt loading and variable substitution + * 3. Enhanced context validation (R109) + * 4. Integration tests for format functions and prompt injection + */ + +import test, { describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Template Paths ───────────────────────────────────────────────────────────── + +const templatesDir = join(process.cwd(), "src/resources/extensions/gsd/templates"); +const promptsDir = join(process.cwd(), "src/resources/extensions/gsd/prompts"); + +const contextEnhancedPath = join(templatesDir, "context-enhanced.md"); +const contextPath = join(templatesDir, "context.md"); +const discussPreparedPath = join(promptsDir, "discuss-prepared.md"); + +// ─── Template Tests ───────────────────────────────────────────────────────────── + +describe("Template: context-enhanced.md", () => { + test("file exists", () => { + assert.ok(existsSync(contextEnhancedPath), "context-enhanced.md should exist"); + }); + + test("contains all original context.md sections", () => { + const contextEnhanced = readFileSync(contextEnhancedPath, "utf-8"); + const originalContext = readFileSync(contextPath, "utf-8"); + + // Extract section headers from original context.md + const originalSections = originalContext.match(/^## .+$/gm) ?? []; + + // Each original section should be present in context-enhanced.md + for (const section of originalSections) { + assert.ok( + contextEnhanced.includes(section), + `context-enhanced.md should contain original section: ${section}`, + ); + } + }); + + test("contains new structured sections for prepared discussions", () => { + const contextEnhanced = readFileSync(contextEnhancedPath, "utf-8"); + + // New sections required by R108 + const newSections = [ + "## Codebase Brief", + "## Architectural Decisions", + "## Interface Contracts", + "## Error Handling Strategy", + "## Testing Requirements", + "## Acceptance Criteria", + "## Ecosystem Notes", + ]; + + for (const section of newSections) { + assert.ok( + contextEnhanced.includes(section), + `context-enhanced.md should contain new section: ${section}`, + ); + } + }); + + test("Codebase Brief has sub-sections", () => { + const contextEnhanced = readFileSync(contextEnhancedPath, "utf-8"); + + assert.ok( + contextEnhanced.includes("### Technology Stack"), + "Codebase Brief should have Technology Stack sub-section", + ); + assert.ok( + contextEnhanced.includes("### Key Modules"), + "Codebase Brief should have Key Modules sub-section", + ); + assert.ok( + contextEnhanced.includes("### Patterns in Use"), + "Codebase Brief should have Patterns in Use sub-section", + ); + }); + + test("Architectural Decisions has structured format guidance", () => { + const contextEnhanced = readFileSync(contextEnhancedPath, "utf-8"); + + // Check for decision structure markers + assert.ok( + contextEnhanced.includes("**Decision:**"), + "Architectural Decisions should have Decision marker", + ); + assert.ok( + contextEnhanced.includes("**Rationale:**"), + "Architectural Decisions should have Rationale marker", + ); + assert.ok( + contextEnhanced.includes("**Evidence:**"), + "Architectural Decisions should have Evidence marker", + ); + assert.ok( + contextEnhanced.includes("**Alternatives Considered:**"), + "Architectural Decisions should have Alternatives Considered marker", + ); + }); +}); + +describe("Template: discuss-prepared.md", () => { + test("file exists", () => { + assert.ok(existsSync(discussPreparedPath), "discuss-prepared.md should exist"); + }); + + test("contains all three brief placeholders", () => { + const discussPrepared = readFileSync(discussPreparedPath, "utf-8"); + + assert.ok( + discussPrepared.includes("{{codebaseBrief}}"), + "discuss-prepared.md should contain {{codebaseBrief}} placeholder", + ); + assert.ok( + discussPrepared.includes("{{priorContextBrief}}"), + "discuss-prepared.md should contain {{priorContextBrief}} placeholder", + ); + assert.ok( + discussPrepared.includes("{{ecosystemBrief}}"), + "discuss-prepared.md should contain {{ecosystemBrief}} placeholder", + ); + }); + + test("contains 4-layer protocol markers", () => { + const discussPrepared = readFileSync(discussPreparedPath, "utf-8"); + + // Check for all four layer headings + assert.ok( + discussPrepared.includes("## Layer 1 — Scope"), + "discuss-prepared.md should contain Layer 1 (Scope)", + ); + assert.ok( + discussPrepared.includes("## Layer 2 — Architecture"), + "discuss-prepared.md should contain Layer 2 (Architecture)", + ); + assert.ok( + discussPrepared.includes("## Layer 3 — Error States"), + "discuss-prepared.md should contain Layer 3 (Error States)", + ); + assert.ok( + discussPrepared.includes("## Layer 4 — Quality Bar"), + "discuss-prepared.md should contain Layer 4 (Quality Bar)", + ); + }); + + test("contains gate question IDs for all layers", () => { + const discussPrepared = readFileSync(discussPreparedPath, "utf-8"); + + assert.ok( + discussPrepared.includes("layer1_scope_gate"), + "discuss-prepared.md should contain layer1_scope_gate question ID", + ); + assert.ok( + discussPrepared.includes("layer2_architecture_gate"), + "discuss-prepared.md should contain layer2_architecture_gate question ID", + ); + assert.ok( + discussPrepared.includes("layer3_error_gate"), + "discuss-prepared.md should contain layer3_error_gate question ID", + ); + assert.ok( + discussPrepared.includes("layer4_quality_gate"), + "discuss-prepared.md should contain layer4_quality_gate question ID", + ); + }); + + test("contains context-enhanced template guidance", () => { + const discussPrepared = readFileSync(discussPreparedPath, "utf-8"); + + assert.ok( + discussPrepared.includes("context-enhanced"), + "discuss-prepared.md should reference context-enhanced template", + ); + }); +}); + +// ─── Prompt Loading Tests ─────────────────────────────────────────────────────── + +describe("Prompt Loading", () => { + // Dynamic import to work with the module's warm cache + test("loadPrompt substitutes all variables correctly", async () => { + const { loadPrompt } = await import("../prompt-loader.ts"); + + const result = loadPrompt("discuss-prepared", { + preamble: "Test preamble", + codebaseBrief: "Test codebase brief content", + priorContextBrief: "Test prior context brief content", + ecosystemBrief: "Test ecosystem brief content", + milestoneId: "M001", + contextPath: ".gsd/milestones/M001/M001-CONTEXT.md", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedTemplates: "Test templates", + commitInstruction: "Test commit instruction", + multiMilestoneCommitInstruction: "Test multi-milestone commit", + }); + + assert.ok(result.includes("Test codebase brief content"), "codebaseBrief should be substituted"); + assert.ok(result.includes("Test prior context brief content"), "priorContextBrief should be substituted"); + assert.ok(result.includes("Test ecosystem brief content"), "ecosystemBrief should be substituted"); + assert.ok(!result.includes("{{codebaseBrief}}"), "placeholder should not remain"); + }); + + test("loadPrompt throws GSDError for missing variables", async () => { + const { loadPrompt } = await import("../prompt-loader.ts"); + const { GSDError, GSD_PARSE_ERROR } = await import("../errors.ts"); + + assert.throws( + () => loadPrompt("discuss-prepared", {}), // Missing required variables + (err: unknown) => { + assert.ok(err instanceof GSDError, "should throw GSDError"); + assert.equal((err as InstanceType).code, GSD_PARSE_ERROR, "should have GSD_PARSE_ERROR code"); + return true; + }, + ); + }); + + test("brief content with {{...}} patterns does not cause false variable errors", async () => { + const { loadPrompt } = await import("../prompt-loader.ts"); + + // Content that contains template-like patterns but should not be treated as variables + const briefWithPatterns = ` +## Tech Stack +- Framework: Uses \`{{slot}}\` placeholder syntax in templates +- Pattern: The codebase has \`{{variableName}}\` markers +`; + + // This should NOT throw, because {{slot}} and {{variableName}} are inside + // the brief value, not undeclared placeholders in the template itself. + const result = loadPrompt("discuss-prepared", { + preamble: "Test", + codebaseBrief: briefWithPatterns, + priorContextBrief: "Test brief", + ecosystemBrief: "Test brief", + milestoneId: "M001", + contextPath: ".gsd/milestones/M001/M001-CONTEXT.md", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedTemplates: "Test templates", + commitInstruction: "Test commit instruction", + multiMilestoneCommitInstruction: "Test multi-milestone commit", + }); + + assert.ok(result.includes("{{slot}}"), "template-like patterns in content should be preserved"); + assert.ok(result.includes("{{variableName}}"), "template-like patterns in content should be preserved"); + }); +}); + +// ─── Validation Tests ─────────────────────────────────────────────────────────── + +describe("Enhanced Context Validation", () => { + test("valid enhanced context passes validation", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const validContent = ` +# M001: Test Milestone + +## Why This Milestone + +This is why we need this milestone. + +## Architectural Decisions + +### Decision 1 + +**Decision:** Use TypeScript +**Rationale:** Type safety + +## Acceptance Criteria + +- Criterion 1 +- Criterion 2 +`; + + const result = validateEnhancedContext(validContent); + assert.equal(result.valid, true, "valid content should pass validation"); + assert.equal(result.missing.length, 0, "no missing sections"); + }); + + test("missing scope section fails", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const contentMissingScope = ` +# M001: Test Milestone + +## Architectural Decisions + +### Decision 1 + +**Decision:** Use TypeScript + +## Acceptance Criteria + +- Criterion 1 +`; + + const result = validateEnhancedContext(contentMissingScope); + assert.equal(result.valid, false, "should fail validation"); + assert.ok( + result.missing.some((m) => m.includes("Scope") || m.includes("Why This Milestone")), + "should report missing scope section", + ); + }); + + test("missing architectural decisions section fails", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const contentMissingDecisions = ` +# M001: Test Milestone + +## Why This Milestone + +This is why we need this milestone. + +## Acceptance Criteria + +- Criterion 1 +`; + + const result = validateEnhancedContext(contentMissingDecisions); + assert.equal(result.valid, false, "should fail validation"); + assert.ok( + result.missing.includes("Architectural Decisions"), + "should report missing architectural decisions section", + ); + }); + + test("missing acceptance criteria section fails", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const contentMissingCriteria = ` +# M001: Test Milestone + +## Why This Milestone + +This is why we need this milestone. + +## Architectural Decisions + +### Decision 1 + +**Decision:** Use TypeScript +`; + + const result = validateEnhancedContext(contentMissingCriteria); + assert.equal(result.valid, false, "should fail validation"); + assert.ok( + result.missing.includes("Acceptance Criteria"), + "should report missing acceptance criteria section", + ); + }); + + test("empty architectural decisions section (no entries) fails", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const contentEmptyDecisions = ` +# M001: Test Milestone + +## Why This Milestone + +This is why we need this milestone. + +## Architectural Decisions + +No decisions yet. + +## Acceptance Criteria + +- Criterion 1 +`; + + const result = validateEnhancedContext(contentEmptyDecisions); + assert.equal(result.valid, false, "should fail validation"); + assert.ok( + result.missing.some((m) => m.includes("decision entry")), + "should report missing decision entry", + ); + }); + + test("alternative scope headers are accepted", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + // Test with ## Scope + const withScope = ` +## Scope + +### In Scope +- Item 1 + +## Architectural Decisions + +### Decision 1 +**Decision:** Test + +## Acceptance Criteria + +- Criterion 1 +`; + assert.equal(validateEnhancedContext(withScope).valid, true, "## Scope should be accepted"); + + // Test with ## Milestone Scope + const withMilestoneScope = ` +## Milestone Scope + +This is the scope. + +## Architectural Decisions + +### Decision 1 +**Decision:** Test + +## Acceptance Criteria + +- Criterion 1 +`; + assert.equal( + validateEnhancedContext(withMilestoneScope).valid, + true, + "## Milestone Scope should be accepted", + ); + }); + + test("alternative acceptance criteria headers are accepted", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const withFinalIntegrated = ` +## Why This Milestone + +Test + +## Architectural Decisions + +### Decision 1 +**Decision:** Test + +## Final Integrated Acceptance + +- Criterion 1 +`; + assert.equal( + validateEnhancedContext(withFinalIntegrated).valid, + true, + "## Final Integrated Acceptance should be accepted", + ); + }); + + test("inline decision format is accepted", async () => { + const { validateEnhancedContext } = await import("../prompt-validation.ts"); + + const withInlineDecision = ` +## Why This Milestone + +Test + +## Architectural Decisions + +**Decision:** Use React for the frontend + +## Acceptance Criteria + +- Criterion 1 +`; + assert.equal( + validateEnhancedContext(withInlineDecision).valid, + true, + "**Decision marker format should be accepted", + ); + }); +}); + +// ─── Integration Tests ────────────────────────────────────────────────────────── + +describe("Integration: Format Functions", () => { + test("formatCodebaseBrief produces non-empty output", async () => { + const { formatCodebaseBrief } = await import("../preparation.ts"); + + const brief = { + techStack: { + primaryLanguage: "TypeScript", + detectedFiles: ["package.json", "tsconfig.json"], + packageManager: "npm", + isMonorepo: false, + hasTests: true, + hasCI: true, + }, + moduleStructure: { + topLevelDirs: ["src", "tests"], + srcSubdirs: ["components", "utils"], + totalFilesSampled: 5, + }, + patterns: { + asyncStyle: "async/await" as const, + errorHandling: "try/catch" as const, + namingConvention: "camelCase" as const, + evidence: { + asyncStyle: ["src/foo.ts: async/await (5 occurrences)"], + errorHandling: ["src/bar.ts: try/catch (3 occurrences)"], + namingConvention: ["camelCase: 50 occurrences"], + }, + fileCounts: { + asyncAwait: 3, + promises: 0, + callbacks: 0, + tryCatch: 2, + errorCallbacks: 0, + resultTypes: 0, + }, + }, + sampledFiles: ["src/index.ts", "src/utils.ts"], + }; + + const formatted = formatCodebaseBrief(brief); + assert.ok(formatted.length > 0, "formatted brief should not be empty"); + assert.ok(formatted.includes("TypeScript"), "should include primary language"); + assert.ok(formatted.includes("async/await"), "should include async style"); + }); + + test("formatPriorContextBrief produces non-empty output", async () => { + const { formatPriorContextBrief } = await import("../preparation.ts"); + + const brief = { + decisions: { + byScope: new Map([ + ["architecture", [{ id: "D001", scope: "architecture", decision: "Use SQLite", choice: "SQLite", rationale: "Simplicity" }]], + ]), + totalCount: 1, + }, + requirements: { + active: [{ id: "R001", description: "Test requirement", status: "active" as const }], + validated: [], + deferred: [], + totalCount: 1, + }, + knowledge: "Some knowledge entry", + summaries: "M001 completed X and Y", + }; + + const formatted = formatPriorContextBrief(brief); + assert.ok(formatted.length > 0, "formatted brief should not be empty"); + assert.ok(formatted.includes("Prior Decisions"), "should include decisions section"); + assert.ok(formatted.includes("D001"), "should include decision ID"); + }); + + test("formatEcosystemBrief produces non-empty output", async () => { + const { formatEcosystemBrief } = await import("../preparation.ts"); + + const briefWithFindings = { + available: true, + queries: ["Next.js best practices 2024"], + findings: [ + { + query: "Next.js best practices 2024", + title: "Server Components Guide", + url: "https://example.com/guide", + snippet: "Use Server Components for data fetching", + }, + ], + provider: "tavily", + }; + + const formatted = formatEcosystemBrief(briefWithFindings); + assert.ok(formatted.length > 0, "formatted brief should not be empty"); + assert.ok(formatted.includes("Ecosystem Research"), "should include research heading"); + assert.ok(formatted.includes("Next.js best practices"), "should include query"); + }); + + test("formatEcosystemBrief handles unavailable state", async () => { + const { formatEcosystemBrief } = await import("../preparation.ts"); + + const briefUnavailable = { + available: false, + queries: [], + findings: [], + skippedReason: "No API key configured", + }; + + const formatted = formatEcosystemBrief(briefUnavailable); + assert.ok(formatted.includes("No API key configured"), "should include skip reason"); + }); + + test("formatted briefs can be injected into prompt without errors", async () => { + const { loadPrompt } = await import("../prompt-loader.ts"); + const { formatCodebaseBrief, formatPriorContextBrief, formatEcosystemBrief } = await import("../preparation.ts"); + + // Create realistic briefs + const codebaseBrief = formatCodebaseBrief({ + techStack: { + primaryLanguage: "TypeScript", + detectedFiles: ["package.json"], + packageManager: "npm", + isMonorepo: false, + hasTests: true, + hasCI: false, + }, + moduleStructure: { + topLevelDirs: ["src"], + srcSubdirs: [], + totalFilesSampled: 1, + }, + patterns: { + asyncStyle: "async/await" as const, + errorHandling: "try/catch" as const, + namingConvention: "camelCase" as const, + evidence: { asyncStyle: [], errorHandling: [], namingConvention: [] }, + fileCounts: { + asyncAwait: 0, + promises: 0, + callbacks: 0, + tryCatch: 0, + errorCallbacks: 0, + resultTypes: 0, + }, + }, + sampledFiles: [], + }); + + const priorContextBrief = formatPriorContextBrief({ + decisions: { byScope: new Map(), totalCount: 0 }, + requirements: { active: [], validated: [], deferred: [], totalCount: 0 }, + knowledge: "No prior knowledge recorded.", + summaries: "No prior milestone summaries.", + }); + + const ecosystemBrief = formatEcosystemBrief({ + available: false, + queries: [], + findings: [], + skippedReason: "Preparation disabled", + }); + + // Should not throw when injecting formatted briefs + const result = loadPrompt("discuss-prepared", { + preamble: "Test preamble", + codebaseBrief, + priorContextBrief, + ecosystemBrief, + milestoneId: "M001", + contextPath: ".gsd/milestones/M001/M001-CONTEXT.md", + roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", + inlinedTemplates: "Test templates", + commitInstruction: "Do not commit", + multiMilestoneCommitInstruction: "Do not commit", + }); + + assert.ok(result.includes("TypeScript"), "codebase brief should be present"); + assert.ok(result.includes("Prior Decisions"), "prior context brief should be present"); + assert.ok(result.includes("Preparation disabled"), "ecosystem brief should be present"); + }); +}); diff --git a/src/resources/extensions/gsd/tests/smart-entry-complete.test.ts b/src/resources/extensions/gsd/tests/smart-entry-complete.test.ts index 6abb0e8e6..f874df272 100644 --- a/src/resources/extensions/gsd/tests/smart-entry-complete.test.ts +++ b/src/resources/extensions/gsd/tests/smart-entry-complete.test.ts @@ -49,5 +49,5 @@ test("guided-flow complete branch offers a chooser for next milestone or status" assert.match(branchChunk, /showNextAction\(/, "complete branch should present a chooser"); assert.match(branchChunk, /findMilestoneIds\(basePath\)/, "complete branch should compute the next milestone id"); assert.match(branchChunk, /nextMilestoneId(?:Reserved)?\(milestoneIds, uniqueMilestoneIds\)/, "complete branch should derive the next milestone id"); - assert.match(branchChunk, /dispatchWorkflow\(pi, buildDiscussPrompt\(/, "complete branch should dispatch the discuss prompt"); + assert.match(branchChunk, /dispatchWorkflow\(pi, await prepareAndBuildDiscussPrompt\(/, "complete branch should dispatch the prepared discuss prompt"); });