diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts index 305f09256..0072d806a 100644 --- a/src/resources/extensions/gsd/commands-codebase.ts +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -14,14 +14,21 @@ import { getCodebaseMapStats, readCodebaseMap, } from "./codebase-generator.js"; +import { loadEffectiveGSDPreferences } from "./preferences.js"; +import type { CodebaseMapOptions } from "./codebase-generator.js"; const USAGE = "Usage: /gsd codebase [generate|update|stats]\n\n" + - " generate [--max-files N] — Generate or regenerate CODEBASE.md\n" + - " update — Incremental update (preserves descriptions)\n" + - " stats — Show file count, coverage, and generation time\n" + - " help — Show this help\n\n" + - "With no subcommand, shows stats if a map exists or help if not."; + " generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" + + " update [--max-files N] [--collapse-threshold N] — Incremental update (preserves descriptions)\n" + + " stats — Show file count, coverage, and generation time\n" + + " help — Show this help\n\n" + + "With no subcommand, shows stats if a map exists or help if not.\n\n" + + "Configure defaults via preferences.md:\n" + + " codebase:\n" + + " exclude_patterns: [\"docs/\", \"fixtures/\"]\n" + + " max_files: 1000\n" + + " collapse_threshold: 15"; export async function handleCodebase( args: string, @@ -34,15 +41,15 @@ export async function handleCodebase( switch (sub) { case "generate": { - const maxFiles = parseMaxFiles(args, ctx); - if (maxFiles === false) return; // validation failed, message already shown + const options = resolveCodebaseOptions(args, ctx); + if (options === false) return; // validation failed, message already shown const existing = readCodebaseMap(basePath); const existingDescriptions = existing ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) : undefined; - const result = generateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }, existingDescriptions); + const result = generateCodebaseMap(basePath, options, existingDescriptions); if (result.fileCount === 0) { ctx.ui.notify( @@ -73,10 +80,10 @@ export async function handleCodebase( return; } - const maxFiles = parseMaxFiles(args, ctx); - if (maxFiles === false) return; + const options = resolveCodebaseOptions(args, ctx); + if (options === false) return; - const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }); + const result = updateCodebaseMap(basePath, options); writeCodebaseMap(basePath, result.content); ctx.ui.notify( @@ -141,19 +148,44 @@ function showStats(basePath: string, ctx: ExtensionCommandContext): void { } /** - * Parse and validate --max-files flag. - * Returns the parsed number, undefined if flag not present, or false if invalid. + * Resolve codebase map options by merging preferences with CLI flags. + * CLI flags override preferences; preferences override built-in defaults. + * Returns false if validation failed (error already shown to user). */ -function parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false { - const maxFilesStr = extractFlag(args, "--max-files"); - if (!maxFilesStr) return undefined; +function resolveCodebaseOptions(args: string, ctx: ExtensionCommandContext): CodebaseMapOptions | false { + // Load preferences defaults + const prefs = loadEffectiveGSDPreferences()?.preferences?.codebase; - const maxFiles = parseInt(maxFilesStr, 10); - if (isNaN(maxFiles) || maxFiles < 1) { - ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); - return false; + // Parse CLI flags + const maxFilesStr = extractFlag(args, "--max-files"); + const collapseStr = extractFlag(args, "--collapse-threshold"); + + // Validate --max-files + let maxFiles: number | undefined; + if (maxFilesStr) { + maxFiles = parseInt(maxFilesStr, 10); + if (isNaN(maxFiles) || maxFiles < 1) { + ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); + return false; + } } - return maxFiles; + + // Validate --collapse-threshold + let collapseThreshold: number | undefined; + if (collapseStr) { + collapseThreshold = parseInt(collapseStr, 10); + if (isNaN(collapseThreshold) || collapseThreshold < 1) { + ctx.ui.notify("--collapse-threshold must be a positive integer (e.g. --collapse-threshold 15).", "warning"); + return false; + } + } + + return { + // CLI flags override preferences + maxFiles: maxFiles ?? prefs?.max_files, + collapseThreshold: collapseThreshold ?? prefs?.collapse_threshold, + excludePatterns: prefs?.exclude_patterns, + }; } function extractFlag(args: string, flag: string): string | undefined { diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 02882a07c..06b614173 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -229,8 +229,10 @@ const NESTED_COMPLETIONS: CompletionMap = { codebase: [ { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, { cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" }, + { cmd: "generate --collapse-threshold", desc: "Generate with custom collapse threshold (default: 20)" }, { cmd: "update", desc: "Incremental update (preserves descriptions)" }, { cmd: "update --max-files", desc: "Update with custom file limit" }, + { cmd: "update --collapse-threshold", desc: "Update with custom collapse threshold" }, { cmd: "stats", desc: "Show file count, description coverage, and generation time" }, { cmd: "help", desc: "Show usage and available subcommands" }, ], diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index f1a077dd8..d5f490459 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -16,6 +16,7 @@ import { gsdRoot } from "./paths.js"; import { assertSafeDirectory } from "./validate-directory.js"; import type { ProjectDetection, ProjectSignals } from "./detection.js"; import { runSkillInstallStep } from "./skill-catalog.js"; +import { generateCodebaseMap, writeCodebaseMap } from "./codebase-generator.js"; // ─── Types ────────────────────────────────────────────────────────────────────── @@ -238,6 +239,17 @@ export async function showProjectInit( ensureGitignore(basePath); untrackRuntimeFiles(basePath); + // Auto-generate codebase map for instant agent orientation + try { + const result = generateCodebaseMap(basePath); + if (result.fileCount > 0) { + writeCodebaseMap(basePath, result.content); + ctx.ui.notify(`Codebase map generated: ${result.fileCount} files`, "info"); + } + } catch { + // Non-fatal — codebase map generation failure should never block project init + } + ctx.ui.notify("GSD initialized. Starting your first milestone...", "info"); return { completed: true, bootstrapped: true }; diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 4356badca..dd87949d4 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -103,6 +103,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "stale_commit_threshold_minutes", "context_management", "experimental", + "codebase", ]); /** Canonical list of all dispatch unit types. */ @@ -211,6 +212,16 @@ export interface ExperimentalPreferences { rtk?: boolean; } +/** Configuration for the codebase map generator (/gsd codebase). */ +export interface CodebaseMapPreferences { + /** Additional directory/file patterns to exclude (e.g. ["docs/", "fixtures/"]). Merged with built-in defaults. */ + exclude_patterns?: string[]; + /** Max files to include in the map. Default: 500. */ + max_files?: number; + /** Files-per-directory threshold before collapsing to a summary line. Default: 20. */ + collapse_threshold?: number; +} + export interface GSDPreferences { version?: number; mode?: WorkflowMode; @@ -275,6 +286,8 @@ export interface GSDPreferences { * See the preferences reference for details on each feature. */ experimental?: ExperimentalPreferences; + /** Configuration for the codebase map generator (/gsd codebase). */ + codebase?: CodebaseMapPreferences; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 50a08a937..93ac6f1f0 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -49,6 +49,7 @@ export type { AutoSupervisorConfig, RemoteQuestionsConfig, CmuxPreferences, + CodebaseMapPreferences, GSDPreferences, LoadedGSDPreferences, SkillResolution, @@ -372,6 +373,17 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr service_tier: override.service_tier ?? base.service_tier, forensics_dedup: override.forensics_dedup ?? base.forensics_dedup, show_token_cost: override.show_token_cost ?? base.show_token_cost, + codebase: (base.codebase || override.codebase) + ? { + ...(base.codebase ?? {}), + ...(override.codebase ?? {}), + // Merge exclude_patterns arrays rather than overriding + exclude_patterns: [ + ...((base.codebase?.exclude_patterns) ?? []), + ...((override.codebase?.exclude_patterns) ?? []), + ].filter(Boolean), + } + : undefined, }; } diff --git a/src/resources/extensions/gsd/tests/codebase-generator.test.ts b/src/resources/extensions/gsd/tests/codebase-generator.test.ts index c698fc65f..91ef3314a 100644 --- a/src/resources/extensions/gsd/tests/codebase-generator.test.ts +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -486,3 +486,66 @@ test("getCodebaseMapStats: reads total file count from header for accuracy with cleanup(base); } }); + +// ─── excludePatterns from options ──────────────────────────────────────── + +test("generateCodebaseMap: custom excludePatterns filters additional directories", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + addFile(base, ".cache-data/data/index.lance"); + addFile(base, "docs/guide.md"); + + const result = generateCodebaseMap(base, { + excludePatterns: [".cache-data/", "docs/"], + }); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(result.content.includes("`src/utils.ts`")); + assert.ok(!result.content.includes(".cache-data")); + assert.ok(!result.content.includes("guide.md")); + assert.equal(result.fileCount, 2); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: collapseThreshold option overrides default", () => { + const base = makeTmpRepo(); + try { + // Create 10 files in one directory — below default threshold (20) + // but above a custom threshold of 5 + for (let i = 0; i < 10; i++) { + addFile(base, `src/comp${i}.ts`); + } + + // With default threshold (20), files should NOT collapse + const expanded = generateCodebaseMap(base); + assert.ok(expanded.content.includes("`src/comp0.ts`")); + + // With custom threshold (5), files SHOULD collapse + const collapsed = generateCodebaseMap(base, { collapseThreshold: 5 }); + assert.ok(collapsed.content.includes("10 files")); + assert.ok(!collapsed.content.includes("`src/comp0.ts`\n")); + } finally { + cleanup(base); + } +}); + +test("updateCodebaseMap: respects excludePatterns option", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "vendor-extra/lib.js"); + + const initial = generateCodebaseMap(base); + writeCodebaseMap(base, initial.content); + + // Update with exclusion should remove vendor-extra files + const result = updateCodebaseMap(base, { excludePatterns: ["vendor-extra/"] }); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(!result.content.includes("vendor-extra")); + } finally { + cleanup(base); + } +});