fix(gsd): auto-refresh codebase cache

This commit is contained in:
Jeremy 2026-04-09 05:46:55 -05:00
parent d8574e5669
commit 655f10de4b
12 changed files with 426 additions and 27 deletions

View file

@ -18,7 +18,8 @@ Read these files in order and act on what they say:
3. **`.gsd/milestones/<active>/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)

View file

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

View file

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

View file

@ -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 = "<!-- gsd:codebase-meta ";
const freshnessCache = new Map<string, { checkedAt: number; result: EnsureCodebaseMapResult }>();
// ─── Parsing ─────────────────────────────────────────────────────────────────
@ -96,6 +136,33 @@ export function parseCodebaseMap(content: string): Map<string, string> {
return descriptions;
}
export function parseCodebaseMapMetadata(content: string): CodebaseMapMetadata | null {
const metaLine = content
.split("\n")
.find((line) => line.trimStart().startsWith(CODEBASE_METADATA_PREFIX));
if (!metaLine) return null;
const trimmed = metaLine.trim();
const jsonStart = CODEBASE_METADATA_PREFIX.length;
const jsonEnd = trimmed.lastIndexOf(" -->");
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<string, string>,
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<string, string>();
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<string, string>,
): { 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<string, string>();
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<string, string>();
@ -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;
}

View file

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

View file

@ -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",
);

View file

@ -72,7 +72,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [
{ cmd: "mcp", desc: "MCP server status and connectivity check (status, check <server>)" },
{ 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" },

View file

@ -44,6 +44,7 @@ export function showHelp(ctx: ExtensionCommandContext): void {
"",
"PROJECT KNOWLEDGE",
" /gsd knowledge <type> <text> 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/",

View file

@ -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 <task>` - 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

View file

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

View file

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

View file

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