From ee922cff59bc2015f71ef5085a2a326a4ad9b170 Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Wed, 15 Apr 2026 04:34:56 +0200 Subject: [PATCH 1/2] feat(gsd): add /gsd extract-learnings command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyzes completed milestone artifacts (PLAN.md, SUMMARY.md, and optionally VERIFICATION.md + UAT.md) and dispatches an LLM turn that extracts institutional knowledge into four categories — Decisions, Lessons, Patterns, Surprises — with source attribution per item. Output: .gsd/milestones//-LEARNINGS.md with YAML frontmatter (counts per category, list of missing optional artifacts). Running twice overwrites the previous file. Integrates with capture_thought when available; silently skips if not. New files: - commands-extract-learnings.ts — handler + pure helpers - tests/commands-extract-learnings.test.ts — 32 unit tests (TDD) --- .../gsd/commands-extract-learnings.ts | 296 +++++++++++++++ .../extensions/gsd/commands/handlers/ops.ts | 5 + .../tests/commands-extract-learnings.test.ts | 340 ++++++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 src/resources/extensions/gsd/commands-extract-learnings.ts create mode 100644 src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts diff --git a/src/resources/extensions/gsd/commands-extract-learnings.ts b/src/resources/extensions/gsd/commands-extract-learnings.ts new file mode 100644 index 000000000..634cb3936 --- /dev/null +++ b/src/resources/extensions/gsd/commands-extract-learnings.ts @@ -0,0 +1,296 @@ +/** + * GSD Command — /gsd extract-learnings + * + * Analyses completed milestone artefacts and dispatches an LLM turn that + * extracts structured knowledge into 4 categories: + * Decisions · Lessons · Patterns · Surprises + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync } from "node:fs"; +import { join, basename } from "node:path"; + +import { gsdRoot, resolveMilestonePath } from "./paths.js"; +import { projectRoot } from "./commands/context.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PhaseArtifacts { + plan: string | null; + summary: string | null; + verification: string | null; + uat: string | null; + missingRequired: string[]; +} + +export interface ExtractLearningsPromptContext { + milestoneId: string; + milestoneName: string; + outputPath: string; + relativeOutputPath: string; + planContent: string; + summaryContent: string; + verificationContent: string | null; + uatContent: string | null; + missingArtifacts: string[]; + projectName: string; +} + +export interface FrontmatterContext { + milestoneId: string; + milestoneName: string; + projectName: string; + generatedAt: string; + counts: { + decisions: number; + lessons: number; + patterns: number; + surprises: number; + }; + missingArtifacts: string[]; +} + +// ─── Pure functions ─────────────────────────────────────────────────────────── + +export function parseExtractLearningsArgs(args: string): { milestoneId: string | null } { + const trimmed = args.trim(); + return { milestoneId: trimmed || null }; +} + +export function buildLearningsOutputPath(milestoneDir: string, milestoneId: string): string { + return join(milestoneDir, `${milestoneId}-LEARNINGS.md`); +} + +export function resolvePhaseArtifacts(milestoneDir: string, milestoneId: string): PhaseArtifacts { + const missingRequired: string[] = []; + + const planFile = `${milestoneId}-PLAN.md`; + const summaryFile = `${milestoneId}-SUMMARY.md`; + const verificationFile = `${milestoneId}-VERIFICATION.md`; + const uatFile = `${milestoneId}-UAT.md`; + + const planPath = join(milestoneDir, planFile); + const summaryPath = join(milestoneDir, summaryFile); + const verificationPath = join(milestoneDir, verificationFile); + const uatPath = join(milestoneDir, uatFile); + + const plan = existsSync(planPath) ? planPath : null; + const summary = existsSync(summaryPath) ? summaryPath : null; + const verification = existsSync(verificationPath) ? verificationPath : null; + const uat = existsSync(uatPath) ? uatPath : null; + + if (!plan) missingRequired.push(planFile); + if (!summary) missingRequired.push(summaryFile); + + return { plan, summary, verification, uat, missingRequired }; +} + +export function buildExtractLearningsPrompt(ctx: ExtractLearningsPromptContext): string { + const optionalSections: string[] = []; + + if (ctx.verificationContent) { + optionalSections.push(`## Verification Report\n\n${ctx.verificationContent}`); + } + if (ctx.uatContent) { + optionalSections.push(`## UAT Report\n\n${ctx.uatContent}`); + } + + const missingNote = ctx.missingArtifacts.length > 0 + ? `\nNote: The following optional artefacts were not available: ${ctx.missingArtifacts.join(", ")}\n` + : ""; + + return `# Extract Learnings — ${ctx.milestoneId}: ${ctx.milestoneName} + +**Project:** ${ctx.projectName} +**Output file:** ${ctx.outputPath} + +## Your Task + +Analyse the artefacts below and extract structured knowledge from milestone **${ctx.milestoneId}**. + +Write a LEARNINGS document to \`${ctx.outputPath}\` with the following 4 sections: + +### Decisions +Key architectural and design decisions made during this milestone, including the rationale and alternatives considered. + +### Lessons +What the team learned — technical discoveries, process insights, and knowledge gaps that were filled. + +### Patterns +Reusable patterns, approaches, or solutions that emerged and should be applied in future work. + +### Surprises +Unexpected challenges, discoveries, or outcomes — things that deviated from assumptions. + +### Source Attribution (REQUIRED) + +Every extracted item MUST include a \`Source:\` line immediately after the item text. +Format: \`Source: {artifact-filename}/{section}\` +Example: \`Source: M001-PLAN.md/Architecture Decisions\` + +Items without a Source attribution are invalid and must not be included in the output. + +--- + +## Artefacts + +### Plan + +${ctx.planContent} + +--- + +### Summary + +${ctx.summaryContent} + +${optionalSections.join("\n\n---\n\n")} +${missingNote} +--- + +## Output Format + +Write the LEARNINGS file to \`${ctx.relativeOutputPath}\` with YAML frontmatter followed by the 4 sections above. +Each section should contain concise, actionable bullet points. +Every bullet point MUST be followed by a source line, for example: + +\`\`\` +### Decisions +- Chose PostgreSQL over SQLite for concurrent write support. + Source: M001-PLAN.md/Architecture Decisions +\`\`\` + +Items without a \`Source:\` line are invalid. + +--- + +## Optional: Capture Individual Learnings + +If the \`capture_thought\` tool is available, call it once for each extracted item with: +- category: "decision" | "lesson" | "pattern" | "surprise" +- phase: "${ctx.milestoneId}" +- content: {the learning text} +- source: {artifact filename} + +If \`capture_thought\` is not available, skip this step silently — do not report an error. +`; +} + +export function buildFrontmatter(ctx: FrontmatterContext): string { + const missingList = ctx.missingArtifacts.length > 0 + ? ctx.missingArtifacts.map((a) => ` - ${a}`).join("\n") + : " []"; + + const missingValue = ctx.missingArtifacts.length > 0 + ? `\n${missingList}` + : " []"; + + return `--- +phase: ${ctx.milestoneId} +phase_name: ${ctx.milestoneName} +project: ${ctx.projectName} +generated: ${ctx.generatedAt} +counts: + decisions: ${ctx.counts.decisions} + lessons: ${ctx.counts.lessons} + patterns: ${ctx.counts.patterns} + surprises: ${ctx.counts.surprises} +missing_artifacts:${missingValue} +---`; +} + +export function extractProjectName(basePath: string): string { + const projectMdPath = join(gsdRoot(basePath), "PROJECT.md"); + + if (existsSync(projectMdPath)) { + try { + const content = readFileSync(projectMdPath, "utf-8"); + const match = content.match(/^name:\s*(.+)$/m); + if (match) return match[1].trim(); + } catch { + // non-fatal + } + } + + return basename(basePath); +} + +// ─── Handler ────────────────────────────────────────────────────────────────── + +export async function handleExtractLearnings( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + const { milestoneId } = parseExtractLearningsArgs(args); + + if (!milestoneId) { + ctx.ui.notify("Usage: /gsd extract-learnings (e.g. M001)", "warning"); + return; + } + + // projectRoot() throws GSDNoProjectError if no project found — intentional, handled by dispatcher + const basePath = projectRoot(); + const milestoneDir = resolveMilestonePath(basePath, milestoneId); + + if (!milestoneDir) { + ctx.ui.notify(`Milestone not found: ${milestoneId}`, "error"); + return; + } + + const artifacts = resolvePhaseArtifacts(milestoneDir, milestoneId); + + if (artifacts.missingRequired.length > 0) { + ctx.ui.notify( + `Cannot extract learnings — required artefacts missing: ${artifacts.missingRequired.join(", ")}`, + "error", + ); + return; + } + + // Read required artefacts + const planContent = readFileSync(artifacts.plan!, "utf-8"); + const summaryContent = readFileSync(artifacts.summary!, "utf-8"); + + // Read optional artefacts + const verificationContent = artifacts.verification + ? readFileSync(artifacts.verification, "utf-8") + : null; + const uatContent = artifacts.uat + ? readFileSync(artifacts.uat, "utf-8") + : null; + + // Determine missing optional artefacts for context + const missingArtifacts: string[] = []; + if (!artifacts.verification) missingArtifacts.push(`${milestoneId}-VERIFICATION.md`); + if (!artifacts.uat) missingArtifacts.push(`${milestoneId}-UAT.md`); + + // Extract milestone name from Plan H1 or fall back to milestoneId + const h1Match = planContent.match(/^#\s+(.+)$/m); + const milestoneName = h1Match?.[1]?.trim() ?? milestoneId; + + const projectName = extractProjectName(basePath); + const outputPath = buildLearningsOutputPath(milestoneDir, milestoneId); + const relativeOutputPath = outputPath.replace(basePath + "/", ""); + + const prompt = buildExtractLearningsPrompt({ + milestoneId, + milestoneName, + outputPath, + relativeOutputPath, + planContent, + summaryContent, + verificationContent, + uatContent, + missingArtifacts, + projectName, + }); + + ctx.ui.notify(`Extracting learnings for ${milestoneId}: "${milestoneName}"...`, "info"); + + pi.sendMessage( + { customType: "gsd-extract-learnings", content: prompt, display: false }, + { triggerTurn: true }, + ); +} diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 2e584b117..3a8a2fe19 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -236,5 +236,10 @@ Examples: await handleAddTests(trimmed.replace(/^add-tests\s*/, "").trim(), ctx, pi); return true; } + if (trimmed === "extract-learnings" || trimmed.startsWith("extract-learnings ")) { + const { handleExtractLearnings } = await import("../../commands-extract-learnings.js"); + await handleExtractLearnings(trimmed.replace(/^extract-learnings\s*/, "").trim(), ctx, pi); + return true; + } return false; } diff --git a/src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts b/src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts new file mode 100644 index 000000000..de148c7d5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/commands-extract-learnings.test.ts @@ -0,0 +1,340 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + parseExtractLearningsArgs, + buildLearningsOutputPath, + resolvePhaseArtifacts, + buildExtractLearningsPrompt, + buildFrontmatter, + extractProjectName, +} from "../commands-extract-learnings.js"; + +// ─── parseExtractLearningsArgs ──────────────────────────────────────────────── + +describe("parseExtractLearningsArgs", () => { + it("parses a milestone ID", () => { + const result = parseExtractLearningsArgs("M001"); + assert.deepEqual(result, { milestoneId: "M001" }); + }); + + it("returns null milestoneId for empty string", () => { + const result = parseExtractLearningsArgs(""); + assert.deepEqual(result, { milestoneId: null }); + }); + + it("returns null milestoneId for whitespace-only string", () => { + const result = parseExtractLearningsArgs(" "); + assert.deepEqual(result, { milestoneId: null }); + }); + + it("trims whitespace from milestone ID", () => { + const result = parseExtractLearningsArgs(" M002 "); + assert.deepEqual(result, { milestoneId: "M002" }); + }); +}); + +// ─── buildLearningsOutputPath ───────────────────────────────────────────────── + +describe("buildLearningsOutputPath", () => { + it("builds the correct output path", () => { + const result = buildLearningsOutputPath("/base/.gsd/milestones/M001", "M001"); + assert.equal(result, "/base/.gsd/milestones/M001/M001-LEARNINGS.md"); + }); + + it("builds path for different milestone ID", () => { + const result = buildLearningsOutputPath("/project/.gsd/milestones/M005", "M005"); + assert.equal(result, "/project/.gsd/milestones/M005/M005-LEARNINGS.md"); + }); +}); + +// ─── resolvePhaseArtifacts ──────────────────────────────────────────────────── + +describe("resolvePhaseArtifacts", () => { + let tmpBase: string; + + beforeEach(() => { + tmpBase = join(tmpdir(), `gsd-learnings-test-${randomUUID()}`); + mkdirSync(tmpBase, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("finds required PLAN and SUMMARY when both present", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# M001 Plan content", "utf-8"); + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# M001 Summary content", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.plan, join(tmpBase, "M001-PLAN.md")); + assert.equal(result.summary, join(tmpBase, "M001-SUMMARY.md")); + assert.deepEqual(result.missingRequired, []); + }); + + it("reports missing PLAN as missingRequired", () => { + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# Summary", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.ok(result.missingRequired.includes("M001-PLAN.md")); + assert.equal(result.plan, null); + }); + + it("reports missing SUMMARY as missingRequired", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# Plan", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.ok(result.missingRequired.includes("M001-SUMMARY.md")); + assert.equal(result.summary, null); + }); + + it("reports both required files missing when neither present", () => { + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.missingRequired.length, 2); + assert.ok(result.missingRequired.includes("M001-PLAN.md")); + assert.ok(result.missingRequired.includes("M001-SUMMARY.md")); + }); + + it("finds optional VERIFICATION when present", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# Plan", "utf-8"); + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# Summary", "utf-8"); + writeFileSync(join(tmpBase, "M001-VERIFICATION.md"), "# Verification", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.verification, join(tmpBase, "M001-VERIFICATION.md")); + }); + + it("returns null for optional VERIFICATION when absent", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# Plan", "utf-8"); + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# Summary", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.verification, null); + }); + + it("finds optional UAT when present", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# Plan", "utf-8"); + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# Summary", "utf-8"); + writeFileSync(join(tmpBase, "M001-UAT.md"), "# UAT", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.uat, join(tmpBase, "M001-UAT.md")); + }); + + it("returns null for optional UAT when absent, no error", () => { + writeFileSync(join(tmpBase, "M001-PLAN.md"), "# Plan", "utf-8"); + writeFileSync(join(tmpBase, "M001-SUMMARY.md"), "# Summary", "utf-8"); + + const result = resolvePhaseArtifacts(tmpBase, "M001"); + assert.equal(result.uat, null); + assert.deepEqual(result.missingRequired, []); + }); +}); + +// ─── buildExtractLearningsPrompt ────────────────────────────────────────────── + +describe("buildExtractLearningsPrompt", () => { + it("includes milestoneId and outputPath", () => { + const result = buildExtractLearningsPrompt({ + milestoneId: "M001", + milestoneName: "Test Milestone", + outputPath: "/project/.gsd/milestones/M001/M001-LEARNINGS.md", + relativeOutputPath: ".gsd/milestones/M001/M001-LEARNINGS.md", + planContent: "# Plan content", + summaryContent: "# Summary content", + verificationContent: null, + uatContent: null, + missingArtifacts: [], + projectName: "MyProject", + }); + + assert.ok(result.includes("M001")); + assert.ok(result.includes("/project/.gsd/milestones/M001/M001-LEARNINGS.md")); + }); + + it("includes all 4 learning categories", () => { + const result = buildExtractLearningsPrompt({ + milestoneId: "M001", + milestoneName: "Test Milestone", + outputPath: "/out/M001-LEARNINGS.md", + relativeOutputPath: ".gsd/milestones/M001/M001-LEARNINGS.md", + planContent: "# Plan", + summaryContent: "# Summary", + verificationContent: null, + uatContent: null, + missingArtifacts: [], + projectName: "MyProject", + }); + + assert.ok(result.includes("Decisions")); + assert.ok(result.includes("Lessons")); + assert.ok(result.includes("Patterns")); + assert.ok(result.includes("Surprises")); + }); + + it("includes plan and summary content", () => { + const result = buildExtractLearningsPrompt({ + milestoneId: "M001", + milestoneName: "Test Milestone", + outputPath: "/out/M001-LEARNINGS.md", + relativeOutputPath: ".gsd/milestones/M001/M001-LEARNINGS.md", + planContent: "PLAN_CONTENT_UNIQUE_123", + summaryContent: "SUMMARY_CONTENT_UNIQUE_456", + verificationContent: null, + uatContent: null, + missingArtifacts: [], + projectName: "MyProject", + }); + + assert.ok(result.includes("PLAN_CONTENT_UNIQUE_123")); + assert.ok(result.includes("SUMMARY_CONTENT_UNIQUE_456")); + }); + + it("includes optional artifacts when present", () => { + const result = buildExtractLearningsPrompt({ + milestoneId: "M001", + milestoneName: "Test Milestone", + outputPath: "/out/M001-LEARNINGS.md", + relativeOutputPath: ".gsd/milestones/M001/M001-LEARNINGS.md", + planContent: "# Plan", + summaryContent: "# Summary", + verificationContent: "VERIFICATION_UNIQUE_789", + uatContent: "UAT_UNIQUE_012", + missingArtifacts: [], + projectName: "MyProject", + }); + + assert.ok(result.includes("VERIFICATION_UNIQUE_789")); + assert.ok(result.includes("UAT_UNIQUE_012")); + }); + + it("lists missing artifacts when present", () => { + const result = buildExtractLearningsPrompt({ + milestoneId: "M001", + milestoneName: "Test Milestone", + outputPath: "/out/M001-LEARNINGS.md", + relativeOutputPath: ".gsd/milestones/M001/M001-LEARNINGS.md", + planContent: "# Plan", + summaryContent: "# Summary", + verificationContent: null, + uatContent: null, + missingArtifacts: ["M001-VERIFICATION.md"], + projectName: "MyProject", + }); + + assert.ok(result.includes("M001-VERIFICATION.md")); + }); +}); + +// ─── buildFrontmatter ───────────────────────────────────────────────────────── + +describe("buildFrontmatter", () => { + it("starts with --- and ends with ---", () => { + const result = buildFrontmatter({ + milestoneId: "M001", + milestoneName: "Test Milestone", + projectName: "MyProject", + generatedAt: "2026-04-15T10:00:00Z", + counts: { decisions: 0, lessons: 0, patterns: 0, surprises: 0 }, + missingArtifacts: [], + }); + + assert.ok(result.startsWith("---\n")); + assert.ok(result.endsWith("---")); + }); + + it("includes required fields", () => { + const result = buildFrontmatter({ + milestoneId: "M001", + milestoneName: "Test Milestone", + projectName: "MyProject", + generatedAt: "2026-04-15T10:00:00Z", + counts: { decisions: 3, lessons: 2, patterns: 1, surprises: 0 }, + missingArtifacts: [], + }); + + assert.ok(result.includes("phase:")); + assert.ok(result.includes("phase_name:")); + assert.ok(result.includes("project:")); + assert.ok(result.includes("generated:")); + assert.ok(result.includes("counts:")); + assert.ok(result.includes("missing_artifacts:")); + }); + + it("includes milestoneId as phase value", () => { + const result = buildFrontmatter({ + milestoneId: "M001", + milestoneName: "Auth System", + projectName: "MyApp", + generatedAt: "2026-04-15T10:00:00Z", + counts: { decisions: 0, lessons: 0, patterns: 0, surprises: 0 }, + missingArtifacts: [], + }); + + assert.ok(result.includes("M001")); + assert.ok(result.includes("Auth System")); + assert.ok(result.includes("MyApp")); + assert.ok(result.includes("2026-04-15T10:00:00Z")); + }); + + it("includes missing artifacts list", () => { + const result = buildFrontmatter({ + milestoneId: "M001", + milestoneName: "Test", + projectName: "Proj", + generatedAt: "2026-04-15T10:00:00Z", + counts: { decisions: 0, lessons: 0, patterns: 0, surprises: 0 }, + missingArtifacts: ["M001-VERIFICATION.md", "M001-UAT.md"], + }); + + assert.ok(result.includes("M001-VERIFICATION.md")); + assert.ok(result.includes("M001-UAT.md")); + }); +}); + +// ─── extractProjectName ─────────────────────────────────────────────────────── + +describe("extractProjectName", () => { + let tmpBase: string; + + beforeEach(() => { + tmpBase = join(tmpdir(), `gsd-projname-test-${randomUUID()}`); + mkdirSync(join(tmpBase, ".gsd"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("reads name from PROJECT.md frontmatter", () => { + writeFileSync( + join(tmpBase, ".gsd", "PROJECT.md"), + "---\nname: My Cool Project\nversion: 1\n---\n# Project\n", + "utf-8", + ); + + const result = extractProjectName(tmpBase); + assert.equal(result, "My Cool Project"); + }); + + it("falls back to directory name when PROJECT.md absent", () => { + const result = extractProjectName(tmpBase); + // Should return the last path segment of tmpBase + assert.equal(result, tmpBase.split("/").at(-1)); + }); + + it("falls back to directory name when PROJECT.md has no name field", () => { + writeFileSync( + join(tmpBase, ".gsd", "PROJECT.md"), + "---\nversion: 1\n---\n# Project\n", + "utf-8", + ); + + const result = extractProjectName(tmpBase); + assert.equal(result, tmpBase.split("/").at(-1)); + }); +}); From 73916a8c38ed87c09ab22f92782fc71a53ecf9df Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Wed, 15 Apr 2026 04:52:52 +0200 Subject: [PATCH 2/2] feat(graph): parse LEARNINGS.md into knowledge graph and rebuild after extraction --- packages/mcp-server/src/readers/graph.test.ts | 178 ++++++++++++++++++ packages/mcp-server/src/readers/graph.ts | 149 ++++++++++++++- .../gsd/commands-extract-learnings.ts | 8 + 3 files changed, 334 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/readers/graph.test.ts b/packages/mcp-server/src/readers/graph.test.ts index bc329c570..456dd4be4 100644 --- a/packages/mcp-server/src/readers/graph.test.ts +++ b/packages/mcp-server/src/readers/graph.test.ts @@ -98,6 +98,49 @@ function makeProjectWithArtifacts(projectDir: string): void { ].join('\n')); } +// --------------------------------------------------------------------------- +// LEARNINGS.md fixture helpers +// --------------------------------------------------------------------------- + +function writeLearningsFixture(projectDir: string, milestoneId: string, content: string): void { + writeFixture(projectDir, `.gsd/milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`, content); +} + +const SAMPLE_LEARNINGS = `--- +phase: "M001" +phase_name: "User Auth" +project: "my-project" +generated: "2026-04-15T10:00:00Z" +counts: + decisions: 2 + lessons: 1 + patterns: 1 + surprises: 1 +missing_artifacts: [] +--- + +# Learnings: User Auth + +## Decisions +- Use JWT for stateless auth across services. + Source: M001-PLAN.md/Architecture + +- Store refresh tokens in HTTP-only cookies only. + Source: M001-PLAN.md/Security + +## Lessons +- Integration tests need a real DB — mocks missed migration bugs. + Source: M001-SUMMARY.md/Testing + +## Patterns +- Repository pattern abstracts DB access and simplifies testing. + Source: M001-PLAN.md/Design + +## Surprises +- Token expiry edge case caused silent auth failures in prod. + Source: M001-SUMMARY.md/Issues +`; + // --------------------------------------------------------------------------- // buildGraph tests // --------------------------------------------------------------------------- @@ -162,6 +205,141 @@ describe('buildGraph', () => { }); }); +// --------------------------------------------------------------------------- +// buildGraph — LEARNINGS.md parsing tests +// --------------------------------------------------------------------------- + +describe('buildGraph — LEARNINGS.md parsing', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = tmpProject(); + // Create minimal milestone directory so parseMilestoneFiles finds it + mkdirSync(join(projectDir, '.gsd', 'milestones', 'M001'), { recursive: true }); + writeLearningsFixture(projectDir, 'M001', SAMPLE_LEARNINGS); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + it('extracts decision nodes from ## Decisions section', async () => { + const graph = await buildGraph(projectDir); + const decisions = graph.nodes.filter((n) => n.type === 'decision' || (n.type === 'rule' && n.id.startsWith('decision:'))); + // Decisions should be extracted with a 'decision' type (or similar existing type) + const decisionNodes = graph.nodes.filter((n) => n.id.includes('decision:M001')); + assert.ok(decisionNodes.length >= 2, `Expected >= 2 decision nodes, got ${decisionNodes.length}`); + }); + + it('extracts lesson nodes from ## Lessons section', async () => { + const graph = await buildGraph(projectDir); + const lessonNodes = graph.nodes.filter((n) => n.id.includes('lesson:M001')); + assert.ok(lessonNodes.length >= 1, `Expected >= 1 lesson node, got ${lessonNodes.length}`); + assert.ok(lessonNodes.every((n) => n.type === 'lesson'), 'All lesson nodes must have type "lesson"'); + }); + + it('extracts pattern nodes from ## Patterns section', async () => { + const graph = await buildGraph(projectDir); + const patternNodes = graph.nodes.filter((n) => n.id.includes('pattern:M001')); + assert.ok(patternNodes.length >= 1, `Expected >= 1 pattern node, got ${patternNodes.length}`); + assert.ok(patternNodes.every((n) => n.type === 'pattern'), 'All pattern nodes must have type "pattern"'); + }); + + it('maps surprises to lesson nodes', async () => { + const graph = await buildGraph(projectDir); + // Surprises should be mapped to lesson type since no "surprise" NodeType exists + const surpriseNodes = graph.nodes.filter((n) => n.id.includes('surprise:M001')); + assert.ok(surpriseNodes.length >= 1, `Expected >= 1 surprise node, got ${surpriseNodes.length}`); + assert.ok(surpriseNodes.every((n) => n.type === 'lesson'), 'Surprises must be mapped to type "lesson"'); + }); + + it('node labels contain the learning text', async () => { + const graph = await buildGraph(projectDir); + const hasJwtDecision = graph.nodes.some((n) => + n.label.toLowerCase().includes('jwt') || n.description?.toLowerCase().includes('jwt'), + ); + assert.ok(hasJwtDecision, 'Expected a node describing the JWT decision'); + }); + + it('node description includes source attribution', async () => { + const graph = await buildGraph(projectDir); + const learningNodes = graph.nodes.filter((n) => + n.id.includes(':M001:') || n.id.match(/:(decision|lesson|pattern|surprise):M001/), + ); + const withSource = learningNodes.filter((n) => n.description?.includes('Source:') || n.description?.includes('M001-PLAN')); + assert.ok(withSource.length > 0, 'Expected at least one node with source attribution in description'); + }); + + it('adds relates_to edge from learning node to milestone node', async () => { + const graph = await buildGraph(projectDir); + const edgesToMilestone = graph.edges.filter( + (e) => e.to === 'milestone:M001' || e.from === 'milestone:M001', + ); + // At least one learning node should relate to the milestone + const learningEdges = graph.edges.filter( + (e) => (e.from.includes('M001') && (e.type === 'relates_to' || e.type === 'contains')) || + (e.to.includes('M001') && e.type === 'relates_to'), + ); + assert.ok(learningEdges.length > 0 || edgesToMilestone.length > 0, + 'Expected edges connecting learning nodes to milestone'); + }); + + it('skips LEARNINGS.md gracefully when file is malformed', async () => { + const badProject = tmpProject(); + mkdirSync(join(badProject, '.gsd', 'milestones', 'M002'), { recursive: true }); + writeLearningsFixture(badProject, 'M002', '\0\0\0 not valid yaml or markdown \0\0\0'); + // Must not throw + const graph = await buildGraph(badProject); + assert.ok(graph.nodes.length >= 0); + assert.equal(typeof graph.builtAt, 'string'); + rmSync(badProject, { recursive: true, force: true }); + }); + + it('produces no learning nodes when all sections are empty', async () => { + const emptyProject = tmpProject(); + mkdirSync(join(emptyProject, '.gsd', 'milestones', 'M003'), { recursive: true }); + writeLearningsFixture(emptyProject, 'M003', `--- +phase: "M003" +phase_name: "Empty" +project: "test" +generated: "2026-04-15T10:00:00Z" +counts: + decisions: 0 + lessons: 0 + patterns: 0 + surprises: 0 +missing_artifacts: [] +--- + +# Learnings: Empty + +## Decisions + +## Lessons + +## Patterns + +## Surprises +`); + const graph = await buildGraph(emptyProject); + const learningNodes = graph.nodes.filter((n) => + n.id.includes('decision:M003') || + n.id.includes('lesson:M003') || + n.id.includes('pattern:M003') || + n.id.includes('surprise:M003'), + ); + assert.equal(learningNodes.length, 0, 'Empty sections should produce no nodes'); + rmSync(emptyProject, { recursive: true, force: true }); + }); + + it('does not crash when LEARNINGS.md is missing entirely', async () => { + const noLearningsProject = tmpProject(); + mkdirSync(join(noLearningsProject, '.gsd', 'milestones', 'M004'), { recursive: true }); + // No LEARNINGS.md file written + const graph = await buildGraph(noLearningsProject); + assert.ok(graph.nodes.length >= 0); + rmSync(noLearningsProject, { recursive: true, force: true }); + }); +}); + // --------------------------------------------------------------------------- // writeGraph tests // --------------------------------------------------------------------------- diff --git a/packages/mcp-server/src/readers/graph.ts b/packages/mcp-server/src/readers/graph.ts index 8c6c9d4c0..0867a5935 100644 --- a/packages/mcp-server/src/readers/graph.ts +++ b/packages/mcp-server/src/readers/graph.ts @@ -27,7 +27,8 @@ export type NodeType = | 'rule' | 'pattern' | 'lesson' - | 'concept'; + | 'concept' + | 'decision'; export type EdgeType = | 'contains' @@ -386,6 +387,151 @@ function parseTasksFromPlan( } } +// --------------------------------------------------------------------------- +// LEARNINGS.md parser +// --------------------------------------------------------------------------- + +/** + * Parse all *-LEARNINGS.md files found in milestone directories. + * Extracts Decisions, Lessons, Patterns, and Surprises as typed graph nodes. + * Surprises are mapped to the 'lesson' NodeType (no distinct type exists). + * Parse errors per file are caught — the file is skipped, never rethrows. + */ +function parseLearningsFiles(gsdRoot: string, nodes: GraphNode[], edges: GraphEdge[]): void { + const milestoneIds = findMilestoneIds(gsdRoot); + + for (const milestoneId of milestoneIds) { + try { + parseSingleLearningsFile(gsdRoot, milestoneId, nodes, edges); + } catch { + // Skip this milestone's LEARNINGS.md on any error + } + } +} + +function parseSingleLearningsFile( + gsdRoot: string, + milestoneId: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + const mDir = resolveMilestoneDir(gsdRoot, milestoneId); + if (!mDir) return; + + const learningsPath = join(mDir, `${milestoneId}-LEARNINGS.md`); + if (!existsSync(learningsPath)) return; + + let content: string; + try { + content = readFileSync(learningsPath, 'utf-8'); + } catch { + return; + } + + // Strip YAML frontmatter if present + const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n?/, ''); + + const milestoneNodeId = `milestone:${milestoneId}`; + const sourceFile = `milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`; + + // Parse each section: [sectionName, nodeType, idPrefix] + const sections: Array<[string, NodeType, string]> = [ + ['Decisions', 'decision', 'decision'], + ['Lessons', 'lesson', 'lesson'], + ['Patterns', 'pattern', 'pattern'], + ['Surprises', 'lesson', 'surprise'], + ]; + + for (const [sectionName, nodeType, idPrefix] of sections) { + const sectionMatch = withoutFrontmatter.match( + new RegExp(`##\\s+${sectionName}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i'), + ); + if (!sectionMatch) continue; + + const sectionContent = sectionMatch[1]; + parseLearningsSection( + sectionContent, + milestoneId, + idPrefix, + nodeType, + milestoneNodeId, + sourceFile, + nodes, + edges, + ); + } +} + +function parseLearningsSection( + sectionContent: string, + milestoneId: string, + idPrefix: string, + nodeType: NodeType, + milestoneNodeId: string, + sourceFile: string, + nodes: GraphNode[], + edges: GraphEdge[], +): void { + // Each item is a bullet line starting with "- " followed by optional + // indented "Source: ..." line. + // We collect bullet items and their associated source attribution. + const lines = sectionContent.split('\n'); + let itemIndex = 0; + let currentText: string | null = null; + let currentSource: string | null = null; + + const flushItem = (): void => { + if (!currentText) return; + itemIndex += 1; + const nodeId = `${idPrefix}:${milestoneId}:${itemIndex}`; + const description = currentSource ? `${currentSource}` : undefined; + + nodes.push({ + id: nodeId, + label: currentText, + type: nodeType, + description, + confidence: 'EXTRACTED', + sourceFile, + }); + + // Edge: milestone relates_to this learning node + edges.push({ + from: milestoneNodeId, + to: nodeId, + type: 'relates_to', + confidence: 'EXTRACTED', + }); + + currentText = null; + currentSource = null; + }; + + for (const line of lines) { + const bulletMatch = line.match(/^[-*]\s+(.+)/); + if (bulletMatch) { + flushItem(); + currentText = bulletMatch[1].trim(); + continue; + } + + // Indented source attribution: " Source: ..." + const sourceMatch = line.match(/^\s+Source:\s+(.+)/i); + if (sourceMatch && currentText !== null) { + currentSource = `Source: ${sourceMatch[1].trim()}`; + continue; + } + + // Continuation of current item text (indented non-source line) + const continuationMatch = line.match(/^\s{2,}(.+)/); + if (continuationMatch && currentText !== null && currentSource === null) { + currentText += ' ' + continuationMatch[1].trim(); + } + } + + flushItem(); +} + // --------------------------------------------------------------------------- // buildGraph // --------------------------------------------------------------------------- @@ -407,6 +553,7 @@ export async function buildGraph(projectDir: string): Promise { parseStateFile, parseKnowledgeFile, parseMilestoneFiles, + parseLearningsFiles, ]; for (const parser of parsers) { diff --git a/src/resources/extensions/gsd/commands-extract-learnings.ts b/src/resources/extensions/gsd/commands-extract-learnings.ts index 634cb3936..de23d5422 100644 --- a/src/resources/extensions/gsd/commands-extract-learnings.ts +++ b/src/resources/extensions/gsd/commands-extract-learnings.ts @@ -174,6 +174,14 @@ If the \`capture_thought\` tool is available, call it once for each extracted it - source: {artifact filename} If \`capture_thought\` is not available, skip this step silently — do not report an error. + +--- + +## Rebuild Knowledge Graph + +After writing LEARNINGS.md, call the \`gsd_graph\` tool with \`{ "mode": "build" }\` to rebuild the knowledge graph so the new learnings are immediately queryable by future milestone prompts. + +If the \`gsd_graph\` tool is not available, skip this step silently. `; }