feat(gsd): add /gsd extract-learnings command
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/<id>/<id>-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)
This commit is contained in:
parent
c63f801412
commit
ee922cff59
3 changed files with 641 additions and 0 deletions
296
src/resources/extensions/gsd/commands-extract-learnings.ts
Normal file
296
src/resources/extensions/gsd/commands-extract-learnings.ts
Normal file
|
|
@ -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<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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue