* fix(#1230): preserve frontmatter status/stopped_at when a state write doesn't change the body source Adds a delta heuristic in `readModifyWriteStateMd`: snapshots the body Status and Stopped At fields before the transform, then after syncStateFrontmatter runs, restores the existing frontmatter values for any field whose body source was not changed by this write. Integrates cleanly with the existing !resync progress-restore block (computes postFm once, applies both restorations, reconstructs frontmatter once). Regression tests added to bug-905 test file (same theme, no new file). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#1230): add Fixed changeset fragment --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
618 lines
26 KiB
JavaScript
618 lines
26 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Regression guard for bug #905.
|
|
*
|
|
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
|
|
* frontmatter when the body-derived value was missing/unknown. The scalars
|
|
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
|
|
* silently stripped whenever `buildStateFrontmatter` could not extract them from
|
|
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
|
|
* annotations.
|
|
*
|
|
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
|
|
* that all four scalars survive a `writeStateMd` / `state sync` call when the
|
|
* body no longer carries the annotation but the existing frontmatter does.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Fixture builders
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
|
|
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
|
|
* annotations that `buildStateFrontmatter` uses to re-derive them.
|
|
*
|
|
* This is the exact scenario that triggered the bug: the body has already lost
|
|
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
|
|
* frontmatter still holds the ground-truth values. A subsequent `state sync`
|
|
* (or any `writeStateMd` call) must not strip them.
|
|
*/
|
|
function buildStateMdWithoutBodyAnnotations(opts) {
|
|
const {
|
|
currentPhase = 3,
|
|
currentPhaseName = 'Implementation',
|
|
currentPlan = 2,
|
|
progressPercent = 42,
|
|
} = opts || {};
|
|
|
|
return [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
`current_phase: ${currentPhase}`,
|
|
`current_phase_name: ${currentPhaseName}`,
|
|
`current_plan: ${currentPlan}`,
|
|
'status: executing',
|
|
'progress:',
|
|
` total_phases: 5`,
|
|
` completed_phases: 2`,
|
|
` total_plans: 10`,
|
|
` completed_plans: 4`,
|
|
` percent: ${progressPercent}`,
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
// Intentionally omitting "Current Phase:", "Current Phase Name:",
|
|
// "Current Plan:" body annotations to reproduce the bug scenario.
|
|
'Status: Executing',
|
|
'Last Activity: 2026-01-01',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'### Decisions',
|
|
'',
|
|
'- Use Node 22',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
|
|
|
|
const syncResult = runGsdTools('state sync', tmpDir);
|
|
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.strictEqual(
|
|
fm.current_phase,
|
|
'3',
|
|
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
|
|
);
|
|
});
|
|
|
|
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
|
|
|
|
const syncResult = runGsdTools('state sync', tmpDir);
|
|
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.strictEqual(
|
|
fm.current_phase_name,
|
|
'Implementation',
|
|
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
|
|
);
|
|
});
|
|
|
|
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
|
|
|
|
const syncResult = runGsdTools('state sync', tmpDir);
|
|
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.strictEqual(
|
|
fm.current_plan,
|
|
'2',
|
|
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
|
|
);
|
|
});
|
|
|
|
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
|
|
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
|
|
// That path runs syncStateFrontmatter and then explicitly re-applies the
|
|
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
|
|
// progress values must survive even though the phases dir is empty.
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
|
|
|
|
// Add body annotation for Last Activity so state update can find and replace it
|
|
const initial = fs.readFileSync(statePath, 'utf8');
|
|
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
|
|
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
|
|
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
|
|
// → curated block survives via cmdStateJson read-path fallback.
|
|
assert.strictEqual(
|
|
fm.progress.completed_plans,
|
|
4,
|
|
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
|
|
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
|
|
);
|
|
});
|
|
|
|
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
|
|
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
|
|
// with resync:true), confirming the fix covers every write path.
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
|
|
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.strictEqual(
|
|
fm.current_phase,
|
|
'7',
|
|
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
|
|
);
|
|
});
|
|
|
|
test('body annotation beats existing frontmatter when both are present', () => {
|
|
// When the body DOES carry the annotation, the derived value wins — we must
|
|
// not accidentally lock stale frontmatter in place.
|
|
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
|
|
// which rebuilds from the body and would return body-derived values regardless
|
|
// of what syncStateFrontmatter wrote to disk).
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
// Frontmatter says phase 3; body says phase 5. Body should win.
|
|
fs.writeFileSync(statePath, [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'current_phase: 3',
|
|
'current_phase_name: Old Phase',
|
|
'current_plan: 1',
|
|
'status: executing',
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 5',
|
|
'Current Phase Name: New Phase',
|
|
'Current Plan: 2',
|
|
'Status: Executing',
|
|
'Last Activity: 2026-01-01',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const syncResult = runGsdTools('state sync', tmpDir);
|
|
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
|
|
// Assert on raw file: body-derived values must be written to frontmatter,
|
|
// not the stale existing values. This guards against a fallback that locks
|
|
// in stale data even when buildStateFrontmatter successfully derived values.
|
|
const writtenContent = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(writtenContent);
|
|
assert.strictEqual(
|
|
rawFm.current_phase,
|
|
'5',
|
|
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
|
|
);
|
|
assert.strictEqual(
|
|
rawFm.current_phase_name,
|
|
'New Phase',
|
|
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
|
|
);
|
|
assert.strictEqual(
|
|
rawFm.current_plan,
|
|
'2',
|
|
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
|
|
);
|
|
});
|
|
|
|
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
|
|
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
|
|
// Without a phases dir, buildStateFrontmatter returns no progress block at all
|
|
// (the existsSync guard at line ~927 short-circuits the disk scan). The
|
|
// existing frontmatter's progress must then survive the writeStateMd call.
|
|
// Use createTempDir (no phases dir) and set up .planning/ manually.
|
|
const dir = createTempDir('gsd-905-nophasesdir-');
|
|
try {
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
const statePath = path.join(dir, '.planning', 'STATE.md');
|
|
|
|
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
|
|
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
|
|
fs.writeFileSync(statePath, [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'current_phase: 2',
|
|
'status: executing',
|
|
'progress:',
|
|
' total_phases: 4',
|
|
' completed_phases: 1',
|
|
' total_plans: 8',
|
|
' completed_plans: 3',
|
|
' percent: 38',
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 2',
|
|
'Status: Executing',
|
|
'Last Activity: 2026-01-01',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
|
|
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
|
|
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
|
|
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Last Activity', '2026-06-08'],
|
|
dir,
|
|
);
|
|
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
|
|
// Assert on the raw frontmatter file — cmdStateJson would apply
|
|
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
|
|
// The progress block must be present in the written frontmatter.
|
|
// parseFrontmatter returns flat keys, so check the presence indicator.
|
|
assert.ok(
|
|
written.includes('progress:'),
|
|
'progress block must be preserved in raw frontmatter when disk has no phases dir',
|
|
);
|
|
// percent: 38 should survive (no disk scan to overwrite it)
|
|
assert.ok(
|
|
written.includes('percent: 38'),
|
|
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
|
|
);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Bug #1230 regression suite
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a STATE.md where:
|
|
* - frontmatter has an explicit status (e.g. 'completed') and optional stopped_at
|
|
* - body has a Status: field that is STALE relative to the frontmatter
|
|
* (e.g. "Verifying Phase 3" would derive 'verifying')
|
|
* A subsequent incidental write must NOT revert the hand-set frontmatter status.
|
|
*/
|
|
function buildStateMd1230({ fmStatus = 'completed', fmStoppedAt = null, bodyStatus = 'Verifying Phase 3', bodyStoppedAt = null } = {}) {
|
|
const fmLines = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
`status: ${fmStatus}`,
|
|
];
|
|
if (fmStoppedAt) fmLines.push(`stopped_at: "${fmStoppedAt}"`);
|
|
fmLines.push('---');
|
|
|
|
const bodyLines = [
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
`Status: ${bodyStatus}`,
|
|
'Last Activity: 2026-01-01',
|
|
`Current Phase: 3`,
|
|
'',
|
|
'## Session',
|
|
'',
|
|
'**Last session:** 2026-01-01T00:00:00.000Z',
|
|
];
|
|
if (bodyStoppedAt) bodyLines.push(`**Stopped at:** ${bodyStoppedAt}`);
|
|
bodyLines.push('');
|
|
|
|
return [...fmLines, ...bodyLines].join('\n');
|
|
}
|
|
|
|
describe('bug #1230: readModifyWriteStateMd preserves frontmatter status/stopped_at when write does not change body source field', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// (a) CORE: record-session with stale body Status leaves frontmatter status: completed intact
|
|
test('(a) record-session does NOT revert frontmatter status: completed when body Status is unchanged', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
// frontmatter status: completed; body Status: Verifying Phase 3 (derives 'verifying')
|
|
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
|
|
|
const result = runGsdTools(
|
|
['state', 'record-session', '--stopped-at', 'Phase 3 final review checkpoint'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `record-session failed: ${result.error}`);
|
|
|
|
// Assert on raw file frontmatter — not state json (which re-derives)
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
assert.strictEqual(
|
|
rawFm.status,
|
|
'completed',
|
|
`frontmatter status must remain 'completed' after record-session (body Status unchanged); got: ${JSON.stringify(rawFm.status)}`,
|
|
);
|
|
});
|
|
|
|
// (b) add-decision (resync:true) with frontmatter status: completed, stale body → status preserved
|
|
test('(b) add-decision (resync:true) does NOT revert frontmatter status: completed when body Status unchanged', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Verifying Phase 3' }));
|
|
|
|
const result = runGsdTools(
|
|
['state', 'add-decision', '--phase', '3', '--summary', 'Use Node 22'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
|
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
assert.strictEqual(
|
|
rawFm.status,
|
|
'completed',
|
|
`frontmatter status must remain 'completed' after add-decision; got: ${JSON.stringify(rawFm.status)}`,
|
|
);
|
|
});
|
|
|
|
// (c) LEGITIMATE UPDATE NOT FROZEN: begin-phase changes body Status → frontmatter re-derived correctly.
|
|
//
|
|
// stateReplaceField(content, 'Status', ...) replaces the FIRST ^Status: match in the
|
|
// full content (frontmatter + body). When the frontmatter has NO 'status:' key, the
|
|
// match falls through to the body 'Status:' line, which IS changed. The delta then
|
|
// fires (preBodyStatus ≠ postBodyStatus), the preservation guard is skipped, and
|
|
// syncStateFrontmatter re-derives from the new body value as intended.
|
|
test('(c) begin-phase changes body Status → frontmatter status reflects new body-derived value', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
// Frontmatter intentionally has NO 'status:' key so stateReplaceField targets the body
|
|
// 'Status:' line. After the transform, body Status becomes "Executing Phase 3" →
|
|
// normalizeStateStatus → 'executing' must be written to frontmatter.
|
|
const content = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v1.0',
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Status: Ready to execute',
|
|
'Last Activity: 2026-01-01',
|
|
'Current Phase: 2',
|
|
'Current Phase Name: Planning',
|
|
'Current Plan: 1',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 2 (Planning) — READY',
|
|
'Plan: 1 of 1',
|
|
'Status: Ready to execute',
|
|
'Last activity: 2026-01-01 -- Phase 2 planning complete',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(statePath, content);
|
|
|
|
const result = runGsdTools(
|
|
['state', 'begin-phase', '--phase', '3', '--name', 'Execution'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `begin-phase failed: ${result.error}`);
|
|
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
// begin-phase changed body Status to "Executing Phase 3" → delta fired → re-derived → 'executing'
|
|
assert.strictEqual(
|
|
rawFm.status,
|
|
'executing',
|
|
`frontmatter status must be updated to 'executing' when begin-phase changes body Status; got: ${JSON.stringify(rawFm.status)}`,
|
|
);
|
|
});
|
|
|
|
// (d) stopped_at: TRUE RED — frontmatter stopped_at preserved when body Stopped at differs
|
|
//
|
|
// Change C: this is a TRUE regression guard. Frontmatter stopped_at ("Phase 7 verified PASS")
|
|
// differs from the body ## Session "Stopped at:" value ("Phase 3 work"). The write operation
|
|
// (add-decision) does NOT touch the Session Stopped at line. The delta heuristic must detect
|
|
// that the Session Stopped at did NOT change (pre == post == "Phase 3 work") and therefore
|
|
// preserve the frontmatter value "Phase 7 verified PASS". Pre-fix code would REVERT to
|
|
// "Phase 3 work" (the body-derived value) — making this a genuine red.
|
|
test('(d) add-decision preserves frontmatter stopped_at when body Session Stopped at differs from frontmatter and is unchanged', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
const content = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'status: completed',
|
|
'stopped_at: "Phase 7 verified PASS"',
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Status: Phase 3 complete',
|
|
'Last Activity: 2026-01-01',
|
|
'Current Phase: 3',
|
|
'',
|
|
'## Session',
|
|
'',
|
|
'**Last session:** 2026-01-01T00:00:00.000Z',
|
|
// body Session Stopped at is STALE (different from frontmatter stopped_at)
|
|
'**Stopped at:** Phase 3 work',
|
|
'**Resume file:** None',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(statePath, content);
|
|
|
|
// add-decision does NOT touch ## Session Stopped at → pre and post body value identical
|
|
// ("Phase 3 work" unchanged) → delta fires → frontmatter "Phase 7 verified PASS" preserved.
|
|
const result = runGsdTools(
|
|
['state', 'add-decision', '--phase', '3', '--summary', 'Preserve stopped_at check'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `add-decision failed: ${result.error}`);
|
|
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
assert.strictEqual(
|
|
rawFm.stopped_at,
|
|
'Phase 7 verified PASS',
|
|
`frontmatter stopped_at must be preserved ("Phase 7 verified PASS") when body Session Stopped at ` +
|
|
`is unchanged (stale "Phase 3 work"); got: ${JSON.stringify(rawFm.stopped_at)}`,
|
|
);
|
|
});
|
|
|
|
// (f) PRODUCTION-PATH: legitimate status transition is NOT frozen by the delta heuristic.
|
|
//
|
|
// Change A: prove that begin-phase on a STATE.md with inline "Status: Executing Phase 1"
|
|
// (the standard template format) correctly transitions frontmatter status from
|
|
// 'executing' to a new 'executing' value when the body Status CHANGES.
|
|
// More critically: also verify a complete-phase → frontmatter becomes 'completed'
|
|
// when the body Status field IS changed. This locks in that the delta heuristic
|
|
// re-derives correctly whenever the body's Status source field actually changes.
|
|
test('(f) begin-phase changes inline body Status → delta fires → frontmatter status updated (not frozen)', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
// Realistic STATE.md: frontmatter status: executing, body Status: Executing Phase 1 (inline format)
|
|
const content = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'status: executing',
|
|
'current_phase: 1',
|
|
'current_phase_name: Planning',
|
|
'current_plan: 1',
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Status: Executing Phase 1',
|
|
'Last Activity: 2026-01-01',
|
|
'Current Phase: 1',
|
|
'Current Phase Name: Planning',
|
|
'Current Plan: 1',
|
|
'Total Plans in Phase: 2',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(statePath, content);
|
|
|
|
// begin-phase 2 changes body Status from "Executing Phase 1" to "Executing Phase 2"
|
|
// → pre and post body Status differ → delta does NOT fire → syncStateFrontmatter
|
|
// re-derives status from new body value → frontmatter status must reflect 'executing'
|
|
// (still 'executing' after begin-phase 2 is a correct transition).
|
|
const beginResult = runGsdTools(
|
|
['state', 'begin-phase', '--phase', '2', '--name', 'Implementation'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(beginResult.success, `state begin-phase failed: ${beginResult.error}`);
|
|
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
// Frontmatter status must have been updated (not frozen at original 'executing' for phase 1).
|
|
// After begin-phase 2, body Status becomes "Executing Phase 2" → re-derived → still 'executing'
|
|
// but it must NOT be the stale body-derived value from before the transform; the key check is
|
|
// that the write completed successfully and status is a known valid value.
|
|
assert.ok(
|
|
rawFm.status === 'executing',
|
|
`frontmatter status must be 'executing' after begin-phase 2 (delta fires, re-derived); got: ${JSON.stringify(rawFm.status)}`,
|
|
);
|
|
|
|
// Stronger check: if we then run a command that changes Status to a DIFFERENT value,
|
|
// the frontmatter MUST reflect the new body-derived status (not be frozen).
|
|
// Use state update to change Status to "Phase 2 complete" → derives 'completed'.
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Status', 'Phase 2 complete'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(updateResult.success, `state update Status failed: ${updateResult.error}`);
|
|
|
|
const written2 = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm2 = parseFrontmatter(written2);
|
|
assert.strictEqual(
|
|
rawFm2.status,
|
|
'completed',
|
|
`frontmatter status must be 'completed' after body Status → 'Phase 2 complete' (delta fires, re-derived); ` +
|
|
`got: ${JSON.stringify(rawFm2.status)}. The delta heuristic must NOT freeze status when the body field changes.`,
|
|
);
|
|
});
|
|
|
|
// (e) milestone-switch uses platformWriteSync directly (not RMW) — check it still resets status correctly
|
|
test('(e) milestone-switch still resets frontmatter status to planning (uses writeStateMd path, not RMW)', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateMd1230({ fmStatus: 'completed', bodyStatus: 'Phase 3 complete' }));
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap\n\n## v2.0 Next\n\n### Phase 4: Next steps\n',
|
|
'utf-8',
|
|
);
|
|
|
|
const result = runGsdTools(
|
|
['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'Next'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `milestone-switch failed: ${result.error}`);
|
|
|
|
const written = fs.readFileSync(statePath, 'utf8');
|
|
const rawFm = parseFrontmatter(written);
|
|
assert.strictEqual(
|
|
rawFm.status,
|
|
'planning',
|
|
`milestone-switch must reset frontmatter status to 'planning'; got: ${JSON.stringify(rawFm.status)}`,
|
|
);
|
|
});
|
|
});
|