From a9b14dc18145dc3276ada75db39eeebd7cfbde60 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Mon, 16 Mar 2026 07:15:18 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20.gsd/KNOWLEDGE.md=20=E2=80=94=20p?= =?UTF-8?q?ersistent=20project-specific=20context=20(#585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/resources/extensions/gsd/auto-prompts.ts | 21 ++- src/resources/extensions/gsd/commands.ts | 53 +++++- .../gsd/docs/preferences-reference.md | 2 +- src/resources/extensions/gsd/files.ts | 122 +++++++++++++ src/resources/extensions/gsd/index.ts | 19 ++- src/resources/extensions/gsd/paths.ts | 2 + .../extensions/gsd/prompts/execute-task.md | 11 +- .../extensions/gsd/prompts/system.md | 2 + .../extensions/gsd/templates/knowledge.md | 19 +++ .../extensions/gsd/tests/knowledge.test.ts | 161 ++++++++++++++++++ 10 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 src/resources/extensions/gsd/templates/knowledge.md create mode 100644 src/resources/extensions/gsd/tests/knowledge.test.ts diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 16d93713f..8b5a46da2 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -89,7 +89,7 @@ export async function inlineDependencySummaries( export async function inlineGsdRootFile( base: string, filename: string, label: string, ): Promise { - const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS"; + const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS" | "KNOWLEDGE"; const absPath = resolveGsdRootFile(base, key); if (!existsSync(absPath)) return null; return inlineFileOptional(absPath, relGsdRootFile(key), label); @@ -377,6 +377,8 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string if (requirementsInline) inlined.push(requirementsInline); const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); if (decisionsInline) inlined.push(decisionsInline); + const knowledgeInlineRM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlineRM) inlined.push(knowledgeInlineRM); inlined.push(inlineTemplate("research", "Research")); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -413,6 +415,8 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba if (requirementsInline) inlined.push(requirementsInline); const decisionsInline = inlineLevel !== "minimal" ? await inlineGsdRootFile(base, "decisions.md", "Decisions") : null; if (decisionsInline) inlined.push(decisionsInline); + const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlinePM) inlined.push(knowledgeInlinePM); inlined.push(inlineTemplate("roadmap", "Roadmap")); if (inlineLevel === "full") { inlined.push(inlineTemplate("decisions", "Decisions")); @@ -461,6 +465,8 @@ export async function buildResearchSlicePrompt( if (decisionsInline) inlined.push(decisionsInline); const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); if (requirementsInline) inlined.push(requirementsInline); + const knowledgeInlineRS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlineRS) inlined.push(knowledgeInlineRS); inlined.push(inlineTemplate("research", "Research")); const depContent = await inlineDependencySummaries(mid, sid, base); @@ -504,6 +510,8 @@ export async function buildPlanSlicePrompt( const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); if (requirementsInline) inlined.push(requirementsInline); } + const knowledgeInlinePS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlinePS) inlined.push(knowledgeInlinePS); inlined.push(inlineTemplate("plan", "Slice Plan")); if (inlineLevel === "full") { inlined.push(inlineTemplate("task-plan", "Task Plan")); @@ -578,11 +586,16 @@ export async function buildExecuteTaskPrompt( ? priorSummaries.slice(-1) : priorSummaries; const carryForwardSection = await buildCarryForwardSection(effectivePriorSummaries, base); + + // Inline project knowledge if available + const knowledgeInlineET = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + const inlinedTemplates = inlineLevel === "minimal" ? inlineTemplate("task-summary", "Task Summary") : [ inlineTemplate("task-summary", "Task Summary"), inlineTemplate("decisions", "Decisions"), + ...(knowledgeInlineET ? [knowledgeInlineET] : []), ].join("\n\n---\n\n"); const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`; @@ -624,6 +637,8 @@ export async function buildCompleteSlicePrompt( const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements"); if (requirementsInline) inlined.push(requirementsInline); } + const knowledgeInlineCS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlineCS) inlined.push(knowledgeInlineCS); // Inline all task summaries for this slice const tDir = resolveTasksDir(base, mid, sid); @@ -697,6 +712,8 @@ export async function buildCompleteMilestonePrompt( const projectInline = await inlineGsdRootFile(base, "project.md", "Project"); if (projectInline) inlined.push(projectInline); } + const knowledgeInlineCM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlineCM) inlined.push(knowledgeInlineCM); // Inline milestone context file (milestone-level, not GSD root) const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); const contextRel = relMilestoneFile(base, mid, "CONTEXT"); @@ -825,6 +842,8 @@ export async function buildReassessRoadmapPrompt( const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); if (decisionsInline) inlined.push(decisionsInline); } + const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge"); + if (knowledgeInlineRA) inlined.push(knowledgeInlineRA); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index a2a86e89a..38b66e3ac 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -22,7 +22,7 @@ import { loadEffectiveGSDPreferences, resolveAllSkillReferences, } from "./preferences.js"; -import { loadFile, saveFile, appendOverride } from "./files.js"; +import { loadFile, saveFile, appendOverride, appendKnowledge } from "./files.js"; import { formatDoctorIssuesForPrompt, formatDoctorReport, @@ -58,12 +58,12 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer", + description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge", getArgumentCompletions: (prefix: string) => { const subcommands = [ "next", "auto", "stop", "pause", "status", "queue", "discuss", "history", "undo", "skip", "export", "cleanup", "prefs", - "config", "hooks", "doctor", "migrate", "remote", "steer", + "config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge", ]; const parts = prefix.trim().split(/\s+/); @@ -126,6 +126,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((cmd) => ({ value: `cleanup ${cmd}`, label: cmd })); } + if (parts[0] === "knowledge" && parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + return ["rule", "pattern", "lesson"] + .filter((cmd) => cmd.startsWith(subPrefix)) + .map((cmd) => ({ value: `knowledge ${cmd}`, label: cmd })); + } + if (parts[0] === "doctor") { const modePrefix = parts[1] ?? ""; const modes = ["fix", "heal", "audit"]; @@ -266,6 +273,15 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed.startsWith("knowledge ")) { + await handleKnowledge(trimmed.replace(/^knowledge\s+/, "").trim(), ctx); + return; + } + if (trimmed === "knowledge") { + ctx.ui.notify("Usage: /gsd knowledge . Example: /gsd knowledge rule Use real DB for integration tests", "warning"); + return; + } + if (trimmed === "migrate" || trimmed.startsWith("migrate ")) { const { handleMigrate } = await import("./migrate/command.js"); await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi); @@ -284,7 +300,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer .`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer |knowledge .`, "warning", ); }, @@ -972,6 +988,35 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); } +async function handleKnowledge(args: string, ctx: ExtensionCommandContext): Promise { + const parts = args.split(/\s+/); + const typeArg = parts[0]?.toLowerCase(); + + if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) { + ctx.ui.notify( + "Usage: /gsd knowledge \nExample: /gsd knowledge rule Use real DB for integration tests", + "warning", + ); + return; + } + + const entryText = parts.slice(1).join(" ").trim(); + if (!entryText) { + ctx.ui.notify(`Usage: /gsd knowledge ${typeArg} `, "warning"); + return; + } + + const type = typeArg as "rule" | "pattern" | "lesson"; + const basePath = process.cwd(); + const state = await deriveState(basePath); + const scope = state.activeMilestone?.id + ? `${state.activeMilestone.id}${state.activeSlice ? `/${state.activeSlice.id}` : ""}` + : "global"; + + await appendKnowledge(basePath, type, entryText, scope); + ctx.ui.notify(`Added ${type} to KNOWLEDGE.md: "${entryText}"`, "success"); +} + async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { const basePath = process.cwd(); const state = await deriveState(basePath); diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index 03359444a..a71f06292 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -80,7 +80,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `skill_rules`: situational rules with a human-readable `when` trigger and one or more of `use`, `prefer`, or `avoid`. -- `custom_instructions`: extra durable instructions related to skill use. +- `custom_instructions`: extra durable instructions related to skill use. For operational project knowledge (recurring rules, gotchas, patterns), use `.gsd/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically and agents can append to it during execution. - `models`: per-stage model selection for auto-mode. Keys: `research`, `planning`, `execution`, `completion`. Values can be: - Simple string: `"claude-sonnet-4-6"` — single model, no fallbacks diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index c27c45a85..20b2fbbec 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -951,6 +951,128 @@ export async function appendOverride(basePath: string, change: string, appliedAt } } +export async function appendKnowledge( + basePath: string, + type: "rule" | "pattern" | "lesson", + entry: string, + scope: string, +): Promise { + const knowledgePath = resolveGsdRootFile(basePath, "KNOWLEDGE"); + const existing = await loadFile(knowledgePath); + + if (existing) { + // Find the next ID for this type + const prefix = type === "rule" ? "K" : type === "pattern" ? "P" : "L"; + const idPattern = new RegExp(`^\\| ${prefix}(\\d+)`, "gm"); + let maxId = 0; + let match; + while ((match = idPattern.exec(existing)) !== null) { + const num = parseInt(match[1], 10); + if (num > maxId) maxId = num; + } + const nextId = `${prefix}${String(maxId + 1).padStart(3, "0")}`; + + // Build the table row + let row: string; + if (type === "rule") { + row = `| ${nextId} | ${scope} | ${entry} | — | manual |`; + } else if (type === "pattern") { + row = `| ${nextId} | ${entry} | — | ${scope} |`; + } else { + row = `| ${nextId} | ${entry} | — | — | ${scope} |`; + } + + // Find the right section and append after the table header + const sectionHeading = type === "rule" ? "## Rules" : type === "pattern" ? "## Patterns" : "## Lessons Learned"; + const sectionIdx = existing.indexOf(sectionHeading); + if (sectionIdx !== -1) { + // Find the end of the table header row (the |---|...| line) + const afterHeading = existing.indexOf("\n", sectionIdx); + // Find the next section or end + const nextSection = existing.indexOf("\n## ", afterHeading + 1); + const insertPoint = nextSection !== -1 ? nextSection : existing.length; + + // Insert row before the next section (or at end) + const before = existing.slice(0, insertPoint).trimEnd(); + const after = existing.slice(insertPoint); + await saveFile(knowledgePath, before + "\n" + row + "\n" + after); + } else { + // Section not found — append at end + await saveFile(knowledgePath, existing.trimEnd() + "\n\n" + row + "\n"); + } + } else { + // Create file from scratch with template header + const header = [ + "# Project Knowledge", + "", + "Append-only register of project-specific rules, patterns, and lessons learned.", + "Agents read this before every unit. Add entries when you discover something worth remembering.", + "", + ].join("\n"); + + let content: string; + if (type === "rule") { + content = header + [ + "## Rules", + "", + "| # | Scope | Rule | Why | Added |", + "|---|-------|------|-----|-------|", + `| K001 | ${scope} | ${entry} | — | manual |`, + "", + "## Patterns", + "", + "| # | Pattern | Where | Notes |", + "|---|---------|-------|-------|", + "", + "## Lessons Learned", + "", + "| # | What Happened | Root Cause | Fix | Scope |", + "|---|--------------|------------|-----|-------|", + "", + ].join("\n"); + } else if (type === "pattern") { + content = header + [ + "## Rules", + "", + "| # | Scope | Rule | Why | Added |", + "|---|-------|------|-----|-------|", + "", + "## Patterns", + "", + "| # | Pattern | Where | Notes |", + "|---|---------|-------|-------|", + `| P001 | ${entry} | — | ${scope} |`, + "", + "## Lessons Learned", + "", + "| # | What Happened | Root Cause | Fix | Scope |", + "|---|--------------|------------|-----|-------|", + "", + ].join("\n"); + } else { + content = header + [ + "## Rules", + "", + "| # | Scope | Rule | Why | Added |", + "|---|-------|------|-----|-------|", + "", + "## Patterns", + "", + "| # | Pattern | Where | Notes |", + "|---|---------|-------|-------|", + "", + "## Lessons Learned", + "", + "| # | What Happened | Root Cause | Fix | Scope |", + "|---|--------------|------------|-----|-------|", + `| L001 | ${entry} | — | — | ${scope} |`, + "", + ].join("\n"); + } + await saveFile(knowledgePath, content); + } +} + export async function loadActiveOverrides(basePath: string): Promise { const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); const content = await loadFile(overridesPath); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 855a51255..b66083f8a 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -47,10 +47,11 @@ import { resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile, buildSliceFileName, buildMilestoneFileName, gsdRoot, resolveMilestonePath, + resolveGsdRootFile, } from "./paths.js"; import { Key } from "@gsd/pi-tui"; import { join } from "node:path"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { shortcutDesc } from "../shared/terminal.js"; import { Text } from "@gsd/pi-tui"; import { pauseAutoForProviderError } from "./provider-error-pause.js"; @@ -272,6 +273,20 @@ export default function (pi: ExtensionAPI) { } } + // Load project knowledge if available + let knowledgeBlock = ""; + const knowledgePath = resolveGsdRootFile(process.cwd(), "KNOWLEDGE"); + if (existsSync(knowledgePath)) { + try { + const content = readFileSync(knowledgePath, "utf-8").trim(); + if (content) { + knowledgeBlock = `\n\n[PROJECT KNOWLEDGE — Rules, patterns, and lessons learned]\n\n${content}`; + } + } catch { + // File read error — skip knowledge injection + } + } + // Detect skills installed during this auto-mode session let newSkillsBlock = ""; if (hasSkillSnapshot()) { @@ -307,7 +322,7 @@ export default function (pi: ExtensionAPI) { } return { - systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${newSkillsBlock}${worktreeBlock}`, + systemPrompt: `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${newSkillsBlock}${worktreeBlock}`, ...(injection ? { message: { diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index c89ec5788..b90c463fa 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -248,6 +248,7 @@ export const GSD_ROOT_FILES = { STATE: "STATE.md", REQUIREMENTS: "REQUIREMENTS.md", OVERRIDES: "OVERRIDES.md", + KNOWLEDGE: "KNOWLEDGE.md", } as const; export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; @@ -259,6 +260,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { STATE: "state.md", REQUIREMENTS: "requirements.md", OVERRIDES: "overrides.md", + KNOWLEDGE: "knowledge.md", }; export function gsdRoot(basePath: string): string { diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 4ae7255cd..fb7d84f7e 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -54,11 +54,12 @@ Then: - Don't fix symptoms. Understand *why* something fails before changing code. A test that passes after a change you don't understand is luck, not a fix. 11. **Blocker discovery:** If execution reveals that the remaining slice plan is fundamentally invalid — not just a bug or minor deviation, but a plan-invalidating finding like a wrong API, missing capability, or architectural mismatch — set `blocker_discovered: true` in the task summary frontmatter and describe the blocker clearly in the summary narrative. Do NOT set `blocker_discovered: true` for ordinary debugging, minor deviations, or issues that can be fixed within the current task or the remaining plan. This flag triggers an automatic replan of the slice. 12. If you made an architectural, pattern, library, or observability decision during this task that downstream work should know about, append it to `.gsd/DECISIONS.md` (use the **Decisions** output template from the inlined templates below if the file doesn't exist yet). Not every task produces decisions — only append when a meaningful choice was made. -13. Use the **Task Summary** output template from the inlined templates below -14. Write `{{taskSummaryPath}}` -15. Mark {{taskId}} done in `{{planPath}}` (change `[ ]` to `[x]`) -16. Do not commit manually — the system auto-commits your changes after this unit completes. -17. Update `.gsd/STATE.md` +13. If you discover a non-obvious rule, recurring gotcha, or useful pattern during execution, append it to `.gsd/KNOWLEDGE.md`. Only add entries that would save future agents from repeating your investigation. Don't add obvious things. +14. Use the **Task Summary** output template from the inlined templates below +15. Write `{{taskSummaryPath}}` +16. Mark {{taskId}} done in `{{planPath}}` (change `[ ]` to `[x]`) +17. Do not commit manually — the system auto-commits your changes after this unit completes. +18. Update `.gsd/STATE.md` All work stays in your working directory: `{{workingDirectory}}`. diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index ed19ce52f..29a640d05 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -65,6 +65,7 @@ Titles live inside file content (headings, frontmatter), not in file or director PROJECT.md (living doc - what the project is right now) 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) 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 @@ -100,6 +101,7 @@ All auto-mode work happens inside a worktree at `.gsd/worktrees//`. This is - **PROJECT.md** is a living document describing what the project is right now - current state only, updated at slice completion when stale - **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. - **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. diff --git a/src/resources/extensions/gsd/templates/knowledge.md b/src/resources/extensions/gsd/templates/knowledge.md new file mode 100644 index 000000000..cf34b867f --- /dev/null +++ b/src/resources/extensions/gsd/templates/knowledge.md @@ -0,0 +1,19 @@ +# Project Knowledge + +Append-only register of project-specific rules, patterns, and lessons learned. +Agents read this before every unit. Add entries when you discover something worth remembering. + +## Rules + +| # | Scope | Rule | Why | Added | +|---|-------|------|-----|-------| + +## Patterns + +| # | Pattern | Where | Notes | +|---|---------|-------|-------| + +## Lessons Learned + +| # | What Happened | Root Cause | Fix | Scope | +|---|--------------|------------|-----|-------| diff --git a/src/resources/extensions/gsd/tests/knowledge.test.ts b/src/resources/extensions/gsd/tests/knowledge.test.ts new file mode 100644 index 000000000..907d43d2b --- /dev/null +++ b/src/resources/extensions/gsd/tests/knowledge.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for KNOWLEDGE.md integration. + * + * Tests: + * - KNOWLEDGE is registered in GSD_ROOT_FILES + * - resolveGsdRootFile resolves KNOWLEDGE paths correctly + * - inlineGsdRootFile works with the KNOWLEDGE key + * - before_agent_start hook includes/omits knowledge block appropriately + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { GSD_ROOT_FILES, resolveGsdRootFile } from '../paths.ts'; +import { inlineGsdRootFile } from '../auto-prompts.ts'; +import { appendKnowledge } from '../files.ts'; + +// ─── KNOWLEDGE is registered in GSD_ROOT_FILES ───────────────────────────── + +test('knowledge: KNOWLEDGE key exists in GSD_ROOT_FILES', () => { + assert.ok('KNOWLEDGE' in GSD_ROOT_FILES, 'GSD_ROOT_FILES should have KNOWLEDGE key'); + assert.strictEqual(GSD_ROOT_FILES.KNOWLEDGE, 'KNOWLEDGE.md'); +}); + +// ─── resolveGsdRootFile resolves KNOWLEDGE.md ─────────────────────────────── + +test('knowledge: resolveGsdRootFile returns canonical path when KNOWLEDGE.md exists', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + writeFileSync(join(gsdDir, 'KNOWLEDGE.md'), '# Project Knowledge\n'); + + const resolved = resolveGsdRootFile(tmp, 'KNOWLEDGE'); + assert.strictEqual(resolved, join(gsdDir, 'KNOWLEDGE.md')); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: resolveGsdRootFile resolves when legacy knowledge.md exists', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + writeFileSync(join(gsdDir, 'knowledge.md'), '# Project Knowledge\n'); + + const resolved = resolveGsdRootFile(tmp, 'KNOWLEDGE'); + // On case-insensitive filesystems (macOS), canonical path matches; + // on case-sensitive (Linux), legacy path matches. Either is valid. + const canonical = join(gsdDir, 'KNOWLEDGE.md'); + const legacy = join(gsdDir, 'knowledge.md'); + assert.ok( + resolved === canonical || resolved === legacy, + `resolved path should be canonical or legacy, got: ${resolved}`, + ); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: resolveGsdRootFile returns canonical path when file does not exist', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + const resolved = resolveGsdRootFile(tmp, 'KNOWLEDGE'); + assert.strictEqual(resolved, join(gsdDir, 'KNOWLEDGE.md')); + + rmSync(tmp, { recursive: true, force: true }); +}); + +// ─── inlineGsdRootFile works with knowledge.md ───────────────────────────── + +test('knowledge: inlineGsdRootFile returns content when KNOWLEDGE.md exists', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + writeFileSync(join(gsdDir, 'KNOWLEDGE.md'), '# Project Knowledge\n\n## Rules\n\nK001: Use real DB'); + + const result = await inlineGsdRootFile(tmp, 'knowledge.md', 'Project Knowledge'); + assert.ok(result !== null, 'should return content'); + assert.ok(result!.includes('Project Knowledge'), 'should include label'); + assert.ok(result!.includes('K001'), 'should include knowledge content'); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: inlineGsdRootFile returns null when KNOWLEDGE.md does not exist', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + const result = await inlineGsdRootFile(tmp, 'knowledge.md', 'Project Knowledge'); + assert.strictEqual(result, null, 'should return null when file does not exist'); + + rmSync(tmp, { recursive: true, force: true }); +}); + +// ─── appendKnowledge creates file and appends entries ────────────────────── + +test('knowledge: appendKnowledge creates KNOWLEDGE.md with rule when file does not exist', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + await appendKnowledge(tmp, 'rule', 'Use real DB for integration tests', 'M001/S01'); + + const content = readFileSync(join(gsdDir, 'KNOWLEDGE.md'), 'utf-8'); + assert.ok(content.includes('# Project Knowledge'), 'should have header'); + assert.ok(content.includes('K001'), 'should have K001 id'); + assert.ok(content.includes('Use real DB for integration tests'), 'should have rule text'); + assert.ok(content.includes('M001/S01'), 'should have scope'); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: appendKnowledge appends to existing KNOWLEDGE.md with auto-incrementing ID', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + // Create initial file with one rule + await appendKnowledge(tmp, 'rule', 'First rule', 'M001'); + // Add second rule + await appendKnowledge(tmp, 'rule', 'Second rule', 'M001/S02'); + + const content = readFileSync(join(gsdDir, 'KNOWLEDGE.md'), 'utf-8'); + assert.ok(content.includes('K001'), 'should have K001'); + assert.ok(content.includes('K002'), 'should have K002'); + assert.ok(content.includes('First rule'), 'should have first rule'); + assert.ok(content.includes('Second rule'), 'should have second rule'); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: appendKnowledge handles pattern type', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + await appendKnowledge(tmp, 'pattern', 'Middleware chain for auth', 'M001'); + + const content = readFileSync(join(gsdDir, 'KNOWLEDGE.md'), 'utf-8'); + assert.ok(content.includes('P001'), 'should have P001 id'); + assert.ok(content.includes('Middleware chain for auth'), 'should have pattern text'); + + rmSync(tmp, { recursive: true, force: true }); +}); + +test('knowledge: appendKnowledge handles lesson type', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-')); + const gsdDir = join(tmp, '.gsd'); + mkdirSync(gsdDir, { recursive: true }); + + await appendKnowledge(tmp, 'lesson', 'API timeout on large payloads', 'M002'); + + const content = readFileSync(join(gsdDir, 'KNOWLEDGE.md'), 'utf-8'); + assert.ok(content.includes('L001'), 'should have L001 id'); + assert.ok(content.includes('API timeout on large payloads'), 'should have lesson text'); + + rmSync(tmp, { recursive: true, force: true }); +});