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