diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index a7d769f46..6fe9dfa09 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -239,6 +239,32 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) { return content; } +/** + * Update fields within the ## Current Position section of STATE.md. + * This keeps the Current Position body in sync with the bold frontmatter fields. + * Only updates fields that already exist in the section; does not add new lines. + * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase. + */ +function updateCurrentPositionFields(content, fields) { + const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const posMatch = content.match(posPattern); + if (!posMatch) return content; + + let posBody = posMatch[2]; + + if (fields.status && /^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`); + } + if (fields.lastActivity && /^Last activity:/im.test(posBody)) { + posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`); + } + if (fields.plan && /^Plan:/m.test(posBody)) { + posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`); + } + + return content.replace(posPattern, `${posMatch[1]}${posBody}`); +} + function cmdStateAdvancePlan(cwd, raw) { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } @@ -273,19 +299,23 @@ function cmdStateAdvancePlan(cwd, raw) { if (currentPlan >= totalPlans) { content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification'); content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today); + content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today }); writeStateMd(statePath, content, cwd); output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false'); } else { const newPlan = currentPlan + 1; + let planDisplayValue; if (useCompoundFormat) { // Preserve compound format: "X of Y in current phase" → replace X only - const newPlanValue = planField.replace(/^\d+/, String(newPlan)); - content = stateReplaceField(content, 'Plan', newPlanValue) || content; + planDisplayValue = planField.replace(/^\d+/, String(newPlan)); + content = stateReplaceField(content, 'Plan', planDisplayValue) || content; } else { + planDisplayValue = `${newPlan} of ${totalPlans}`; content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content; } content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute'); content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today); + content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue }); writeStateMd(statePath, content, cwd); output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true'); } @@ -882,12 +912,44 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) { updated.push('Current focus'); } - // Update ## Current Position section (#1104) + // Update ## Current Position section (#1104, #1365) + // Update individual fields within Current Position instead of replacing the + // entire section, so that Status, Last activity, and Progress are preserved. const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; const positionMatch = content.match(positionPattern); if (positionMatch) { - const newPosition = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING\nPlan: 1 of ${planCount || '?'}\n`; - content = content.replace(positionPattern, (_match, header) => `${header}${newPosition}`); + const header = positionMatch[1]; + let posBody = positionMatch[2]; + + // Update or insert Phase line + const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`; + if (/^Phase:/m.test(posBody)) { + posBody = posBody.replace(/^Phase:.*$/m, newPhase); + } else { + posBody = newPhase + '\n' + posBody; + } + + // Update or insert Plan line + const newPlan = `Plan: 1 of ${planCount || '?'}`; + if (/^Plan:/m.test(posBody)) { + posBody = posBody.replace(/^Plan:.*$/m, newPlan); + } else { + posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`); + } + + // Update Status line if present + const newStatus = `Status: Executing Phase ${phaseNumber}`; + if (/^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, newStatus); + } + + // 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); + } + + content = content.replace(positionPattern, `${header}${posBody}`); updated.push('Current Position'); } diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 721de2871..c6e3bfa11 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1482,6 +1482,130 @@ describe('milestone-scoped phase counting in frontmatter', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// begin-phase — field preservation (#1365) +// ───────────────────────────────────────────────────────────────────────────── + +describe('state begin-phase preserves Current Position fields (#1365)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('begin-phase preserves Status, Last activity, and Progress in Current Position', () => { + const stateMd = `# Project State + +**Current Phase:** 1 +**Current Phase Name:** setup +**Total Phases:** 5 +**Current Plan:** 0 +**Total Plans in Phase:** 0 +**Status:** Ready to plan +**Last Activity:** 2026-03-20 +**Last Activity Description:** Roadmap created + +## Current Position +Phase: 1 of 5 (setup) +Plan: 0 of ? in current phase +Status: Ready to plan +Last activity: 2026-03-20 -- Roadmap created +Progress: [..........] 0% + +## Decisions Made + +| Phase | Decision | Rationale | +|-------|----------|-----------| +`; + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + const result = runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '4'], + tmpDir + ); + assert.ok(result.success, `Command failed: ${result.error}`); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8' + ); + + // Extract the Current Position section + const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + assert.ok(posMatch, 'Current Position section should exist'); + const posSection = posMatch[1]; + + // Phase and Plan lines should be updated + assert.ok(/^Phase:.*EXECUTING/m.test(posSection), 'Phase line should say EXECUTING'); + assert.ok(/^Plan:.*1 of 4/m.test(posSection), 'Plan line should show 1 of 4'); + + // Status, Last activity, and Progress must still be present (the bug destroys these) + assert.ok(/^Status:/m.test(posSection), + 'Status field must be preserved in Current Position'); + assert.ok(/^Last activity:/m.test(posSection), + 'Last activity field must be preserved in Current Position'); + assert.ok(/^Progress:/m.test(posSection), + 'Progress field must be preserved in Current Position'); + }); + + test('advance-plan can update Status after begin-phase', () => { + // Simulates the full workflow: begin-phase then advance through all plans + const stateMd = `# Project State + +**Current Phase:** 1 +**Current Phase Name:** setup +**Total Phases:** 5 +**Current Plan:** 0 +**Total Plans in Phase:** 0 +**Status:** Ready to plan +**Last Activity:** 2026-03-20 +**Last Activity Description:** Roadmap created + +## Current Position +Phase: 1 of 5 (setup) +Plan: 0 of ? in current phase +Status: Ready to plan +Last activity: 2026-03-20 -- Roadmap created +Progress: [..........] 0% + +## Decisions Made + +| Phase | Decision | Rationale | +|-------|----------|-----------| +`; + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd); + + // Step 1: begin-phase + const beginResult = runGsdTools( + ['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '2'], + tmpDir + ); + assert.ok(beginResult.success, `begin-phase failed: ${beginResult.error}`); + + // Step 2: advance-plan to go from plan 1 to plan 2 + const adv1 = runGsdTools(['state', 'advance-plan'], tmpDir); + assert.ok(adv1.success, `advance-plan 1 failed: ${adv1.error}`); + + // Step 3: advance-plan again — plan 2 of 2 is the last, should set "Phase complete" + const adv2 = runGsdTools(['state', 'advance-plan'], tmpDir); + assert.ok(adv2.success, `advance-plan 2 failed: ${adv2.error}`); + + const content = fs.readFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8' + ); + const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i); + assert.ok(posMatch, 'Current Position section should exist after advance-plan'); + const posSection = posMatch[1]; + + // After advancing past all plans, Status should say "Phase complete" + assert.ok(/Status:.*Phase complete/i.test(posSection), + 'Status should be updated to "Phase complete" after last advance-plan'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // summary-extract command // ─────────────────────────────────────────────────────────────────────────────