From 2668fbbeb00f405b4996321ba7b1d92d53df0f7f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 22:42:28 -0400 Subject: [PATCH] fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase (#1256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase state begin-phase/complete-phase called stateReplaceField on the FULL file content, so its case-insensitive ^Status: plain-pattern matched the YAML frontmatter status: line first (no g flag) and never updated the body pipe-table Status cell; syncStateFrontmatter then re-derived the stale status from the unchanged body, freezing the frontmatter status. Fix: strip frontmatter before the body-field replacements (operate on body only), reassemble with frontmatter preserved, so the body Status cell updates and the frontmatter derives correctly — for inline AND pipe-table body formats. Also corrects the Current Position pipe-table else-branches (Status/Phase/Last-activity) to write bare, consistent cell values. Pipe-table Status is a supported body format (not rewritten to inline). Closes #1255 Co-Authored-By: Claude Opus 4.8 * chore(#1255): add changeset for pipe-table state status fix (#1256) Co-Authored-By: Claude Opus 4.8 * test(#1255): fold pipe-table regression into state.test.cjs + Windows-portable frontmatter regex Per the 2026-06 audit, new tests/bug-NNNN-*.test.cjs files are banned (lint-regression-test-names) — folded the 7 #1255 regressions into tests/state.test.cjs and removed the standalone file + its lint-test-file-count allowlist entry. Also fixed the frontmatter assertions' /^---\n/ anchors to /^---\r?\n/ (windows-test-parity-guard frontmatterAnchorLiteralNewline). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/1255-state-status-pipe-table.md | 5 + src/state.cts | 129 ++++++--- tests/state.test.cjs | 305 +++++++++++++++++++++ 3 files changed, 406 insertions(+), 33 deletions(-) create mode 100644 .changeset/1255-state-status-pipe-table.md diff --git a/.changeset/1255-state-status-pipe-table.md b/.changeset/1255-state-status-pipe-table.md new file mode 100644 index 000000000..778d24356 --- /dev/null +++ b/.changeset/1255-state-status-pipe-table.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1256 +--- +**`state begin-phase` / `complete-phase` now advance the frontmatter `status` for pipe-table `STATE.md`, not only inline `Status:` files.** The status update matched the YAML frontmatter `status:` line first and never updated a body `| Status | … |` cell, so the frontmatter `status` froze (e.g. stuck at `planning`); it now transitions correctly (`planning → executing → completed`) regardless of whether the body `Status` is inline or pipe-table. (#1255) diff --git a/src/state.cts b/src/state.cts index 1c96934c9..26c52970c 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1749,63 +1749,81 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName const updated: string[] = []; readModifyWriteStateMd(statePath, (content) => { + // Bug #1255: all body-field replacements must operate on the body only + // (frontmatter stripped), not on the full content. When the full content is + // passed to stateReplaceField the YAML `status: planning` key matches the + // plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the + // pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter + // re-derives 'planning' from the unchanged body — the status never advances. + const existingFm = extractFrontmatter(content) as Record; + const hasFrontmatter = Object.keys(existingFm).length > 0; + let body = stripFrontmatter(content); + + // Helper to reassemble content for field-replacement checks; callers that + // only need to test/replace body fields use `body` directly, and the final + // return reassembles the frontmatter block with the updated body. + const reassemble = (b: string) => + hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b; + // Idempotency guard (#3127): if the phase is already mid-flight, do NOT // overwrite execution-progress fields (Current Plan, plan body line, // Last Activity Description). Only update fields that are safe to // refresh on resume (Last Activity date, Status if inconsistent). // A phase is considered mid-flight when Status contains 'Executing Phase N' // for the current phase number. - const currentStatus = stateExtractField(content, 'Status') || ''; + // #1255: extract from body (not full content) so the YAML `status:` key + // cannot shadow the body Status field. + const currentStatus = stateExtractField(body, 'Status') || ''; const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus); - // Update Status field + // Update Status field (body only — #1255) const statusValue = `Executing Phase ${phaseNumber}`; - let result = stateReplaceField(content, 'Status', statusValue); - if (result) { content = result; updated.push('Status'); } + let result = stateReplaceField(body, 'Status', statusValue); + if (result) { body = result; updated.push('Status'); } // Update Last Activity (safe to update on resume — tracks when execute-phase ran) - result = stateReplaceField(content, 'Last Activity', today); - if (result) { content = result; updated.push('Last Activity'); } + result = stateReplaceField(body, 'Last Activity', today); + if (result) { body = result; updated.push('Last Activity'); } if (!isAlreadyExecuting) { // First-time execution: set all progress fields // Update Last Activity Description const activityDesc = `Phase ${phaseNumber} execution started`; - result = stateReplaceField(content, 'Last Activity Description', activityDesc); - if (result) { content = result; updated.push('Last Activity Description'); } + result = stateReplaceField(body, 'Last Activity Description', activityDesc); + if (result) { body = result; updated.push('Last Activity Description'); } // Update Current Phase - result = stateReplaceField(content, 'Current Phase', String(phaseNumber)); - if (result) { content = result; updated.push('Current Phase'); } + result = stateReplaceField(body, 'Current Phase', String(phaseNumber)); + if (result) { body = result; updated.push('Current Phase'); } // Update Current Phase Name if (phaseName) { - result = stateReplaceField(content, 'Current Phase Name', phaseName); - if (result) { content = result; updated.push('Current Phase Name'); } + result = stateReplaceField(body, 'Current Phase Name', phaseName); + if (result) { body = result; updated.push('Current Phase Name'); } } // Update Current Plan to 1 (starting from the first plan) - result = stateReplaceField(content, 'Current Plan', '1'); - if (result) { content = result; updated.push('Current Plan'); } + result = stateReplaceField(body, 'Current Plan', '1'); + if (result) { body = result; updated.push('Current Plan'); } // Update Total Plans in Phase if (planCount) { - result = stateReplaceField(content, 'Total Plans in Phase', String(planCount)); - if (result) { content = result; updated.push('Total Plans in Phase'); } + result = stateReplaceField(body, 'Total Plans in Phase', String(planCount)); + if (result) { body = result; updated.push('Total Plans in Phase'); } } // Update **Current focus:** body text line (#1104) const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`; const focusPattern = /(\*\*Current focus:\*\*\s*).*/i; - if (focusPattern.test(content)) { - content = content.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`); + if (focusPattern.test(body)) { + body = body.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`); updated.push('Current focus'); } // Update ## Current Position section (#1104, #1365) const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = content.match(positionPattern); + const positionMatch = body.match(positionPattern); if (positionMatch) { const header = positionMatch[1]; let posBody = positionMatch[2]; @@ -1830,35 +1848,55 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName const newStatus = `Status: Executing Phase ${phaseNumber}`; if (/^Status:/m.test(posBody)) { posBody = posBody.replace(/^Status:.*$/m, newStatus); + } else { + // Pipe-table format in Current Position (#1255) + const replaced = stateReplaceField(posBody, 'Status', `Executing Phase ${phaseNumber}`); + if (replaced !== null) posBody = replaced; } // Update Last activity line if present const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, newActivity); + } else { + // Pipe-table format in Current Position (#1255) + // Value must match the inline branch (date + narrative), not bare date. + const activityValue = `${today} -- Phase ${phaseNumber} execution started`; + const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) + ?? stateReplaceField(posBody, 'Last activity', activityValue); + if (replaced !== null) posBody = replaced; } - content = content.replace(positionPattern, () => `${header}${posBody}`); + body = body.replace(positionPattern, () => `${header}${posBody}`); updated.push('Current Position'); } } else { // Resume path: only update Last activity timestamp in Current Position // (do not touch Plan:, stopped_at, progress.percent, or plan counter) const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = content.match(positionPattern); + const positionMatch = body.match(positionPattern); if (positionMatch) { const header = positionMatch[1]; let posBody = positionMatch[2]; const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity); - content = content.replace(positionPattern, () => `${header}${posBody}`); + body = body.replace(positionPattern, () => `${header}${posBody}`); updated.push('Last activity (resume)'); + } else { + // Pipe-table format in Current Position (#1255) + const replaced = stateReplaceField(posBody, 'Last Activity', resumeActivity) + ?? stateReplaceField(posBody, 'Last activity', resumeActivity); + if (replaced !== null) { + posBody = replaced; + body = body.replace(positionPattern, () => `${header}${posBody}`); + updated.push('Last activity (resume)'); + } } } } - return content; + return reassemble(body); }, cwd); output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false'); @@ -2522,23 +2560,32 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string readModifyWriteStateMd(statePath, (content) => { const currentPhase = resolvedPhase; - // Update Status field + // Bug #1255: operate on body only so the YAML frontmatter `status:` key + // cannot shadow the body Status field (pipe-table or inline). + const existingFm = extractFrontmatter(content) as Record; + const hasFrontmatter = Object.keys(existingFm).length > 0; + let body = stripFrontmatter(content); + + const reassemble = (b: string) => + hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b; + + // Update Status field (body only — #1255) const statusValue = `Phase ${currentPhase} complete`; - let result = stateReplaceField(content, 'Status', statusValue); - if (result) { content = result; updated.push('Status'); } + let result = stateReplaceField(body, 'Status', statusValue); + if (result) { body = result; updated.push('Status'); } // Update Last Activity date - result = stateReplaceField(content, 'Last Activity', today); - if (result) { content = result; updated.push('Last Activity'); } + result = stateReplaceField(body, 'Last Activity', today); + if (result) { body = result; updated.push('Last Activity'); } // Update Last Activity Description const activityDesc = `Phase ${currentPhase} marked complete`; - result = stateReplaceField(content, 'Last Activity Description', activityDesc); - if (result) { content = result; updated.push('Last Activity Description'); } + result = stateReplaceField(body, 'Last Activity Description', activityDesc); + if (result) { body = result; updated.push('Last Activity Description'); } // Update ## Current Position section const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = content.match(positionPattern); + const positionMatch = body.match(positionPattern); if (positionMatch) { const header = positionMatch[1]; let posBody = positionMatch[2]; @@ -2547,25 +2594,41 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string const newPhase = `Phase: ${currentPhase} — COMPLETE`; if (/^Phase:/m.test(posBody)) { posBody = posBody.replace(/^Phase:.*$/m, newPhase); + } else { + // Pipe-table format in Current Position (#1255) + // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label. + const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`); + if (replaced !== null) posBody = replaced; } // Update Status line if present const newStatus = `Status: Phase ${currentPhase} complete`; if (/^Status:/m.test(posBody)) { posBody = posBody.replace(/^Status:.*$/m, newStatus); + } else { + // Pipe-table format in Current Position (#1255) + const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`); + if (replaced !== null) posBody = replaced; } // Update Last activity line if present const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, newActivity); + } else { + // Pipe-table format in Current Position (#1255) + // Value must match the inline branch (date + narrative), not bare date. + const activityValue = `${today} -- Phase ${currentPhase} marked complete`; + const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) + ?? stateReplaceField(posBody, 'Last activity', activityValue); + if (replaced !== null) posBody = replaced; } - content = content.replace(positionPattern, () => `${header}${posBody}`); + body = body.replace(positionPattern, () => `${header}${posBody}`); updated.push('Current Position'); } - return content; + return reassemble(body); }, cwd); output( diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 5cc6d570c..687383d4d 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -9,6 +9,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { createFixture } = require('./fixtures/index.cjs'); @@ -3655,3 +3656,307 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi ); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #1255 — begin/complete-phase advance status for pipe-table STATE.md +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Regression tests for bug #1255. + * + * `state begin-phase` / `state complete-phase` do not advance the frontmatter + * `status` when the body `Status` field is expressed as a pipe-table row + * (`| Status | Planning |`) instead of an inline key-value pair + * (`Status: Planning`). + * + * Root cause: `stateReplaceField(content, 'Status', ...)` is called with the + * full file content (frontmatter + body). The plain-text pattern + * (`^Status:\s*(.+)` with /im flag) matches `status: planning` in the YAML + * frontmatter block rather than the body pipe-table row. The pipe-table row + * is never updated. `syncStateFrontmatter` then re-derives from the body (which + * still says 'Planning') and the #1230 delta heuristic preserves the old + * frontmatter value ('planning'), so the status never advances to 'executing'. + * + * Fix: strip frontmatter before all body-field replacements in + * `cmdStateBeginPhase` and `cmdStateCompletePhase`, then reassemble. + * + * Additional bugs fixed (#1255 follow-up): + * 1. complete-phase Phase table cell had label-duplication: `Phase: 1 — COMPLETE` + * instead of bare `1 — COMPLETE`. + * 2. begin-phase and complete-phase Last-activity table branches wrote bare date + * instead of date + narrative (inconsistent with inline branch). + */ + +function make1255TempProject(stateContent) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1255-')); + const planningDir = path.join(dir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + // Minimal ROADMAP so buildStateFrontmatter can resolve phase counts + fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [ + '# ROADMAP', + '', + '## Phase 1: setup:', + '- [ ] Step 1', + '', + ].join('\n'), 'utf8'); + fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8'); + return dir; +} + +// STATE.md where Status lives entirely in pipe-table rows (no inline "Status: ..." anywhere) +// This is the form a hand-edited or legacy STATE.md might use, and is a +// supported body format (do NOT silently rewrite to inline). +const TABLE_STATUS_PLANNING_1255 = `--- +gsd_state_version: '1.0' +status: planning +--- + +# Project State + +## Configuration + +| Current Phase | 1 | +| Current Phase Name | setup | +| Total Plans in Phase | 3 | +| Current Plan | 1 | +| Status | Planning | +| Last Activity | 2026-06-01 | +| Last Activity Description | Roadmap created | + +## Current Position + +| Phase | 1 (setup) | +| Plan | 1 of 3 | +| Status | Planning | +| Last activity | 2026-06-01 | +`; + +// STATE.md with Status as pipe-table but execution already in progress (complete-phase scenario) +const TABLE_STATUS_EXECUTING_1255 = `--- +gsd_state_version: '1.0' +status: executing +--- + +# Project State + +## Configuration + +| Current Phase | 1 | +| Current Phase Name | setup | +| Total Plans in Phase | 3 | +| Current Plan | 3 | +| Status | Executing Phase 1 | +| Last Activity | 2026-06-01 | +| Last Activity Description | Phase 1 execution started | + +## Current Position + +| Phase | 1 (setup) | +| Plan | 3 of 3 | +| Status | Executing Phase 1 | +| Last activity | 2026-06-01 | +`; + +describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md', () => { + // begin-phase: planning → executing + test('begin-phase advances frontmatter status planning→executing when body Status is pipe-table', () => { + const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255); + try { + const result = runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'], + dir + ); + assert.ok(result.success, `begin-phase failed: ${result.error || result.output}`); + + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + + // Primary assertion: frontmatter status must advance to 'executing' + const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after begin-phase'); + const fm = fmMatch[1]; + assert.ok( + /^status:\s*executing\s*$/m.test(fm), + `frontmatter status must be 'executing' after begin-phase on pipe-table STATUS; got frontmatter:\n${fm}` + ); + } finally { + cleanup(dir); + } + }); + + // begin-phase: body pipe-table row must also be updated + test('begin-phase updates body pipe-table Status cell to Executing Phase N', () => { + const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255); + try { + runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'], + dir + ); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + // The pipe-table Status cell in the Configuration table must be updated + assert.ok( + /\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(after), + `body pipe-table Status cell must be updated to 'Executing Phase 1'; got:\n${after}` + ); + } finally { + cleanup(dir); + } + }); + + // begin-phase: Current Position table cells — exact cell values + test('begin-phase updates Current Position pipe-table Status and Last activity cells correctly', () => { + const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255); + try { + runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'], + dir + ); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + + // Extract the ## Current Position section only, to avoid matching Configuration rows + const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + assert.ok(cpMatch, '## Current Position section must exist'); + const cpSection = cpMatch[1]; + + // Status cell in Current Position: bare value, not prefixed + assert.ok( + /\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(cpSection), + `Current Position Status cell must be 'Executing Phase 1'; got Current Position:\n${cpSection}` + ); + + // Last activity cell must include date + narrative (not bare date) + assert.ok( + /\|\s*Last activity\s*\|[^|]*--\s*Phase 1 execution started\s*\|/i.test(cpSection), + `Current Position Last activity cell must include narrative '-- Phase 1 execution started'; got Current Position:\n${cpSection}` + ); + } finally { + cleanup(dir); + } + }); + + // complete-phase: executing → completed + test('complete-phase advances frontmatter status executing→completed when body Status is pipe-table', () => { + const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255); + try { + const result = runGsdTools( + ['state', 'complete-phase', '--phase', '1'], + dir + ); + assert.ok(result.success, `complete-phase failed: ${result.error || result.output}`); + + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + + // Primary assertion: frontmatter status must be 'completed' + const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after complete-phase'); + const fm = fmMatch[1]; + assert.ok( + /^status:\s*completed\s*$/m.test(fm), + `frontmatter status must be 'completed' after complete-phase on pipe-table STATUS; got frontmatter:\n${fm}` + ); + } finally { + cleanup(dir); + } + }); + + // complete-phase: body pipe-table row must also be updated + test('complete-phase updates body pipe-table Status cell to Phase N complete', () => { + const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255); + try { + runGsdTools( + ['state', 'complete-phase', '--phase', '1'], + dir + ); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + assert.ok( + /\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(after), + `body pipe-table Status cell must be updated to 'Phase 1 complete'; got:\n${after}` + ); + } finally { + cleanup(dir); + } + }); + + // complete-phase: Current Position table cells — exact cell values (catches bugs 1 and 2) + test('complete-phase updates Current Position pipe-table Phase/Status/Last-activity cells correctly', () => { + const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255); + try { + runGsdTools( + ['state', 'complete-phase', '--phase', '1'], + dir + ); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + + // Extract the ## Current Position section only, to avoid matching Configuration rows + const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + assert.ok(cpMatch, '## Current Position section must exist'); + const cpSection = cpMatch[1]; + + // Bug 1: Phase cell must be bare '1 — COMPLETE', NOT 'Phase: 1 — COMPLETE' + assert.ok( + /\|\s*Phase\s*\|\s*1\s*—\s*COMPLETE\s*\|/.test(cpSection), + `Current Position Phase cell must be '1 — COMPLETE' (no 'Phase:' prefix in cell value); got Current Position:\n${cpSection}` + ); + assert.ok( + !/\|\s*Phase\s*\|\s*Phase:\s*1/.test(cpSection), + `Current Position Phase cell must NOT contain 'Phase: 1' (label-duplication bug); got Current Position:\n${cpSection}` + ); + + // Status cell in Current Position: bare value + assert.ok( + /\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(cpSection), + `Current Position Status cell must be 'Phase 1 complete'; got Current Position:\n${cpSection}` + ); + + // Bug 2: Last activity cell must include date + narrative (not bare date) + assert.ok( + /\|\s*Last activity\s*\|[^|]*--\s*Phase 1 marked complete\s*\|/i.test(cpSection), + `Current Position Last activity cell must include narrative '-- Phase 1 marked complete'; got Current Position:\n${cpSection}` + ); + } finally { + cleanup(dir); + } + }); + + // Regression guard: inline Status format must still work (existing behavior unchanged) + test('begin-phase still works correctly with inline Status: format (regression guard)', () => { + const inlineStateMd = `--- +gsd_state_version: '1.0' +status: planning +--- + +# Project State + +Current Phase: 1 +Current Phase Name: setup +Total Plans in Phase: 3 +Current Plan: 1 +Status: Planning +Last Activity: 2026-06-01 +Last Activity Description: Roadmap created + +## Current Position +Phase: 1 (setup) +Plan: 1 of 3 +Status: Planning +Last activity: 2026-06-01 -- Roadmap created +`; + const dir = make1255TempProject(inlineStateMd); + try { + const result = runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'], + dir + ); + assert.ok(result.success, `begin-phase failed on inline format: ${result.error || result.output}`); + const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); + const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/); + assert.ok(fmMatch, 'must have frontmatter'); + const fm = fmMatch[1]; + assert.ok( + /^status:\s*executing\s*$/m.test(fm), + `inline Status: format: frontmatter status must be 'executing'; got:\n${fm}` + ); + } finally { + cleanup(dir); + } + }); +});