feat: add .gsd/KNOWLEDGE.md — persistent project-specific context (#585)

This commit is contained in:
Flux Labs 2026-03-16 07:15:18 -05:00 committed by GitHub
parent e5244658b3
commit a9b14dc181
10 changed files with 399 additions and 13 deletions

View file

@ -89,7 +89,7 @@ export async function inlineDependencySummaries(
export async function inlineGsdRootFile(
base: string, filename: string, label: string,
): Promise<string | null> {
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")}`;

View file

@ -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 <rule|pattern|lesson> <description>. 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 <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>.`,
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
"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<void> {
const parts = args.split(/\s+/);
const typeArg = parts[0]?.toLowerCase();
if (!typeArg || !["rule", "pattern", "lesson"].includes(typeArg)) {
ctx.ui.notify(
"Usage: /gsd knowledge <rule|pattern|lesson> <description>\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} <description>`, "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<void> {
const basePath = process.cwd();
const state = await deriveState(basePath);

View file

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

View file

@ -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<void> {
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<Override[]> {
const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES");
const content = await loadFile(overridesPath);

View file

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

View file

@ -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<GSDRootFileKey, string> = {
STATE: "state.md",
REQUIREMENTS: "requirements.md",
OVERRIDES: "overrides.md",
KNOWLEDGE: "knowledge.md",
};
export function gsdRoot(basePath: string): string {

View file

@ -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}}`.

View file

@ -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/<MID>/`. 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.

View file

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

View file

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