feat(system-context): inject global ~/.gsd/agent/KNOWLEDGE.md into system prompt (#2331)

* feat(system-context): inject global ~/.gsd/agent/KNOWLEDGE.md into system prompt

Reads ~/.gsd/agent/KNOWLEDGE.md (global) alongside the existing project
.gsd/KNOWLEDGE.md and merges both into the [KNOWLEDGE] block. Global
section appears first so project entries can override or refine global
rules. Emits a startup warning when the global file exceeds 4 KB to
keep system prompt size in check.

Extracted loading logic into loadKnowledgeBlock() for testability.
Five new unit tests cover: empty state, project-only, global-only,
merged order, and size threshold.

Closes #2316

* fix(test): relax derive-state-db perf threshold from 1ms to 10ms

The <1ms assertion was intermittently failing on loaded CI runners
(observed: 1.054ms). 10ms still validates the in-memory cache path
is fast while being robust across shared CI environments.

---------

Co-authored-by: TÂCHES <afromanguy@me.com>
This commit is contained in:
Jeremy McSpadden 2026-03-24 23:03:00 -05:00 committed by GitHub
parent cace21cb02
commit e0b3bad2a5
2 changed files with 137 additions and 11 deletions

View file

@ -64,17 +64,12 @@ export async function buildBeforeAgentStartResult(
}
}
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 {
// skip
}
const { block: knowledgeBlock, globalSizeKb } = loadKnowledgeBlock(gsdHome, process.cwd());
if (globalSizeKb > 4) {
ctx.ui.notify(
`GSD: ~/.gsd/agent/KNOWLEDGE.md is ${globalSizeKb.toFixed(1)}KB — consider trimming to keep system prompt lean.`,
"warning",
);
}
let memoryBlock = "";
@ -126,6 +121,48 @@ export async function buildBeforeAgentStartResult(
};
}
export function loadKnowledgeBlock(gsdHomeDir: string, cwd: string): { block: string; globalSizeKb: number } {
// 1. Global knowledge (~/.gsd/agent/KNOWLEDGE.md) — cross-project, user-maintained
let globalKnowledge = "";
let globalSizeKb = 0;
const globalKnowledgePath = join(gsdHomeDir, "agent", "KNOWLEDGE.md");
if (existsSync(globalKnowledgePath)) {
try {
const content = readFileSync(globalKnowledgePath, "utf-8").trim();
if (content) {
globalSizeKb = Buffer.byteLength(content, "utf-8") / 1024;
globalKnowledge = content;
}
} catch {
// skip
}
}
// 2. Project knowledge (.gsd/KNOWLEDGE.md) — project-specific
let projectKnowledge = "";
const knowledgePath = resolveGsdRootFile(cwd, "KNOWLEDGE");
if (existsSync(knowledgePath)) {
try {
const content = readFileSync(knowledgePath, "utf-8").trim();
if (content) projectKnowledge = content;
} catch {
// skip
}
}
if (!globalKnowledge && !projectKnowledge) {
return { block: "", globalSizeKb: 0 };
}
const parts: string[] = [];
if (globalKnowledge) parts.push(`## Global Knowledge\n\n${globalKnowledge}`);
if (projectKnowledge) parts.push(`## Project Knowledge\n\n${projectKnowledge}`);
return {
block: `\n\n[KNOWLEDGE — Rules, patterns, and lessons learned]\n\n${parts.join("\n\n")}`,
globalSizeKb,
};
}
function buildWorktreeContextBlock(): string {
const worktreeName = getActiveWorktreeName();
const worktreeMainCwd = getWorktreeOriginalCwd();

View file

@ -6,6 +6,7 @@
* - resolveGsdRootFile resolves KNOWLEDGE paths correctly
* - inlineGsdRootFile works with the KNOWLEDGE key
* - before_agent_start hook includes/omits knowledge block appropriately
* - loadKnowledgeBlock merges global and project knowledge correctly
*/
import test from 'node:test';
@ -16,6 +17,7 @@ import { tmpdir } from 'node:os';
import { GSD_ROOT_FILES, resolveGsdRootFile } from '../paths.ts';
import { inlineGsdRootFile } from '../auto-prompts.ts';
import { appendKnowledge } from '../files.ts';
import { loadKnowledgeBlock } from '../bootstrap/system-context.ts';
// ─── KNOWLEDGE is registered in GSD_ROOT_FILES ─────────────────────────────
@ -159,3 +161,90 @@ test('knowledge: appendKnowledge handles lesson type', async () => {
rmSync(tmp, { recursive: true, force: true });
});
// ─── loadKnowledgeBlock — global + project merge ────────────────────────────
test('loadKnowledgeBlock: returns empty block when neither file exists', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-')));
const gsdHome = join(tmp, 'home');
const cwd = join(tmp, 'project');
mkdirSync(join(cwd, '.gsd'), { recursive: true });
mkdirSync(join(gsdHome, 'agent'), { recursive: true });
const result = loadKnowledgeBlock(gsdHome, cwd);
assert.strictEqual(result.block, '');
assert.strictEqual(result.globalSizeKb, 0);
rmSync(tmp, { recursive: true, force: true });
});
test('loadKnowledgeBlock: uses project knowledge alone when no global file', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-')));
const gsdHome = join(tmp, 'home');
const cwd = join(tmp, 'project');
mkdirSync(join(cwd, '.gsd'), { recursive: true });
mkdirSync(join(gsdHome, 'agent'), { recursive: true });
writeFileSync(join(cwd, '.gsd', 'KNOWLEDGE.md'), 'K001: Use real DB');
const result = loadKnowledgeBlock(gsdHome, cwd);
assert.ok(result.block.includes('[KNOWLEDGE — Rules, patterns, and lessons learned]'));
assert.ok(result.block.includes('## Project Knowledge'));
assert.ok(result.block.includes('K001: Use real DB'));
assert.ok(!result.block.includes('## Global Knowledge'));
assert.strictEqual(result.globalSizeKb, 0);
rmSync(tmp, { recursive: true, force: true });
});
test('loadKnowledgeBlock: uses global knowledge alone when no project file', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-')));
const gsdHome = join(tmp, 'home');
const cwd = join(tmp, 'project');
mkdirSync(join(cwd, '.gsd'), { recursive: true });
mkdirSync(join(gsdHome, 'agent'), { recursive: true });
writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'G001: Respond in English');
const result = loadKnowledgeBlock(gsdHome, cwd);
assert.ok(result.block.includes('[KNOWLEDGE — Rules, patterns, and lessons learned]'));
assert.ok(result.block.includes('## Global Knowledge'));
assert.ok(result.block.includes('G001: Respond in English'));
assert.ok(!result.block.includes('## Project Knowledge'));
assert.ok(result.globalSizeKb > 0);
rmSync(tmp, { recursive: true, force: true });
});
test('loadKnowledgeBlock: merges global before project when both exist', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-')));
const gsdHome = join(tmp, 'home');
const cwd = join(tmp, 'project');
mkdirSync(join(cwd, '.gsd'), { recursive: true });
mkdirSync(join(gsdHome, 'agent'), { recursive: true });
writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'G001: Global rule');
writeFileSync(join(cwd, '.gsd', 'KNOWLEDGE.md'), 'K001: Project rule');
const result = loadKnowledgeBlock(gsdHome, cwd);
assert.ok(result.block.includes('## Global Knowledge'));
assert.ok(result.block.includes('## Project Knowledge'));
assert.ok(result.block.includes('G001: Global rule'));
assert.ok(result.block.includes('K001: Project rule'));
// Global section appears before project section
assert.ok(result.block.indexOf('## Global Knowledge') < result.block.indexOf('## Project Knowledge'));
rmSync(tmp, { recursive: true, force: true });
});
test('loadKnowledgeBlock: reports globalSizeKb above 4KB threshold', () => {
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-kb-')));
const gsdHome = join(tmp, 'home');
const cwd = join(tmp, 'project');
mkdirSync(join(cwd, '.gsd'), { recursive: true });
mkdirSync(join(gsdHome, 'agent'), { recursive: true });
// Write > 4KB of content
writeFileSync(join(gsdHome, 'agent', 'KNOWLEDGE.md'), 'x'.repeat(5000));
const result = loadKnowledgeBlock(gsdHome, cwd);
assert.ok(result.globalSizeKb > 4, `expected > 4KB, got ${result.globalSizeKb}`);
rmSync(tmp, { recursive: true, force: true });
});