diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index a4ddb3fa2..e6cb15dd3 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -1534,6 +1534,26 @@ export function insertVerificationEvidence(e: { }); } +export interface VerificationEvidenceRow { + id: number; + task_id: string; + slice_id: string; + milestone_id: string; + command: string; + exit_code: number; + verdict: string; + duration_ms: number; + created_at: string; +} + +export function getVerificationEvidence(milestoneId: string, sliceId: string, taskId: string): VerificationEvidenceRow[] { + if (!currentDb) return []; + const rows = currentDb.prepare( + "SELECT * FROM verification_evidence WHERE milestone_id = :mid AND slice_id = :sid AND task_id = :tid ORDER BY id", + ).all({ ":mid": milestoneId, ":sid": sliceId, ":tid": taskId }); + return rows as unknown as VerificationEvidenceRow[]; +} + export interface MilestoneRow { id: string; title: string; diff --git a/src/resources/extensions/gsd/tests/summary-render-parity.test.ts b/src/resources/extensions/gsd/tests/summary-render-parity.test.ts new file mode 100644 index 000000000..ffd4fc955 --- /dev/null +++ b/src/resources/extensions/gsd/tests/summary-render-parity.test.ts @@ -0,0 +1,221 @@ +/** + * summary-render-parity.test.ts — Regression test for #2720 + * + * Asserts that the SUMMARY.md produced at task-completion time + * (renderSummaryMarkdown in complete-task.ts) is structurally identical + * to the SUMMARY.md produced at projection-regeneration time + * (renderSummaryContent in workflow-projections.ts). + * + * Both render paths receive equivalent data (CompleteTaskParams vs TaskRow) + * and must produce the same output. If they diverge, projection regeneration + * silently replaces richer content with a stripped-down version. + */ + +import { createTestContext } from './test-helpers.ts'; +import { renderSummaryContent } from '../workflow-projections.ts'; +import type { TaskRow } from '../gsd-db.ts'; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Fixtures — same logical data in both shapes +// ═══════════════════════════════════════════════════════════════════════════ + +const SLICE_ID = "S01"; +const MILESTONE_ID = "M001"; + +const taskRow: TaskRow = { + milestone_id: MILESTONE_ID, + slice_id: SLICE_ID, + id: "T01", + title: "Implement widget parser", + status: "complete", + one_liner: "Implement widget parser", + narrative: "Added a recursive descent parser for widget DSL.", + verification_result: "All 42 unit tests pass; linter clean.", + duration: "2h", + completed_at: "2025-01-15T10:30:00.000Z", + blocker_discovered: false, + deviations: "Switched from PEG to hand-rolled parser for perf.", + known_issues: "No known issues.", + key_files: ["src/parser.ts", "src/lexer.ts"], + key_decisions: ["Hand-rolled parser over PEG for 3x throughput"], + full_summary_md: "", + description: "", + estimate: "", + files: [], + verify: "", + inputs: [], + expected_output: [], + observability_impact: "", + full_plan_md: "", + sequence: 1, +}; + +const verificationEvidence = [ + { command: "npm test", exitCode: 0, verdict: "42/42 passed ✅", durationMs: 3200 }, + { command: "npm run lint", exitCode: 0, verdict: "No warnings ✅", durationMs: 1100 }, +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +// Test 1: renderSummaryContent includes Verification section +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("## Verification"), + "renderSummaryContent must include a ## Verification section", + ); +} + +// Test 2: renderSummaryContent includes Verification Evidence table +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID, verificationEvidence); + assertTrue( + output.includes("## Verification Evidence"), + "renderSummaryContent must include a ## Verification Evidence section", + ); + assertTrue( + output.includes("npm test"), + "Verification Evidence table must include the command", + ); + assertTrue( + output.includes("| Exit Code |") || output.includes("exit_code") || output.includes("Exit Code"), + "Verification Evidence table must include exit code column", + ); +} + +// Test 3: renderSummaryContent includes Files Created/Modified section +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("## Files Created/Modified"), + "renderSummaryContent must include a ## Files Created/Modified section", + ); + assertTrue( + output.includes("`src/parser.ts`"), + "Files section must list key_files as inline code", + ); +} + +// Test 4: one_liner renders as bold (not blockquote) for consistency +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes(`**${taskRow.one_liner}**`), + "one_liner must render as bold text (not blockquote)", + ); +} + +// Test 5: frontmatter key_files uses YAML list format (not JSON array) +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("key_files:\n - src/parser.ts\n - src/lexer.ts"), + "key_files frontmatter must use YAML list format, not JSON array", + ); +} + +// Test 6: frontmatter key_decisions uses YAML list format (not JSON array) +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("key_decisions:\n - Hand-rolled parser over PEG for 3x throughput"), + "key_decisions frontmatter must use YAML list format, not JSON array", + ); +} + +// Test 7: Deviations section always present (with "None." fallback) +{ + const noDeviations = { ...taskRow, deviations: "" }; + const output = renderSummaryContent(noDeviations, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("## Deviations"), + "Deviations section must always be present even when empty", + ); + assertTrue( + output.includes("None."), + "Deviations section must show 'None.' when no deviations", + ); +} + +// Test 8: Known Issues section always present (with "None." fallback) +{ + const noKnownIssues = { ...taskRow, known_issues: "" }; + const output = renderSummaryContent(noKnownIssues, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("## Known Issues"), + "Known Issues section must always be present even when empty", + ); +} + +// Test 9: verification_result frontmatter not double-quoted +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + // Should be: verification_result: passed (not "passed") + assertTrue( + !output.includes('verification_result: "'), + "verification_result frontmatter value must not be double-quoted", + ); +} + +// Test 10: duration frontmatter not double-quoted +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + !output.includes('duration: "'), + "duration frontmatter value must not be double-quoted", + ); +} + +// Test 11: empty key_files renders YAML placeholder, not empty array +{ + const noFiles = { ...taskRow, key_files: [] }; + const output = renderSummaryContent(noFiles, SLICE_ID, MILESTONE_ID); + assertTrue( + output.includes("key_files:\n - (none)"), + "empty key_files must render as YAML list with (none) placeholder", + ); +} + +// Test 12: frontmatter does not contain extra projection-only fields +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID); + assertTrue( + !output.includes("provides:"), + "frontmatter must not contain provides field", + ); + assertTrue( + !output.includes("requires:"), + "frontmatter must not contain requires field", + ); + assertTrue( + !output.includes("affects:"), + "frontmatter must not contain affects field", + ); + assertTrue( + !output.includes("patterns_established:"), + "frontmatter must not contain patterns_established field", + ); + assertTrue( + !output.includes("drill_down_paths:"), + "frontmatter must not contain drill_down_paths field", + ); + assertTrue( + !output.includes("observability_surfaces:"), + "frontmatter must not contain observability_surfaces field", + ); +} + +// Test 13: no verification evidence renders empty table row +{ + const output = renderSummaryContent(taskRow, SLICE_ID, MILESTONE_ID, []); + assertTrue( + output.includes("No verification commands discovered"), + "Empty evidence array must render placeholder row", + ); +} + +report(); diff --git a/src/resources/extensions/gsd/tools/complete-task.ts b/src/resources/extensions/gsd/tools/complete-task.ts index d7805b20d..8de2daa74 100644 --- a/src/resources/extensions/gsd/tools/complete-task.ts +++ b/src/resources/extensions/gsd/tools/complete-task.ts @@ -30,7 +30,7 @@ import { checkOwnership, taskUnitKey } from "../unit-ownership.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanCheckboxes } from "../markdown-renderer.js"; -import { renderAllProjections } from "../workflow-projections.js"; +import { renderAllProjections, renderSummaryContent } from "../workflow-projections.js"; import { writeManifest } from "../workflow-manifest.js"; import { appendEvent } from "../workflow-events.js"; @@ -41,79 +41,40 @@ export interface CompleteTaskResult { summaryPath: string; } +import type { TaskRow } from "../gsd-db.js"; + /** - * Render task summary markdown matching the template format. - * YAML frontmatter uses snake_case keys for parseSummary() compatibility. + * Build a TaskRow-shaped object from CompleteTaskParams so the unified + * renderSummaryContent() can be used at completion time (#2720). */ -function renderSummaryMarkdown(params: CompleteTaskParams): string { - const now = new Date().toISOString(); - const keyFilesYaml = params.keyFiles.length > 0 - ? params.keyFiles.map(f => ` - ${f}`).join("\n") - : " - (none)"; - const keyDecisionsYaml = params.keyDecisions.length > 0 - ? params.keyDecisions.map(d => ` - ${d}`).join("\n") - : " - (none)"; - - // Build verification evidence table rows - let evidenceTable = "| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n"; - if (params.verificationEvidence.length > 0) { - params.verificationEvidence.forEach((e, i) => { - evidenceTable += `| ${i + 1} | \`${e.command}\` | ${e.exitCode} | ${e.verdict} | ${e.durationMs}ms |\n`; - }); - } else { - evidenceTable += "| — | No verification commands discovered | — | — | — |\n"; - } - - // Determine verification_result from evidence - const allPassed = params.verificationEvidence.length > 0 && - params.verificationEvidence.every(e => e.exitCode === 0 || e.verdict.includes("✅") || e.verdict.toLowerCase().includes("pass")); - const verificationResult = allPassed ? "passed" : (params.verificationEvidence.length === 0 ? "untested" : "mixed"); - - // Extract a title from the oneLiner or taskId - const title = params.oneLiner || params.taskId; - - return `--- -id: ${params.taskId} -parent: ${params.sliceId} -milestone: ${params.milestoneId} -key_files: -${keyFilesYaml} -key_decisions: -${keyDecisionsYaml} -duration: "" -verification_result: ${verificationResult} -completed_at: ${now} -blocker_discovered: ${params.blockerDiscovered} ---- - -# ${params.taskId}: ${title} - -**${params.oneLiner}** - -## What Happened - -${params.narrative} - -## Verification - -${params.verification} - -## Verification Evidence - -${evidenceTable} - -## Deviations - -${params.deviations || "None."} - -## Known Issues - -${params.knownIssues || "None."} - -## Files Created/Modified - -${params.keyFiles.map(f => `- \`${f}\``).join("\n") || "None."} -`; +function paramsToTaskRow(params: CompleteTaskParams, completedAt: string): TaskRow { + return { + milestone_id: params.milestoneId, + slice_id: params.sliceId, + id: params.taskId, + title: params.oneLiner || params.taskId, + status: "complete", + one_liner: params.oneLiner, + narrative: params.narrative, + verification_result: params.verification, + duration: "", + completed_at: completedAt, + blocker_discovered: params.blockerDiscovered, + deviations: params.deviations, + known_issues: params.knownIssues, + key_files: params.keyFiles, + key_decisions: params.keyDecisions, + full_summary_md: "", + description: "", + estimate: "", + files: [], + verify: "", + inputs: [], + expected_output: [], + observability_impact: "", + full_plan_md: "", + sequence: 0, + }; } /** @@ -218,8 +179,9 @@ export async function handleCompleteTask( // If disk render fails, roll back the DB status so deriveState() and // verifyExpectedArtifact() stay consistent (both say "not done"). - // Render summary markdown - const summaryMd = renderSummaryMarkdown(params); + // Render summary markdown via the single source of truth (#2720) + const taskRow = paramsToTaskRow(params, completedAt); + const summaryMd = renderSummaryContent(taskRow, params.sliceId, params.milestoneId, params.verificationEvidence); // Resolve and write summary to disk let summaryPath: string; diff --git a/src/resources/extensions/gsd/workflow-projections.ts b/src/resources/extensions/gsd/workflow-projections.ts index 848f70376..7a16c0e56 100644 --- a/src/resources/extensions/gsd/workflow-projections.ts +++ b/src/resources/extensions/gsd/workflow-projections.ts @@ -9,8 +9,9 @@ import { getMilestone, getMilestoneSlices, getSliceTasks, + getVerificationEvidence, } from "./gsd-db.js"; -import type { MilestoneRow, SliceRow, TaskRow } from "./gsd-db.js"; +import type { MilestoneRow, SliceRow, TaskRow, VerificationEvidenceRow } from "./gsd-db.js"; import { atomicWriteSync } from "./atomic-write.js"; import { join } from "node:path"; import { mkdirSync, existsSync } from "node:fs"; @@ -147,71 +148,93 @@ export function renderRoadmapProjection(basePath: string, milestoneId: string): /** * Render SUMMARY.md content from a task row. - * Pure function — no side effects. + * Single source of truth for summary rendering — used both at completion + * time and at projection regeneration time (#2720). + * + * @param evidence - Optional verification evidence rows. When called from + * complete-task, these are passed directly. When called from projection + * regeneration, they are queried from the DB by renderSummaryProjection. */ -export function renderSummaryContent(taskRow: TaskRow, sliceId: string, milestoneId: string): string { - const lines: string[] = []; +export function renderSummaryContent( + taskRow: TaskRow, + sliceId: string, + milestoneId: string, + evidence?: Array<{ command: string; exitCode?: number; exit_code?: number; verdict: string; durationMs?: number; duration_ms?: number }>, +): string { + // ── Frontmatter (YAML list format, matches parseSummary() expectations) ── + const keyFilesYaml = taskRow.key_files && taskRow.key_files.length > 0 + ? taskRow.key_files.map(f => ` - ${f}`).join("\n") + : " - (none)"; + const keyDecisionsYaml = taskRow.key_decisions && taskRow.key_decisions.length > 0 + ? taskRow.key_decisions.map(d => ` - ${d}`).join("\n") + : " - (none)"; - // Frontmatter - lines.push("---"); - lines.push(`id: ${taskRow.id}`); - lines.push(`parent: ${sliceId}`); - lines.push(`milestone: ${milestoneId}`); - lines.push("provides: []"); - lines.push("requires: []"); - lines.push("affects: []"); + // Derive verification_result from evidence if available + const evidenceList = evidence ?? []; + const allPassed = evidenceList.length > 0 && + evidenceList.every(e => { + const code = e.exitCode ?? e.exit_code ?? -1; + return code === 0 || e.verdict.includes("\u2705") || e.verdict.toLowerCase().includes("pass"); + }); + const verificationResult = taskRow.verification_result + ? (allPassed ? "passed" : (evidenceList.length === 0 ? "untested" : "mixed")) + : (allPassed ? "passed" : (evidenceList.length === 0 ? "untested" : "mixed")); - // key_files is already parsed to string[] - if (taskRow.key_files && taskRow.key_files.length > 0) { - lines.push(`key_files: [${taskRow.key_files.map(f => `"${f}"`).join(", ")}]`); + // Build verification evidence table + let evidenceTable = "| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n"; + if (evidenceList.length > 0) { + evidenceList.forEach((e, i) => { + const code = e.exitCode ?? e.exit_code ?? 0; + const dur = e.durationMs ?? e.duration_ms ?? 0; + evidenceTable += `| ${i + 1} | \`${e.command}\` | ${code} | ${e.verdict} | ${dur}ms |\n`; + }); } else { - lines.push("key_files: []"); + evidenceTable += "| \u2014 | No verification commands discovered | \u2014 | \u2014 | \u2014 |\n"; } - // key_decisions is already parsed to string[] - if (taskRow.key_decisions && taskRow.key_decisions.length > 0) { - lines.push(`key_decisions: [${taskRow.key_decisions.map(d => `"${d}"`).join(", ")}]`); - } else { - lines.push("key_decisions: []"); - } + const title = taskRow.one_liner || taskRow.title || taskRow.id; - lines.push("patterns_established: []"); - lines.push("drill_down_paths: []"); - lines.push("observability_surfaces: []"); - lines.push(`duration: "${taskRow.duration || ""}"`); - lines.push(`verification_result: "${taskRow.verification_result || ""}"`); - lines.push(`completed_at: ${taskRow.completed_at || ""}`); - lines.push(`blocker_discovered: ${taskRow.blocker_discovered ? "true" : "false"}`); - lines.push("---"); - lines.push(""); - lines.push(`# ${taskRow.id}: ${taskRow.title}`); - lines.push(""); + return `--- +id: ${taskRow.id} +parent: ${sliceId} +milestone: ${milestoneId} +key_files: +${keyFilesYaml} +key_decisions: +${keyDecisionsYaml} +duration: ${taskRow.duration || ""} +verification_result: ${verificationResult} +completed_at: ${taskRow.completed_at || ""} +blocker_discovered: ${taskRow.blocker_discovered ? "true" : "false"} +--- - // One-liner (if present) - if (taskRow.one_liner) { - lines.push(`> ${taskRow.one_liner}`); - lines.push(""); - } +# ${taskRow.id}: ${title} - lines.push("## What Happened"); - lines.push(taskRow.full_summary_md || taskRow.narrative || "No summary recorded."); - lines.push(""); +**${taskRow.one_liner || ""}** - // Deviations (if present) - if (taskRow.deviations) { - lines.push("## Deviations"); - lines.push(taskRow.deviations); - lines.push(""); - } +## What Happened - // Known issues (if present) - if (taskRow.known_issues) { - lines.push("## Known Issues"); - lines.push(taskRow.known_issues); - lines.push(""); - } +${taskRow.narrative || "No summary recorded."} - return lines.join("\n"); +## Verification + +${taskRow.verification_result || "No verification recorded."} + +## Verification Evidence + +${evidenceTable} +## Deviations + +${taskRow.deviations || "None."} + +## Known Issues + +${taskRow.known_issues || "None."} + +## Files Created/Modified + +${taskRow.key_files && taskRow.key_files.length > 0 ? taskRow.key_files.map(f => `- \`${f}\``).join("\n") : "None."} +`; } /** @@ -223,7 +246,8 @@ export function renderSummaryProjection(basePath: string, milestoneId: string, s const taskRow = taskRows.find(t => t.id === taskId); if (!taskRow) return; - const content = renderSummaryContent(taskRow, sliceId, milestoneId); + const evidenceRows = getVerificationEvidence(milestoneId, sliceId, taskId); + const content = renderSummaryContent(taskRow, sliceId, milestoneId, evidenceRows); const dir = join(basePath, ".gsd", "milestones", milestoneId, "slices", sliceId, "tasks"); mkdirSync(dir, { recursive: true }); atomicWriteSync(join(dir, `${taskId}-SUMMARY.md`), content);