Merge pull request #4227 from NilsR0711/feat/gsd-extract-learnings

feat(gsd): add /gsd extract-learnings command
This commit is contained in:
Jeremy McSpadden 2026-04-14 22:33:37 -05:00 committed by GitHub
commit 3fb1bef6d8
5 changed files with 975 additions and 1 deletions

View file

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

View file

@ -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<KnowledgeGraph> {
parseStateFile,
parseKnowledgeFile,
parseMilestoneFiles,
parseLearningsFiles,
];
for (const parser of parsers) {

View file

@ -0,0 +1,304 @@
/**
* 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.
---
## 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.
`;
}
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<void> {
const { milestoneId } = parseExtractLearningsArgs(args);
if (!milestoneId) {
ctx.ui.notify("Usage: /gsd extract-learnings <milestoneId> (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 },
);
}

View file

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

View file

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