From ecd57e622c1aa4592d19f9048394d9fc09d2edb1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 8 May 2026 23:53:21 -0400 Subject: [PATCH] fix(3265): prefer YAML frontmatter for state-snapshot canonical fields (#3275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(3265): prefer YAML frontmatter for state-snapshot canonical fields stateSnapshot in both sdk/src/query/state.ts and the CJS twin (get-shit-done/bin/lib/state.cjs cmdStateSnapshot) passed the whole STATE.md blob to stateExtractField, whose bold pattern (**Field:**) has no line anchor. A body table cell such as "**Status:** to ✅ COMPLETE" therefore silenced the correct YAML frontmatter value. Fix: extractFrontmatter(content) first; stripFrontmatter(content) for the body passed to stateExtractField; for each canonical scalar field prefer the non-empty frontmatter value, falling back to body extraction when the key is absent or the file has no frontmatter block at all. Regression tests added in sdk/src/query/state.test.ts (vitest) and tests/state.test.cjs (node:test) covering: - frontmatter status beats **Status:** inside a table cell - frontmatter current_plan beats bold body value - no-frontmatter files continue to extract from body - field absent from frontmatter falls through to body extractor Fixes #3265 Co-Authored-By: Claude Sonnet 4.6 * chore: add changeset for #3275 Co-Authored-By: Claude Sonnet 4.6 * test: reproduce fmStr drops non-string YAML scalars (#3275 CR finding) Add tests/bug-3275-fmstr-non-string-scalars.test.cjs with 5 cases covering CJS state-snapshot with numeric frontmatter scalars (current_phase: 19, total_phases: 7, total_plans_in_phase: 5), string regression, and no-frontmatter body fallback regression. Co-Authored-By: Claude Sonnet 4.6 * fix(state): fmStr accepts numeric/boolean YAML scalars (CR finding) Rename `fmStr` to `fmScalar` in both state.cjs and sdk/src/query/state.ts and broaden the type guard so that non-null number/boolean frontmatter values are coerced to String(v) instead of being discarded. The previous `typeof v === 'string'` check was a latent bug: if the YAML parser ever returns typed scalars (e.g. `current_phase: 19` as the number 19), the frontmatter value would be silently dropped and the stale body value used instead. Both files are updated identically (k014 parity). Also adds three SDK vitest regression cases (numeric current_phase, total_phases, total_plans_in_phase) in sdk/src/query/state.test.ts. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/sharp-badgers-squeak.md | 5 + get-shit-done/bin/lib/state.cjs | 47 ++-- sdk/src/query/state.test.ts | 207 ++++++++++++++++++ sdk/src/query/state.ts | 49 +++-- ...bug-3275-fmstr-non-string-scalars.test.cjs | 146 ++++++++++++ tests/state.test.cjs | 92 ++++++++ 6 files changed, 518 insertions(+), 28 deletions(-) create mode 100644 .changeset/sharp-badgers-squeak.md create mode 100644 tests/bug-3275-fmstr-non-string-scalars.test.cjs diff --git a/.changeset/sharp-badgers-squeak.md b/.changeset/sharp-badgers-squeak.md new file mode 100644 index 000000000..1b3e2f596 --- /dev/null +++ b/.changeset/sharp-badgers-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3275 +--- +state-snapshot no longer returns wrong status and other fields when STATE.md body contains a Markdown table cell with bold field syntax (e.g. **Status:** in a task history row) — YAML frontmatter values now take precedence over body extraction for all canonical scalar fields. diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 8195b76d4..16f7448b0 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -648,17 +648,36 @@ function cmdStateSnapshot(cwd, raw) { const content = fs.readFileSync(statePath, 'utf-8'); - // Extract basic fields - const currentPhase = stateExtractField(content, 'Current Phase'); - const currentPhaseName = stateExtractField(content, 'Current Phase Name'); - const totalPhasesRaw = stateExtractField(content, 'Total Phases'); - const currentPlan = stateExtractField(content, 'Current Plan'); - const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase'); - const status = stateExtractField(content, 'Status'); - const progressRaw = stateExtractField(content, 'Progress'); - const lastActivity = stateExtractField(content, 'Last Activity'); - const lastActivityDesc = stateExtractField(content, 'Last Activity Description'); - const pausedAt = stateExtractField(content, 'Paused At'); + // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a + // body table cell containing **Status:** Y cannot shadow the authoritative + // frontmatter value. Mirrors the fix in sdk/src/query/state.ts. + const fm = extractFrontmatter(content); + const body = stripFrontmatter(content); + + // Helper: return frontmatter scalar value when present and non-empty. + // Accepts strings, numbers, and booleans — coercing non-string primitives to + // their string representation so callers always receive string | null. + // Returns null for missing, null/undefined, or empty-after-trim values so + // the caller falls back to body extraction. + const fmScalar = (key) => { + const v = fm[key]; + if (v === null || v === undefined) return null; + if (typeof v === 'string') return v.trim() || null; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return null; + }; + + // Extract basic fields — frontmatter keys take precedence over body + const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase'); + const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name'); + const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases'); + const currentPlan = fmScalar('current_plan') ?? stateExtractField(body, 'Current Plan'); + const totalPlansRaw = fmScalar('total_plans_in_phase') ?? stateExtractField(body, 'Total Plans in Phase'); + const status = fmScalar('status') ?? stateExtractField(body, 'Status'); + const progressRaw = fmScalar('progress') ?? stateExtractField(body, 'Progress'); + const lastActivity = fmScalar('last_activity') ?? stateExtractField(body, 'Last Activity'); + const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description'); + const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, 'Paused At'); // Parse numeric fields const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; @@ -667,7 +686,7 @@ function cmdStateSnapshot(cwd, raw) { // Extract decisions table const decisions = []; - const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); + const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); if (decisionsMatch) { const tableBody = decisionsMatch[1]; const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); @@ -685,7 +704,7 @@ function cmdStateSnapshot(cwd, raw) { // Extract blockers list const blockers = []; - const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); + const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); if (blockersMatch) { const blockersSection = blockersMatch[1]; const items = blockersSection.match(/^-\s+(.+)$/gm) || []; @@ -701,7 +720,7 @@ function cmdStateSnapshot(cwd, raw) { resume_file: null, }; - const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); if (sessionMatch) { const sessionSection = sessionMatch[1]; const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i) diff --git a/sdk/src/query/state.test.ts b/sdk/src/query/state.test.ts index 09c7eeb04..a97eac3b6 100644 --- a/sdk/src/query/state.test.ts +++ b/sdk/src/query/state.test.ts @@ -346,6 +346,126 @@ describe('stateSnapshot', () => { }); }); +// ─── Regression: #3265 — frontmatter wins over bold-body cell ───────────── + +describe('stateSnapshot — bug #3265 frontmatter precedence', () => { + it('returns frontmatter status, not **Status:** value embedded in a body table cell', async () => { + // Reproduce the collision: frontmatter says "executing", but the body + // contains a Markdown table cell with "**Status:** to ✅ COMPLETE ..." + // which stateExtractField (bold pattern) would match before the YAML line. + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'current_plan: 19.5-05', + '---', + '', + '# Project State', + '', + '## Recent Quick Tasks', + '', + '| Date | Task | Notes |', + '|------|------|-------|', + '| 2026-05-01 | Reopened Plan 19.5-05. **Status:** to ✅ COMPLETE | done |', + '', + '**Current Phase:** 19', + '**Current Plan:** archived-lane', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3265-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + // Frontmatter status must win + expect(data.status).toBe('executing'); + + await rm(localDir, { recursive: true, force: true }); + }); + + it('returns frontmatter current_plan, not bold body value when both present', async () => { + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'current_plan: 19.5-05', + '---', + '', + '# Project State', + '', + '**Current Phase:** 19', + '**Current Plan:** archived-lane', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3265b-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + // Frontmatter current_plan must win over body bold value + expect(data.current_plan).toBe('19.5-05'); + + await rm(localDir, { recursive: true, force: true }); + }); + + it('falls back to body extraction when no frontmatter block is present', async () => { + const stateContent = [ + '# Project State', + '', + '**Current Phase:** 07', + '**Status:** paused', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3265c-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + // No frontmatter — body extraction must still work + expect(data.status).toBe('paused'); + expect(data.current_phase).toBe('07'); + + await rm(localDir, { recursive: true, force: true }); + }); + + it('falls back to body extractor for a field absent from frontmatter', async () => { + // Frontmatter has status but no current_plan — snapshot must body-extract current_plan + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'status: planning', + '---', + '', + '# Project State', + '', + '**Current Plan:** 05-03', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3265d-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + expect(data.status).toBe('planning'); + // current_plan absent from frontmatter — must come from body + expect(data.current_plan).toBe('05-03'); + + await rm(localDir, { recursive: true, force: true }); + }); +}); + // ─── Regression: --ws propagation (#2618 gap 1) ──────────────────────────── describe('stateJson with --ws workstream', () => { @@ -379,3 +499,90 @@ Status: planning expect(data.status).toBe('planning'); }); }); + +// ─── Regression: #3275 CR — fmScalar handles numeric/boolean YAML scalars ─── + +describe('stateSnapshot — CR #3275 fmScalar non-string scalar coercion', () => { + it('treats numeric current_phase as string "19", not missing', async () => { + // A real YAML parser (e.g. js-yaml) would parse `current_phase: 19` as + // the number 19, not the string "19". fmScalar must coerce it so the + // frontmatter value wins over the body's bold field. + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'current_phase: 19', + '---', + '', + '# Project State', + '', + '**Current Phase:** 03', + '**Status:** executing', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3275a-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + // Frontmatter wins: current_phase must be "19", not "03" (from body) + expect(data.current_phase).toBe('19'); + + await rm(localDir, { recursive: true, force: true }); + }); + + it('treats numeric total_phases in frontmatter as string, not missing', async () => { + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'total_phases: 7', + '---', + '', + '# Project State', + '', + '**Total Phases:** 3', + '**Status:** executing', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3275b-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + // total_phases is parsed as int downstream: frontmatter 7 must win over body 3 + expect(data.total_phases).toBe(7); + + await rm(localDir, { recursive: true, force: true }); + }); + + it('treats numeric total_plans_in_phase in frontmatter as string, not missing', async () => { + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'total_plans_in_phase: 5', + '---', + '', + '# Project State', + '', + '**Total Plans in Phase:** 2', + '**Status:** executing', + '', + ].join('\n'); + + const localDir = await mkdtemp(join(tmpdir(), 'gsd-3275c-')); + await mkdir(join(localDir, '.planning'), { recursive: true }); + await writeFile(join(localDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateSnapshot([], localDir); + const data = result.data as Record; + + expect(data.total_plans_in_phase).toBe(5); + + await rm(localDir, { recursive: true, force: true }); + }); +}); diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts index 7d31ac725..e184eafc2 100644 --- a/sdk/src/query/state.ts +++ b/sdk/src/query/state.ts @@ -337,17 +337,38 @@ export const stateSnapshot: QueryHandler = async (_args, projectDir, workstream) return { data: { error: 'STATE.md not found' } }; } - // Extract basic fields - const currentPhase = stateExtractField(content, 'Current Phase'); - const currentPhaseName = stateExtractField(content, 'Current Phase Name'); - const totalPhasesRaw = stateExtractField(content, 'Total Phases'); - const currentPlan = stateExtractField(content, 'Current Plan'); - const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase'); - const status = stateExtractField(content, 'Status'); - const progressRaw = stateExtractField(content, 'Progress'); - const lastActivity = stateExtractField(content, 'Last Activity'); - const lastActivityDesc = stateExtractField(content, 'Last Activity Description'); - const pausedAt = stateExtractField(content, 'Paused At'); + // Bug #3265: prefer YAML frontmatter for canonical scalar fields so that a + // body table cell containing **Status:** Y cannot shadow the authoritative + // frontmatter value. Matches the precedent set by buildStateFrontmatter + // (see state.ts:92 Bug #2613 comment). + const fm = extractFrontmatter(content); + const body = stripFrontmatter(content); + + // Helper: return frontmatter scalar value when present and non-empty. + // Accepts strings, numbers, and booleans — coercing non-string primitives to + // their string representation so callers always receive string | null. + // Returns null for missing, null/undefined, or empty-after-trim values so + // the caller falls back to body extractor (covers STATE.md files that have + // no frontmatter at all, or frontmatter that lacks the specific key). + const fmScalar = (key: string): string | null => { + const v = fm[key]; + if (v === null || v === undefined) return null; + if (typeof v === 'string') return v.trim() || null; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return null; + }; + + // Extract basic fields — frontmatter keys take precedence over body + const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase'); + const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name'); + const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases'); + const currentPlan = fmScalar('current_plan') ?? stateExtractField(body, 'Current Plan'); + const totalPlansRaw = fmScalar('total_plans_in_phase') ?? stateExtractField(body, 'Total Plans in Phase'); + const status = fmScalar('status') ?? stateExtractField(body, 'Status'); + const progressRaw = fmScalar('progress') ?? stateExtractField(body, 'Progress'); + const lastActivity = fmScalar('last_activity') ?? stateExtractField(body, 'Last Activity'); + const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description'); + const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, 'Paused At'); // Parse numeric fields const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; @@ -361,7 +382,7 @@ export const stateSnapshot: QueryHandler = async (_args, projectDir, workstream) // Extract decisions table const decisions: Array<{ phase: string; summary: string; rationale: string }> = []; - const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); + const decisionsMatch = body.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); if (decisionsMatch) { const tableBody = decisionsMatch[1]; const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); @@ -379,7 +400,7 @@ export const stateSnapshot: QueryHandler = async (_args, projectDir, workstream) // Extract blockers list const blockers: string[] = []; - const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); + const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); if (blockersMatch) { const blockersSection = blockersMatch[1]; const items = blockersSection.match(/^-\s+(.+)$/gm) || []; @@ -395,7 +416,7 @@ export const stateSnapshot: QueryHandler = async (_args, projectDir, workstream) resume_file: null, }; - const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); if (sessionMatch) { const sessionSection = sessionMatch[1]; const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i) diff --git a/tests/bug-3275-fmstr-non-string-scalars.test.cjs b/tests/bug-3275-fmstr-non-string-scalars.test.cjs new file mode 100644 index 000000000..0fe89a45c --- /dev/null +++ b/tests/bug-3275-fmstr-non-string-scalars.test.cjs @@ -0,0 +1,146 @@ +/** + * GSD Tools Tests — Bug #3275 (CR finding) + * + * Regression guard: `state-snapshot` must prefer YAML frontmatter scalar + * values even when those scalars are numeric (e.g. current_phase: 19) or + * boolean — not just when they are strings. + * + * Prior to the fix, `fmStr` checked `typeof v === 'string'`, so a numeric + * frontmatter value like `current_phase: 19` was treated as missing and the + * snapshot fell back to body extraction, which could return a stale or + * incorrect value. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('state-snapshot: fmStr accepts non-string YAML scalars (#3275 CR)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('numeric current_phase in frontmatter wins over body extraction', () => { + // YAML parses bare integers as numbers, not strings. + // fmStr must not drop the frontmatter value when it is a number. + const stateMd = [ + '---', + 'gsd_state_version: 1.0', + 'current_phase: 19', + '---', + '', + '# Project State', + '', + '**Current Phase:** 03', + '**Status:** executing', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // Frontmatter numeric value must win over bold-body value + assert.strictEqual(output.current_phase, '19', 'numeric frontmatter current_phase must be used'); + }); + + test('numeric total_phases in frontmatter wins over body extraction', () => { + const stateMd = [ + '---', + 'gsd_state_version: 1.0', + 'total_phases: 7', + '---', + '', + '# Project State', + '', + '**Total Phases:** 3', + '**Status:** executing', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // Frontmatter says 7, body says 3 — frontmatter must win + assert.strictEqual(output.total_phases, 7, 'numeric frontmatter total_phases must be used'); + }); + + test('numeric total_plans_in_phase in frontmatter wins over body extraction', () => { + const stateMd = [ + '---', + 'gsd_state_version: 1.0', + 'total_plans_in_phase: 5', + '---', + '', + '# Project State', + '', + '**Total Plans in Phase:** 2', + '**Status:** executing', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.total_plans_in_phase, 5, 'numeric frontmatter total_plans_in_phase must be used'); + }); + + test('string current_phase in frontmatter still works (no regression)', () => { + const stateMd = [ + '---', + 'gsd_state_version: 1.0', + "current_phase: '19'", + '---', + '', + '# Project State', + '', + '**Current Phase:** 03', + '**Status:** executing', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_phase, '19', 'string frontmatter current_phase still works'); + }); + + test('no-frontmatter file still extracts from body (no regression)', () => { + const stateMd = [ + '# Project State', + '', + '**Current Phase:** 05', + '**Total Phases:** 8', + '**Status:** paused', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_phase, '05', 'body extraction still works without frontmatter'); + assert.strictEqual(output.total_phases, 8, 'numeric body total_phases still extracted'); + }); +}); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index e21b46375..6e1433df0 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -191,6 +191,98 @@ describe('state-snapshot command', () => { }); }); +// ─── Regression: #3265 — frontmatter wins over bold-body cell ───────────── + +describe('state-snapshot — bug #3265 frontmatter precedence', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns frontmatter status, not **Status:** value embedded in a body table cell', () => { + // Reproduce the collision: frontmatter says "executing", but the body + // contains a Markdown table cell with "**Status:** to ✅ COMPLETE ..." + // which stateExtractField (bold pattern) would match before the YAML line. + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'current_plan: 19.5-05', + '---', + '', + '# Project State', + '', + '## Recent Quick Tasks', + '', + '| Date | Task | Notes |', + '|------|------|-------|', + '| 2026-05-01 | Reopened Plan 19.5-05. **Status:** to ✅ COMPLETE | done |', + '', + '**Current Phase:** 19', + '**Current Plan:** archived-lane', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // Frontmatter status must win over the table cell's **Status:** match + assert.strictEqual(output.status, 'executing', 'frontmatter status beats body table cell'); + }); + + test('returns frontmatter current_plan, not bold body value when both present', () => { + const stateContent = [ + '---', + 'gsd_state_version: 1.0', + 'status: executing', + 'current_plan: 19.5-05', + '---', + '', + '# Project State', + '', + '**Current Phase:** 19', + '**Current Plan:** archived-lane', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.current_plan, '19.5-05', 'frontmatter current_plan beats body bold value'); + }); + + test('falls back to body extraction when no frontmatter block is present', () => { + const stateContent = [ + '# Project State', + '', + '**Current Phase:** 07', + '**Status:** paused', + '', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = runGsdTools('state-snapshot', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + // No frontmatter — body extraction must still work + assert.strictEqual(output.status, 'paused', 'body extraction works without frontmatter'); + assert.strictEqual(output.current_phase, '07', 'body extraction works without frontmatter'); + }); +}); + describe('state mutation commands', () => { let tmpDir;