diff --git a/.changeset/fair-lions-patch.md b/.changeset/fair-lions-patch.md new file mode 100644 index 000000000..127c21c62 --- /dev/null +++ b/.changeset/fair-lions-patch.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1345 +--- +Preserve curated STATE.md progress frontmatter when `state patch` updates non-progress fields, while still allowing progress-related fields to resync from disk-derived project state. diff --git a/src/state.cts b/src/state.cts index 4026af13d..d51a0bf50 100644 --- a/src/state.cts +++ b/src/state.cts @@ -114,6 +114,21 @@ interface PrunedSection { lines: string[]; } +const STATE_PROGRESS_RESYNC_FIELDS = new Set([ + 'Progress', + 'Total Plans in Phase', + 'Total Phases', +]); + +function shouldResyncStateProgress(fields: Iterable): boolean { + for (const field of fields) { + if (STATE_PROGRESS_RESYNC_FIELDS.has(field)) { + return true; + } + } + return false; +} + // ─── Cache ──────────────────────────────────────────────────────────────────── // Cache disk scan results from buildStateFrontmatter per cwd per process (#1967). @@ -258,6 +273,7 @@ function cmdStatePatch(cwd: string, patches: Record, raw: boolea const statePath = planningPaths(cwd).state; try { const results: { updated: string[]; failed: string[] } = { updated: [], failed: [] }; + const shouldResync = shouldResyncStateProgress(Object.keys(patches)); // Use atomic read-modify-write to prevent lost updates from concurrent agents readModifyWriteStateMd(statePath, (content) => { @@ -271,7 +287,7 @@ function cmdStatePatch(cwd: string, patches: Record, raw: boolea } } return content; - }, cwd); + }, cwd, { resync: shouldResync }); output(results, raw, results.updated.length > 0 ? 'true' : 'false'); } catch { @@ -295,7 +311,7 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string | const statePath = planningPaths(cwd).state; try { let updated = false; - const shouldResync = ['Progress', 'Total Plans in Phase', 'Total Phases'].includes(field as string); + const shouldResync = shouldResyncStateProgress([field as string]); // Preserve curated progress for body-only updates, but allow fields that // directly project into progress.* frontmatter to rebuild after mutation. readModifyWriteStateMd(statePath, (content) => { diff --git a/tests/bug-3242-state-update-progress-trample.test.cjs b/tests/bug-3242-state-update-progress-trample.test.cjs index 5ceb8837d..d1fc5bff8 100644 --- a/tests/bug-3242-state-update-progress-trample.test.cjs +++ b/tests/bug-3242-state-update-progress-trample.test.cjs @@ -18,6 +18,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -89,6 +90,32 @@ function createPhaseDirs(phasesDir, count) { } } +function createPhasePlanOnlyDirs(phasesDir, count) { + for (let i = 1; i <= count; i++) { + const dir = path.join(phasesDir, String(i).padStart(2, '0')); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `01-PLAN.md`), `# Plan\n`); + } +} + +function readPersistedProgress(statePath) { + const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf-8')); + assert.ok(fm.progress, 'persisted frontmatter must have a progress block'); + return Object.fromEntries( + Object.entries(fm.progress).map(([key, value]) => [key, Number(value)]), + ); +} + +function assertProgressEquals(actual, expected) { + for (const [key, value] of Object.entries(expected)) { + assert.strictEqual( + actual[key], + value, + `persisted progress.${key} expected ${value}, got ${actual[key]}`, + ); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Bug A: state.update must not trample curated progress.* frontmatter // ───────────────────────────────────────────────────────────────────────────── @@ -205,6 +232,88 @@ describe('#3242 Bug A: body-only state.update preserves curated progress frontma }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #1264: state.patch must apply the same progress preservation policy +// ───────────────────────────────────────────────────────────────────────────── + +describe('#1264: state.patch preserves curated progress frontmatter for non-progress fields', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('query state.patch of Current Phase preserves persisted progress.* values', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const curatedProgress = { + total_phases: 4, + completed_phases: 3, + total_plans: 11, + completed_plans: 11, + percent: 75, + }; + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: curatedProgress.completed_plans, + totalPlans: curatedProgress.total_plans, + completedPhases: curatedProgress.completed_phases, + totalPhases: curatedProgress.total_phases, + percent: curatedProgress.percent, + })); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(5), + ); + createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 5); + + const patchResult = runGsdTools([ + 'query', + 'state.patch', + JSON.stringify({ 'Current Phase': '08.2' }), + ], tmpDir); + assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); + + const output = JSON.parse(patchResult.output); + assert.deepEqual(output.updated, ['Current Phase']); + + const progress = readPersistedProgress(statePath); + assertProgressEquals(progress, curatedProgress); + }); + + test('query state.patch of Total Plans in Phase still resyncs persisted progress.* from the updated body', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateWithCuratedProgress({ + completedPlans: 22, + totalPlans: 22, + completedPhases: 6, + totalPhases: 12, + percent: 50, + })); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + buildRoadmap(8), + ); + createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 8); + + const patchResult = runGsdTools([ + 'query', + 'state.patch', + JSON.stringify({ 'Total Plans in Phase': '8' }), + ], tmpDir); + assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`); + + const output = JSON.parse(patchResult.output); + assert.deepEqual(output.updated, ['Total Plans in Phase']); + + const progress = readPersistedProgress(statePath); + assert.strictEqual(progress.total_plans, 8); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Bug B: progress.percent must use min(plan_fraction, phase_fraction) // ─────────────────────────────────────────────────────────────────────────────