diff --git a/src/resources/GSD-WORKFLOW.md b/src/resources/GSD-WORKFLOW.md index 8c819643f..ef0759969 100644 --- a/src/resources/GSD-WORKFLOW.md +++ b/src/resources/GSD-WORKFLOW.md @@ -18,7 +18,8 @@ Read these files in order and act on what they say: 3. **`.gsd/milestones//M###-CONTEXT.md`** — Milestone-level project decisions, reference paths, constraints. Read this before doing implementation work. 4. If a slice is active and has one, read **`S##-CONTEXT.md`** — Slice-specific decisions and constraints. 5. If a slice is active, read its **`S##-PLAN.md`** — Which tasks exist? Which are done? -6. If a task was interrupted, check for **`continue.md`** in the active slice directory — Resume from there. +6. If `.gsd/CODEBASE.md` exists, skim it for fast structural orientation before broad code exploration. +7. If a task was interrupted, check for **`continue.md`** in the active slice directory — Resume from there. Then do the thing `STATE.md` says to do next. @@ -44,6 +45,7 @@ All artifacts live in `.gsd/` at the project root: .gsd/ STATE.md # Dashboard — always read first (derived cache; runtime, gitignored) DECISIONS.md # Append-only decisions register + CODEBASE.md # Generated codebase map cache (auto-refreshed by GSD) milestones/ M001/ M001-ROADMAP.md # Milestone plan (checkboxes = state) diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index bed614e74..b832f8d51 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -64,6 +64,7 @@ import { loadEffectiveGSDPreferences } from "./preferences.js"; import { getSliceTasks } from "./gsd-db.js"; import { runPreExecutionChecks, type PreExecutionResult } from "./pre-execution-checks.js"; import { writePreExecutionEvidence } from "./verification-evidence.js"; +import { ensureCodebaseMapFresh } from "./codebase-generator.js"; /** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */ const MAX_VERIFICATION_RETRIES = 3; @@ -669,6 +670,35 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"continue" | "step-wizard" | "stopped"> { const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx; + if (s.currentUnit) { + try { + const codebasePrefs = loadEffectiveGSDPreferences()?.preferences?.codebase; + const refresh = ensureCodebaseMapFresh( + s.basePath, + codebasePrefs + ? { + excludePatterns: codebasePrefs.exclude_patterns, + maxFiles: codebasePrefs.max_files, + collapseThreshold: codebasePrefs.collapse_threshold, + } + : undefined, + { force: true, ttlMs: 0 }, + ); + if (refresh.status === "generated" || refresh.status === "updated") { + debugLog("postUnit", { + phase: "codebase-refresh", + unitType: s.currentUnit.type, + unitId: s.currentUnit.id, + status: refresh.status, + fileCount: refresh.fileCount, + reason: refresh.reason, + }); + } + } catch (e) { + logWarning("postUnit", `CODEBASE refresh failed: ${(e as Error).message}`); + } + } + // ── Post-unit hooks ── if (s.currentUnit && !s.stepMode) { const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); @@ -995,4 +1025,3 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" return "continue"; } - diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 7d16b3b00..744e57606 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -11,6 +11,7 @@ import { readForensicsMarker } from "../forensics.js"; import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js"; import { resolveSkillReference } from "../preferences-skills.js"; import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js"; +import { ensureCodebaseMapFresh, readCodebaseMap } from "../codebase-generator.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-discovery.js"; import { getActiveAutoWorktreeContext } from "../auto-worktree.js"; import { getActiveWorktreeName, getWorktreeOriginalCwd } from "../worktree-command.js"; @@ -128,10 +129,24 @@ export async function buildBeforeAgentStartResult( } let codebaseBlock = ""; + try { + const codebaseOptions = loadedPreferences?.preferences?.codebase + ? { + excludePatterns: loadedPreferences.preferences.codebase.exclude_patterns, + maxFiles: loadedPreferences.preferences.codebase.max_files, + collapseThreshold: loadedPreferences.preferences.codebase.collapse_threshold, + } + : undefined; + ensureCodebaseMapFresh(process.cwd(), codebaseOptions); + } catch (e) { + logWarning("bootstrap", `CODEBASE refresh failed: ${(e as Error).message}`); + } + const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE"); - if (existsSync(codebasePath)) { + const rawCodebase = readCodebaseMap(process.cwd()); + if (existsSync(codebasePath) && rawCodebase) { try { - const rawContent = readFileSync(codebasePath, "utf-8").trim(); + const rawContent = rawCodebase.trim(); if (rawContent) { // Cap injection size to ~2 000 tokens to avoid bloating every request. // Full map is always available at .gsd/CODEBASE.md. @@ -141,7 +156,7 @@ export async function buildBeforeAgentStartResult( const content = rawContent.length > MAX_CODEBASE_CHARS ? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*" : rawContent; - codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, may be stale — run /gsd codebase update to refresh)]\n\n${content}`; + codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, auto-refreshed when GSD detects tracked file changes; use /gsd codebase stats for status)]\n\n${content}`; } } catch (e) { logWarning("bootstrap", `CODEBASE file read failed: ${(e as Error).message}`); @@ -494,4 +509,3 @@ export function clearForensicsMarker(basePath: string): void { } } } - diff --git a/src/resources/extensions/gsd/codebase-generator.ts b/src/resources/extensions/gsd/codebase-generator.ts index a7b1b1e56..f56d84079 100644 --- a/src/resources/extensions/gsd/codebase-generator.ts +++ b/src/resources/extensions/gsd/codebase-generator.ts @@ -8,6 +8,7 @@ * Maintenance: agent updates descriptions as it works; incremental update preserves them. */ +import { createHash } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname, extname } from "node:path"; @@ -22,6 +23,28 @@ export interface CodebaseMapOptions { collapseThreshold?: number; } +export interface CodebaseMapMetadata { + generatedAt: string; + fingerprint: string; + fileCount: number; + truncated: boolean; +} + +export interface EnsureCodebaseMapOptions { + ttlMs?: number; + maxAgeMs?: number; + force?: boolean; +} + +export interface EnsureCodebaseMapResult { + status: "generated" | "updated" | "fresh" | "empty"; + fileCount: number; + truncated: boolean; + generatedAt: string | null; + fingerprint: string | null; + reason?: string; +} + interface FileEntry { path: string; description: string; @@ -33,6 +56,18 @@ interface DirectoryGroup { collapsed: boolean; } +interface ResolvedCodebaseMapOptions { + excludes: string[]; + maxFiles: number; + collapseThreshold: number; + optionSignature: string; +} + +interface EnumeratedFiles { + files: string[]; + truncated: boolean; +} + // ─── Defaults ──────────────────────────────────────────────────────────────── const DEFAULT_EXCLUDES = [ @@ -55,6 +90,11 @@ const DEFAULT_EXCLUDES = [ const DEFAULT_MAX_FILES = 500; const DEFAULT_COLLAPSE_THRESHOLD = 20; +const DEFAULT_REFRESH_TTL_MS = 30_000; +const DEFAULT_MAX_AGE_MS = 15 * 60_000; +const CODEBASE_METADATA_PREFIX = ""); + if (jsonEnd <= jsonStart) return null; + + try { + const parsed = JSON.parse(trimmed.slice(jsonStart, jsonEnd)); + if ( + typeof parsed?.generatedAt === "string" + && typeof parsed?.fingerprint === "string" + && typeof parsed?.fileCount === "number" + && typeof parsed?.truncated === "boolean" + ) { + return parsed as CodebaseMapMetadata; + } + } catch { + // Ignore malformed metadata and treat the map as stale. + } + return null; +} + // ─── File Enumeration ──────────────────────────────────────────────────────── function shouldExclude(filePath: string, excludes: string[]): boolean { @@ -134,6 +201,36 @@ function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated }; } +function resolveGeneratorOptions(options?: CodebaseMapOptions): ResolvedCodebaseMapOptions { + const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; + const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; + const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; + return { + excludes, + maxFiles, + collapseThreshold, + optionSignature: JSON.stringify({ + excludes, + maxFiles, + collapseThreshold, + }), + }; +} + +function computeCodebaseFingerprint( + files: string[], + resolved: ResolvedCodebaseMapOptions, + truncated: boolean, +): string { + return createHash("sha1") + .update(JSON.stringify({ + files, + truncated, + optionSignature: resolved.optionSignature, + })) + .digest("hex"); +} + // ─── Grouping ──────────────────────────────────────────────────────────────── function groupByDirectory( @@ -174,14 +271,19 @@ function groupByDirectory( // ─── Rendering ─────────────────────────────────────────────────────────────── -function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string { +function renderCodebaseMap( + groups: DirectoryGroup[], + totalFiles: number, + truncated: boolean, + metadata: CodebaseMapMetadata, +): string { const lines: string[] = []; - const now = new Date().toISOString().split(".")[0] + "Z"; const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0); lines.push("# Codebase Map"); lines.push(""); - lines.push(`Generated: ${now} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`); + lines.push(`Generated: ${metadata.generatedAt} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`); + lines.push(`${CODEBASE_METADATA_PREFIX}${JSON.stringify(metadata)} -->`); if (truncated) { lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`); } @@ -229,6 +331,41 @@ function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncat return lines.join("\n"); } +function buildCodebaseMap( + basePath: string, + resolved: ResolvedCodebaseMapOptions, + existingDescriptions?: Map, + enumerated?: EnumeratedFiles, +): { + content: string; + fileCount: number; + truncated: boolean; + files: string[]; + fingerprint: string; + generatedAt: string; +} { + const listed = enumerated ?? enumerateFiles(basePath, resolved.excludes, resolved.maxFiles); + const descriptions = existingDescriptions ?? new Map(); + const groups = groupByDirectory(listed.files, descriptions, resolved.collapseThreshold); + const generatedAt = new Date().toISOString().split(".")[0] + "Z"; + const metadata: CodebaseMapMetadata = { + generatedAt, + fingerprint: computeCodebaseFingerprint(listed.files, resolved, listed.truncated), + fileCount: listed.files.length, + truncated: listed.truncated, + }; + const content = renderCodebaseMap(groups, listed.files.length, listed.truncated, metadata); + + return { + content, + fileCount: listed.files.length, + truncated: listed.truncated, + files: listed.files, + fingerprint: metadata.fingerprint, + generatedAt, + }; +} + // ─── Public API ────────────────────────────────────────────────────────────── /** @@ -239,17 +376,9 @@ export function generateCodebaseMap( basePath: string, options?: CodebaseMapOptions, existingDescriptions?: Map, -): { content: string; fileCount: number; truncated: boolean; files: string[] } { - const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; - const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; - const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; - - const { files, truncated } = enumerateFiles(basePath, excludes, maxFiles); - const descriptions = existingDescriptions ?? new Map(); - const groups = groupByDirectory(files, descriptions, collapseThreshold); - const content = renderCodebaseMap(groups, files.length, truncated); - - return { content, fileCount: files.length, truncated, files }; +): { content: string; fileCount: number; truncated: boolean; files: string[]; fingerprint: string; generatedAt: string } { + const resolved = resolveGeneratorOptions(options); + return buildCodebaseMap(basePath, resolved, existingDescriptions); } /** @@ -259,8 +388,18 @@ export function generateCodebaseMap( export function updateCodebaseMap( basePath: string, options?: CodebaseMapOptions, -): { content: string; added: number; removed: number; unchanged: number; fileCount: number; truncated: boolean } { +): { + content: string; + added: number; + removed: number; + unchanged: number; + fileCount: number; + truncated: boolean; + fingerprint: string; + generatedAt: string; +} { const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); + const resolved = resolveGeneratorOptions(options); // Load existing descriptions let existingDescriptions = new Map(); @@ -273,7 +412,7 @@ export function updateCodebaseMap( // Generate new map preserving descriptions — reuse the returned file list // to avoid a second enumeration (prevents race between content and stats). - const result = generateCodebaseMap(basePath, options, existingDescriptions); + const result = buildCodebaseMap(basePath, resolved, existingDescriptions); const currentSet = new Set(result.files); // Count changes @@ -294,9 +433,114 @@ export function updateCodebaseMap( unchanged: result.files.length - added, fileCount: result.fileCount, truncated: result.truncated, + fingerprint: result.fingerprint, + generatedAt: result.generatedAt, }; } +function clearFreshnessCache(basePath: string): void { + for (const key of freshnessCache.keys()) { + if (key === basePath || key.startsWith(`${basePath}::`)) { + freshnessCache.delete(key); + } + } +} + +export function ensureCodebaseMapFresh( + basePath: string, + options?: CodebaseMapOptions, + ensureOptions?: EnsureCodebaseMapOptions, +): EnsureCodebaseMapResult { + const resolved = resolveGeneratorOptions(options); + const cacheKey = `${basePath}::${resolved.optionSignature}`; + const ttlMs = ensureOptions?.ttlMs ?? DEFAULT_REFRESH_TTL_MS; + const maxAgeMs = ensureOptions?.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + const force = ensureOptions?.force === true; + const now = Date.now(); + + if (!force && ttlMs > 0) { + const cached = freshnessCache.get(cacheKey); + if (cached && now - cached.checkedAt < ttlMs) { + return cached.result; + } + } + + const existing = readCodebaseMap(basePath); + const listed = enumerateFiles(basePath, resolved.excludes, resolved.maxFiles); + const fingerprint = computeCodebaseFingerprint(listed.files, resolved, listed.truncated); + + const cacheAndReturn = (result: EnsureCodebaseMapResult): EnsureCodebaseMapResult => { + freshnessCache.set(cacheKey, { checkedAt: now, result }); + return result; + }; + + if (!existing) { + const generated = buildCodebaseMap(basePath, resolved, undefined, listed); + if (generated.fileCount > 0) { + writeCodebaseMap(basePath, generated.content); + return cacheAndReturn({ + status: "generated", + fileCount: generated.fileCount, + truncated: generated.truncated, + generatedAt: generated.generatedAt, + fingerprint: generated.fingerprint, + reason: "missing", + }); + } + return cacheAndReturn({ + status: "empty", + fileCount: 0, + truncated: false, + generatedAt: null, + fingerprint, + reason: "no-tracked-files", + }); + } + + const metadata = parseCodebaseMapMetadata(existing); + const existingDescriptions = parseCodebaseMap(existing); + const ageMs = metadata ? now - Date.parse(metadata.generatedAt) : Number.POSITIVE_INFINITY; + const staleReason = + !metadata ? "missing-metadata" + : metadata.fingerprint !== fingerprint ? "files-changed" + : metadata.fileCount !== listed.files.length ? "file-count-changed" + : metadata.truncated !== listed.truncated ? "truncation-changed" + : maxAgeMs > 0 && Number.isFinite(ageMs) && ageMs > maxAgeMs ? "expired" + : undefined; + + if (!staleReason) { + return cacheAndReturn({ + status: "fresh", + fileCount: metadata?.fileCount ?? listed.files.length, + truncated: metadata?.truncated ?? listed.truncated, + generatedAt: metadata?.generatedAt ?? null, + fingerprint: metadata?.fingerprint ?? fingerprint, + }); + } + + const updated = buildCodebaseMap(basePath, resolved, existingDescriptions, listed); + if (updated.fileCount > 0) { + writeCodebaseMap(basePath, updated.content); + return cacheAndReturn({ + status: "updated", + fileCount: updated.fileCount, + truncated: updated.truncated, + generatedAt: updated.generatedAt, + fingerprint: updated.fingerprint, + reason: staleReason, + }); + } + + return cacheAndReturn({ + status: "empty", + fileCount: 0, + truncated: false, + generatedAt: null, + fingerprint, + reason: staleReason, + }); +} + /** * Write CODEBASE.md to .gsd/ directory. */ @@ -305,6 +549,7 @@ export function writeCodebaseMap(basePath: string, content: string): string { mkdirSync(root, { recursive: true }); const outPath = join(root, "CODEBASE.md"); writeFileSync(outPath, content, "utf-8"); + clearFreshnessCache(basePath); return outPath; } diff --git a/src/resources/extensions/gsd/commands-bootstrap.ts b/src/resources/extensions/gsd/commands-bootstrap.ts index 9a973c2d9..0f5c55cd1 100644 --- a/src/resources/extensions/gsd/commands-bootstrap.ts +++ b/src/resources/extensions/gsd/commands-bootstrap.ts @@ -45,6 +45,7 @@ const TOP_LEVEL_SUBCOMMANDS = [ { cmd: "start", desc: "Start a workflow template" }, { cmd: "templates", desc: "List available workflow templates" }, { cmd: "extensions", desc: "Manage extensions" }, + { cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache" }, ] as const; function filterStartsWith( @@ -218,6 +219,15 @@ function getGsdArgumentCompletions(prefix: string) { ], "extensions"); } + if (parts[0] === "codebase" && parts.length <= 2) { + return filterStartsWith(partial, [ + { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { cmd: "update", desc: "Refresh the CODEBASE.md cache immediately" }, + { cmd: "stats", desc: "Show codebase-map coverage and generation time" }, + { cmd: "help", desc: "Show usage and subcommands" }, + ], "codebase"); + } + if (parts[0] === "doctor" && parts.length <= 2) { return filterStartsWith(partial, [ { cmd: "fix", desc: "Auto-fix detected issues" }, diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts index 0072d806a..20967e03f 100644 --- a/src/resources/extensions/gsd/commands-codebase.ts +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -20,10 +20,11 @@ import type { CodebaseMapOptions } from "./codebase-generator.js"; const USAGE = "Usage: /gsd codebase [generate|update|stats]\n\n" + " 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" + + " update [--max-files N] [--collapse-threshold N] — Refresh the CODEBASE.md cache immediately\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" + + "With no subcommand, shows stats if a map exists or help if not.\n" + + "GSD also refreshes CODEBASE.md automatically before prompt injection and after completed units when tracked files change.\n\n" + "Configure defaults via preferences.md:\n" + " codebase:\n" + " exclude_patterns: [\"docs/\", \"fixtures/\"]\n" + @@ -141,7 +142,7 @@ function showStats(basePath: string, ctx: ExtensionCommandContext): void { ` Undescribed: ${stats.undescribedCount}\n` + ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + (stats.undescribedCount > 0 - ? `Tip: Run /gsd codebase update to refresh after file changes.` + ? `Tip: Auto-refresh keeps the cache current, but /gsd codebase update forces an immediate refresh.` : `Coverage is complete.`), "info", ); diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index a232a2001..b3a03753d 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -72,7 +72,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "mcp", desc: "MCP server status and connectivity check (status, check )" }, { cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" }, { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, - { cmd: "codebase", desc: "Generate and manage codebase map (.gsd/CODEBASE.md)" }, + { cmd: "codebase", desc: "Generate, refresh, and inspect the codebase map cache (.gsd/CODEBASE.md)" }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -236,7 +236,7 @@ const NESTED_COMPLETIONS: CompletionMap = { { 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", desc: "Refresh the CODEBASE.md cache immediately (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" }, diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index 5461aa40d..ca24eb0cc 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -44,6 +44,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { "", "PROJECT KNOWLEDGE", " /gsd knowledge Add rule, pattern, or lesson to KNOWLEDGE.md", + " /gsd codebase [generate|update|stats] Manage the CODEBASE.md cache used in prompt context", "", "SETUP & CONFIGURATION", " /gsd init Project init wizard — detect, configure, bootstrap .gsd/", diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index e79e1c3b9..45998c36e 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -62,6 +62,7 @@ Titles live inside file content (headings, frontmatter), not in file or director REQUIREMENTS.md (requirement contract - tracks active/validated/deferred/out-of-scope) DECISIONS.md (append-only register of architectural and pattern decisions) KNOWLEDGE.md (append-only register of project-specific rules, patterns, and lessons learned) + CODEBASE.md (generated codebase map cache — auto-refreshed when tracked files change) OVERRIDES.md (user-issued overrides that supersede plan content via /gsd steer) QUEUE.md (append-only log of queued milestones via /gsd queue) STATE.md @@ -104,6 +105,7 @@ In all modes, slices commit sequentially on the active branch; there are no per- - **REQUIREMENTS.md** tracks the requirement contract — requirements move between Active, Validated, Deferred, Blocked, and Out of Scope as slices prove or invalidate them. Update at slice completion when evidence supports a status change. - **DECISIONS.md** is an append-only register of architectural and pattern decisions - read it during planning/research, append to it during execution when a meaningful decision is made - **KNOWLEDGE.md** is an append-only register of project-specific rules, patterns, and lessons learned. Read it at the start of every unit. Append to it when you discover a recurring issue, a non-obvious pattern, or a rule that future agents should follow. +- **CODEBASE.md** is a generated structural cache of the tracked repository. GSD auto-refreshes it when tracked files change and injects it into system context when available. Use `/gsd codebase update` only when you need to force an immediate refresh. - **CONTEXT.md** files (milestone or slice level) capture the brief — scope, goals, constraints, and key decisions from discussion. When present, they are the authoritative source for what a milestone or slice is trying to achieve. Read them before planning or executing. - **Milestones** are major project phases (M001, M002, ...) - **Slices** are demoable vertical increments (S01, S02, ...) ordered by risk. After each slice completes, the roadmap is reassessed before the next slice begins. @@ -131,6 +133,7 @@ Templates showing the expected format for each artifact type are in: - `/gsd status` - progress dashboard overlay - `/gsd queue` - queue future milestones (safe while auto-mode is running) - `/gsd quick ` - quick task with GSD guarantees (atomic commits, state tracking) but no milestone ceremony +- `/gsd codebase [generate|update|stats]` - manage the `.gsd/CODEBASE.md` cache used for prompt context - `{{shortcutDashboard}}` - toggle dashboard overlay - `{{shortcutShell}}` - show shell processes diff --git a/src/resources/extensions/gsd/tests/codebase-generator.test.ts b/src/resources/extensions/gsd/tests/codebase-generator.test.ts index fb3a0fc15..d8d3d74c8 100644 --- a/src/resources/extensions/gsd/tests/codebase-generator.test.ts +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -8,11 +8,13 @@ import { execSync } from "node:child_process"; import { parseCodebaseMap, + parseCodebaseMapMetadata, generateCodebaseMap, updateCodebaseMap, writeCodebaseMap, readCodebaseMap, getCodebaseMapStats, + ensureCodebaseMapFresh, } from "../codebase-generator.ts"; // ─── Helpers ────────────────────────────────────────────────────────────── @@ -212,6 +214,24 @@ test("generateCodebaseMap: preserves existing descriptions", () => { } }); +test("generateCodebaseMap: writes freshness metadata comment", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + + const result = generateCodebaseMap(base); + const metadata = parseCodebaseMapMetadata(result.content); + + assert.ok(metadata, "metadata comment should be present"); + assert.equal(metadata?.fileCount, 1); + assert.equal(metadata?.truncated, false); + assert.equal(typeof metadata?.fingerprint, "string"); + assert.ok(metadata?.generatedAt?.endsWith("Z")); + } finally { + cleanup(base); + } +}); + test("generateCodebaseMap: collapses large directories", () => { const base = makeTmpRepo(); try { @@ -571,3 +591,51 @@ test("updateCodebaseMap: respects excludePatterns option", () => { cleanup(base); } }); + +test("ensureCodebaseMapFresh: generates CODEBASE.md when missing", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + + const result = ensureCodebaseMapFresh(base, undefined, { ttlMs: 0, force: true }); + const written = readCodebaseMap(base); + + assert.equal(result.status, "generated"); + assert.ok(written?.includes("`src/main.ts`")); + } finally { + cleanup(base); + } +}); + +test("ensureCodebaseMapFresh: updates CODEBASE.md when tracked files change", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + const initial = ensureCodebaseMapFresh(base, undefined, { ttlMs: 0, force: true }); + assert.equal(initial.status, "generated"); + + addFile(base, "src/new.ts"); + const refreshed = ensureCodebaseMapFresh(base, undefined, { ttlMs: 0, force: true }); + const written = readCodebaseMap(base); + + assert.equal(refreshed.status, "updated"); + assert.equal(refreshed.reason, "files-changed"); + assert.ok(written?.includes("`src/new.ts`")); + } finally { + cleanup(base); + } +}); + +test("ensureCodebaseMapFresh: returns fresh when metadata matches repository state", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + ensureCodebaseMapFresh(base, undefined, { ttlMs: 0, force: true }); + + const refreshed = ensureCodebaseMapFresh(base, undefined, { ttlMs: 0, force: true }); + assert.equal(refreshed.status, "fresh"); + assert.equal(refreshed.fileCount, 1); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/prompt-contracts.test.ts b/src/resources/extensions/gsd/tests/prompt-contracts.test.ts index 7c1092641..d84ff0a07 100644 --- a/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +++ b/src/resources/extensions/gsd/tests/prompt-contracts.test.ts @@ -35,6 +35,13 @@ test("workflow-start prompt defaults to autonomy instead of per-phase confirmati assert.doesNotMatch(prompt, /Gate between phases/i); }); +test("system prompt references CODEBASE.md and /gsd codebase", () => { + const prompt = readPrompt("system"); + assert.match(prompt, /CODEBASE\.md/); + assert.match(prompt, /\/gsd codebase \[generate\|update\|stats\]/); + assert.match(prompt, /auto-refreshes it when tracked files change/i); +}); + test("discuss prompt allows implementation questions when they materially matter", () => { const prompt = readPrompt("discuss"); assert.match(prompt, /Lead with experience, but ask implementation when it materially matters/i); diff --git a/src/resources/extensions/gsd/tests/update-command.test.ts b/src/resources/extensions/gsd/tests/update-command.test.ts index 9245d87c0..849f261ef 100644 --- a/src/resources/extensions/gsd/tests/update-command.test.ts +++ b/src/resources/extensions/gsd/tests/update-command.test.ts @@ -65,3 +65,22 @@ test("/gsd update is listed in completions with correct description", () => { "completion description should mention updating", ); }); + +test("/gsd codebase appears in top-level completions", () => { + const pi = createMockPi(); + registerGSDCommand(pi as any); + + const gsd = pi.commands.get("gsd"); + const completions = gsd.getArgumentCompletions("code"); + const codebaseEntry = completions.find((c: any) => c.value === "codebase"); + assert.ok(codebaseEntry, "codebase should appear in completions"); + assert.match(codebaseEntry.description, /codebase map cache/i); +}); + +test("/gsd codebase appears in help description", () => { + const pi = createMockPi(); + registerGSDCommand(pi as any); + + const gsd = pi.commands.get("gsd"); + assert.ok(gsd?.description?.includes("codebase"), "description should mention codebase"); +});