fix: unify SUMMARY.md render paths for projection fidelity (#3091)

* fix: unify SUMMARY.md render paths for projection fidelity

Closes #2720

renderSummaryMarkdown (complete-task.ts) and renderSummaryContent
(workflow-projections.ts) produced structurally different output for the
same data — different frontmatter format, different sections, different
formatting. Deleting a SUMMARY.md and regenerating it via projection
yielded a different file than the original.

Fix: make renderSummaryContent the single source of truth. complete-task
now builds a TaskRow from params and delegates to renderSummaryContent.
The projection renderer passes verification evidence from the DB so
both paths produce identical output including the Verification Evidence
table, Files Created/Modified section, and YAML-format frontmatter.

Added getVerificationEvidence() to gsd-db for projection-time evidence
retrieval, and a 22-assertion parity test that prevents future drift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: safe type assertion for verification evidence query result

Cast through `unknown` to satisfy TS2352 — better-sqlite3's `.all()`
returns `Record<string, unknown>[]` which doesn't directly overlap with
`VerificationEvidenceRow[]`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:36:37 -04:00 committed by GitHub
parent 3e78270cad
commit c924f9f1f8
4 changed files with 356 additions and 129 deletions

View file

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

View file

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

View file

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

View file

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