From 7ccf57200d51913e6144886260a5cd68c35bb194 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 13 Jul 2026 12:40:52 -0400 Subject: [PATCH] fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter (#2233) * fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter syncStateFrontmatter rebuilds frontmatter from a fixed schema, dropping any custom/unknown key on every mutating verb. Before reconstruction, merge any existing frontmatter key the schema does not own. Schema keys still win. Closes #2202 * docs(#2202): add changeset fragment * docs(#2202): backfill PR number * fix(#2202): add regression test + remove redundant type assertion - tests/state.test.cjs: behavioral regression test asserting custom/unknown STATE.md frontmatter keys survive a mutating verb (they were silently dropped before the syncStateFrontmatter carry-forward). - src/state.cts: drop the unnecessary `as Record` assertion that tripped @typescript-eslint/no-unnecessary-type-assertion (the lint-tests gate failure). Refs #2202 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/agile-pandas-dance.md | 5 +++++ src/state.cts | 10 ++++++++++ tests/state.test.cjs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 .changeset/agile-pandas-dance.md diff --git a/.changeset/agile-pandas-dance.md b/.changeset/agile-pandas-dance.md new file mode 100644 index 000000000..5041d24ea --- /dev/null +++ b/.changeset/agile-pandas-dance.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2233 +--- +**Custom STATE.md frontmatter keys are no longer dropped on every mutating verb** — syncStateFrontmatter rebuilt the frontmatter from a fixed schema, silently dropping any custom key. It now carries forward existing keys the schema does not own. (#2202) diff --git a/src/state.cts b/src/state.cts index 5e59b7370..f7f3811ac 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1650,6 +1650,16 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']); } + // #2202: carry forward any existing frontmatter key that the schema does not + // own, so custom/unknown keys are not silently dropped on every mutating verb. + // Schema-owned keys (already in derivedFm from buildStateFrontmatter + the + // preserve guards above) still win. + for (const key of Object.keys(existingFm)) { + if (!(key in derivedFm) && existingFm[key] !== undefined) { + derivedFm[key] = existingFm[key]; + } + } + const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter); return `---\n${yamlStr}\n---\n\n${body}`; } diff --git a/tests/state.test.cjs b/tests/state.test.cjs index eaf61a34a..5257bb23d 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -611,6 +611,37 @@ milestone: v1.0 assert.ok(!content.includes('status: unknown'), 'should not contain unknown status'); }); + test('#2202: preserves unknown frontmatter keys the schema does not own', () => { + // Regression: a mutating verb rewrites STATE.md via syncStateFrontmatter, + // which rebuilds frontmatter from the body + schema. Before #2202 it dropped + // any frontmatter key the schema does not own; custom/tooling keys must + // survive every write. + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `--- +status: executing +milestone: v1.0 +custom_tracking_id: ABC-123 +team: platform +--- + +# Project State + +**Current Phase:** 03 +**Current Plan:** 03-02 +` + ); + + // Any writeStateMd triggers syncStateFrontmatter. + runGsdTools('state update "Current Plan" "03-03"', tmpDir); + + const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(content, /custom_tracking_id: ABC-123/, 'unknown key custom_tracking_id must be preserved'); + assert.match(content, /team: platform/, 'unknown key team must be preserved'); + // Schema-owned keys still win / survive alongside the carried-forward keys. + assert.ok(content.includes('status: executing'), 'schema-owned status still preserved'); + }); + test('round-trip: write then read via state json', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'),