fix(gsd): auto-refresh codebase cache
This commit is contained in:
parent
d8574e5669
commit
655f10de4b
12 changed files with 426 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue