feat(gsd): enhance /gsd codebase with preferences, --collapse-threshold, and auto-init

Add configurable codebase map options via preferences.md (exclude_patterns,
max_files, collapse_threshold), expose --collapse-threshold as a CLI flag,
and auto-generate CODEBASE.md during project init for instant agent orientation.

Closes #3509
This commit is contained in:
Jeremy 2026-04-04 14:51:51 -05:00
parent 104d103d14
commit bbe67da02c
6 changed files with 155 additions and 21 deletions

View file

@ -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 {

View file

@ -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" },
],

View file

@ -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 };

View file

@ -103,6 +103,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"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 {

View file

@ -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,
};
}

View file

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