* fix(#2613): preserve STATE.md frontmatter on write path (option 2) `readModifyWriteStateMd` strips frontmatter before invoking the modifier, so `syncStateFrontmatter` received body-only content and `existingFm` was always `{}`. The preservation branch never fired, and every mutation re-derived `status` (to `'unknown'` when body had no `Status:` line) and `progress.*` (to 0/0 when the shipped milestone's phase directories were archived), silently overwriting authoritative frontmatter values. Option 2 — write-side analogue of #2495 READ fix: `buildStateFrontmatter` reads the current STATE.md frontmatter from disk as a preservation backstop. Status preserved when derived is `'unknown'` and existing is non-unknown. Progress preserved when disk scan returns all zeros AND existing has non-zero counts. Legitimate body-driven status changes and non-zero disk counts still win. Milestone/milestone_name already preserved via `getMilestoneInfo`'s #2495 fix — regression test added to lock that in. Adds 5 regression tests covering status preservation, progress preservation, milestone preservation, legitimate status updates, and disk-scan-wins-when-non-zero. Closes #2613 * fix(sdk): double-cast WorkflowConfig to Record in loadGateConfig TypeScript error on main (introduced in #2611) blocks the install-smoke CI job: `WorkflowConfig` has no string index signature, so the direct cast to `Record<string, unknown>` fails type-check. The SDK build fails, `installSdkIfNeeded()` cannot install `gsd-sdk` from source, and the smoke job reports a false-positive installer regression. src/query/check-decision-coverage.ts(236,16): error TS2352: Conversion of type 'WorkflowConfig' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. Apply the double-cast via `unknown` as the compiler suggests. Behavior is unchanged — this was already a cast.
This commit is contained in:
@@ -89,6 +89,19 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
|
||||
const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at');
|
||||
const pausedAt = stateExtractField(bodyContent, 'Paused At');
|
||||
|
||||
// Bug #2613: read existing STATE.md frontmatter as preservation backstop.
|
||||
// The write path through `readModifyWriteStateMd` strips frontmatter before
|
||||
// invoking the modifier, so callers of `buildStateFrontmatter` only see the
|
||||
// body. Without reading frontmatter here, status defaults to 'unknown' when
|
||||
// body has no Status field, and progress is stomped to 0/0 when the current
|
||||
// milestone's phase directories have been archived. Matches the #2495 READ
|
||||
// pattern: STATE.md is authoritative, re-derive only when absent.
|
||||
let existingFm: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8');
|
||||
existingFm = extractFrontmatter(raw);
|
||||
} catch { /* STATE.md missing on first write — no preservation needed */ }
|
||||
|
||||
let milestone: string | null = null;
|
||||
let milestoneName: string | null = null;
|
||||
try {
|
||||
@@ -160,6 +173,12 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
|
||||
normalizedStatus = 'executing';
|
||||
}
|
||||
|
||||
// Bug #2613: status preservation — if body has no Status field and existing
|
||||
// frontmatter has a non-unknown status, prefer existing.
|
||||
if (normalizedStatus === 'unknown' && typeof existingFm.status === 'string' && existingFm.status && existingFm.status !== 'unknown') {
|
||||
normalizedStatus = existingFm.status;
|
||||
}
|
||||
|
||||
const fm: Record<string, unknown> = { gsd_state_version: '1.0' };
|
||||
|
||||
if (milestone) fm.milestone = milestone;
|
||||
@@ -181,6 +200,20 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
|
||||
if (progressPercent !== null) progress.percent = progressPercent;
|
||||
if (Object.keys(progress).length > 0) fm.progress = progress;
|
||||
|
||||
// Bug #2613: progress preservation — when disk scan returns zero counts
|
||||
// (archived/shipped milestone) and existing frontmatter has non-zero counts,
|
||||
// prefer existing. Legitimate mid-milestone updates see non-zero disk counts
|
||||
// and fall through, keeping disk as ground truth.
|
||||
const existingProgress = existingFm.progress as Record<string, unknown> | undefined;
|
||||
if (existingProgress && typeof existingProgress === 'object') {
|
||||
const derivedTotalPlans = Number(progress.total_plans ?? 0);
|
||||
const derivedCompletedPlans = Number(progress.completed_plans ?? 0);
|
||||
const existingTotalPlans = Number(existingProgress.total_plans ?? 0);
|
||||
if (derivedTotalPlans === 0 && derivedCompletedPlans === 0 && existingTotalPlans > 0) {
|
||||
fm.progress = existingProgress;
|
||||
}
|
||||
}
|
||||
|
||||
return fm;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user