From ff36c117dd4ba746531dd1305d2ab56d154cd0e7 Mon Sep 17 00:00:00 2001 From: deseltrus <101901449+deseltrus@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:13:48 +0200 Subject: [PATCH] fix(gsd): prevent double frontmatter in task SUMMARY.md from projection re-render (#2818) renderSummaryContent() in workflow-projections.ts wraps full_summary_md (already a complete markdown doc with frontmatter) inside a second generated frontmatter/heading envelope. This produces double frontmatter, double H1 headings, and duplicate Deviations/Known Issues sections. The fix checks whether full_summary_md exists and starts with frontmatter delimiters. If so, it is used as the entire output. The fallback synthesis from individual DB columns only runs when full_summary_md is absent or lacks frontmatter. Adds 3 regression tests to projection-regression.test.ts. --- .../gsd/tests/projection-regression.test.ts | 97 ++++++++++++++++++- .../extensions/gsd/workflow-projections.ts | 8 ++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/projection-regression.test.ts b/src/resources/extensions/gsd/tests/projection-regression.test.ts index 90a06e7b9..f22f4d607 100644 --- a/src/resources/extensions/gsd/tests/projection-regression.test.ts +++ b/src/resources/extensions/gsd/tests/projection-regression.test.ts @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { renderPlanContent, renderRoadmapContent } from '../workflow-projections.ts'; +import { renderPlanContent, renderRoadmapContent, renderSummaryContent } from '../workflow-projections.ts'; import type { SliceRow, TaskRow } from '../gsd-db.ts'; // ─── Helpers ───────────────────────────────────────────────────────────── @@ -172,3 +172,98 @@ test('renderRoadmapContent: slice with status "pending" shows ⬜', () => { assert.ok(content.includes('⬜'), 'pending slice should show ⬜'); }); + +// ─── renderSummaryContent: double-frontmatter regression ───────────────── + +test('renderSummaryContent: uses full_summary_md as-is when it contains frontmatter', () => { + const existingSummary = [ + '---', + 'id: T01', + 'parent: S01', + 'milestone: M001', + 'key_files:', + ' - src/thing.ts', + 'verification_result: passed', + 'completed_at: 2026-01-01T00:00:00Z', + 'blocker_discovered: false', + '---', + '', + '# T01: Did the thing', + '', + '**One-liner summary**', + '', + '## What Happened', + '', + 'Narrative content here.', + '', + '## Deviations', + '', + 'None.', + '', + ].join('\n'); + + const task = makeTaskRow({ + id: 'T01', + status: 'complete', + title: 'Did the thing', + one_liner: 'One-liner summary', + narrative: 'Narrative content here.', + full_summary_md: existingSummary, + }); + + const result = renderSummaryContent(task, 'S01', 'M001'); + + // Must NOT produce double frontmatter + const frontmatterCount = (result.match(/^---$/gm) || []).length; + assert.equal(frontmatterCount, 2, `Expected exactly 2 frontmatter delimiters (one block), got ${frontmatterCount}`); + + // Must NOT produce double H1 heading + const h1Count = (result.match(/^# T01:/gm) || []).length; + assert.equal(h1Count, 1, `Expected exactly 1 H1 heading, got ${h1Count}`); + + // Content should match the full_summary_md exactly + assert.equal(result, existingSummary); +}); + +test('renderSummaryContent: synthesizes from DB columns when full_summary_md is empty', () => { + const task = makeTaskRow({ + id: 'T01', + status: 'complete', + title: 'Did the thing', + one_liner: 'One-liner summary', + narrative: 'Built the feature.', + full_summary_md: '', + deviations: 'Deviated slightly.', + known_issues: 'None.', + }); + + const result = renderSummaryContent(task, 'S01', 'M001'); + + // Should have exactly one frontmatter block + const frontmatterCount = (result.match(/^---$/gm) || []).length; + assert.equal(frontmatterCount, 2, 'Should have one frontmatter block (2 delimiters)'); + + // Should contain synthesized sections + assert.ok(result.includes('## What Happened'), 'Should have What Happened section'); + assert.ok(result.includes('Built the feature.'), 'Should use narrative for content'); + assert.ok(result.includes('## Deviations'), 'Should have Deviations section'); + assert.ok(result.includes('Deviated slightly.'), 'Should include deviation text'); +}); + +test('renderSummaryContent: synthesizes when full_summary_md has no frontmatter', () => { + const task = makeTaskRow({ + id: 'T02', + status: 'complete', + title: 'Partial summary', + narrative: 'Did some work.', + full_summary_md: 'Just a plain text summary with no frontmatter.', + }); + + const result = renderSummaryContent(task, 'S01', 'M001'); + + // Should synthesize with proper frontmatter since the stored md lacks it + assert.ok(result.startsWith('---'), 'Should start with frontmatter'); + assert.ok(result.includes('id: T02'), 'Should have task ID in frontmatter'); + assert.ok(result.includes('## What Happened'), 'Should have What Happened section'); + assert.ok(result.includes('Did some work.'), 'Should use narrative'); +}); diff --git a/src/resources/extensions/gsd/workflow-projections.ts b/src/resources/extensions/gsd/workflow-projections.ts index dfa8b170e..6aea665f0 100644 --- a/src/resources/extensions/gsd/workflow-projections.ts +++ b/src/resources/extensions/gsd/workflow-projections.ts @@ -180,6 +180,14 @@ export function renderSummaryContent( milestoneId: string, evidence?: Array<{ command: string; exitCode?: number; exit_code?: number; verdict: string; durationMs?: number; duration_ms?: number }>, ): string { + // If the task already has a fully rendered summary (written by handleCompleteTask's + // renderSummaryMarkdown), use it as-is. That content already includes frontmatter, + // heading, and all sections. Re-wrapping it inside a second frontmatter/heading + // envelope produces double frontmatter and duplicate sections. + if (taskRow.full_summary_md && taskRow.full_summary_md.trimStart().startsWith("---")) { + return taskRow.full_summary_md; + } + // ── 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")