diff --git a/.changeset/rapid-pumas-parade.md b/.changeset/rapid-pumas-parade.md new file mode 100644 index 000000000..110caa7e8 --- /dev/null +++ b/.changeset/rapid-pumas-parade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4510 +--- +**STATE.md field reads now target declared field lines** — prose lookalikes are ignored while indented bold fields remain readable, keeping CLI output, sync diagnostics, and writers aligned. diff --git a/src/state-document.cts b/src/state-document.cts index 15f6bb7cf..28e2d6d35 100644 --- a/src/state-document.cts +++ b/src/state-document.cts @@ -371,7 +371,7 @@ export function stateFieldContinuation(content: string, fieldName: string): stri // is deliberately absent: a `| Field | value |` row is bounded by its closing // pipe and cannot wrap. const match = - new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i').exec(content) ?? + new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im').exec(content) ?? new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im').exec(content); if (!match) return null; @@ -400,8 +400,9 @@ export function stateFieldContinuation(content: string, fieldName: string): stri export function stateExtractField(content: string, fieldName: string): string | null { const escaped = escapeRegex(fieldName); - // Bold inline format: **FieldName:** value - const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i'); + // Bold line-start format: **FieldName:** value. Leading same-line whitespace + // matches the writer's established indented-field tolerance. + const boldPattern = new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im'); const boldMatch = content.match(boldPattern); if (boldMatch) return boldMatch[1].trim(); diff --git a/src/state.cts b/src/state.cts index 521cd1461..c294aee79 100644 --- a/src/state.cts +++ b/src/state.cts @@ -588,7 +588,7 @@ function cmdStateGet(cwd: string, section: string | undefined, raw: boolean): vo const fieldEscaped = escapeRegex(section); // Check for **field:** value (bold format) - const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i'); + const boldPattern = new RegExp(`^[ \\t]*\\*\\*${fieldEscaped}:\\*\\*[ \\t]*(.*)`, 'im'); const boldMatch = content.match(boldPattern); if (boldMatch) { output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim()); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 9a9ff3790..ec2a1173b 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1590,6 +1590,18 @@ describe('cmdStateGet (state get)', () => { assert.strictEqual(output['Status'], 'Active', 'should extract Status field value'); }); + test('bold field lookup ignores prose lookalikes and accepts indentation (#4481)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Project State\n\nA note cites **Status:** stale prose.\n **Status:** Active\n' + ); + + const result = runGsdTools('state get Status', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output['Status'], 'Active'); + }); + test('extracts markdown section content', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), @@ -3892,6 +3904,46 @@ Progress: [..........] 0% }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #4481 — field reads agree with line-anchored field writes +// ───────────────────────────────────────────────────────────────────────────── + +describe('#4481: stateExtractField ignores mid-sentence bold lookalikes', () => { + const documentWithLookalikes = [ + '# Project State', + '', + 'A note cites **Progress:** dashboards, **Total Plans in Phase:** aggregation, and **Last Activity:** retention.', + '', + ' **Progress:** [█░░░░░░░░░] 10%', + '\t**Total Plans in Phase:** 2', + ' **Last Activity:** 2026-01-01', + '', + ].join('\n'); + + test('returns the real line-start value for every sync field', () => { + assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Progress'), '[█░░░░░░░░░] 10%'); + assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Total Plans in Phase'), '2'); + assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Last Activity'), '2026-01-01'); + }); + + test('sync change advisories name the same old values the writer replaces', () => { + const result = stateTransitionMod.transitionCore( + documentWithLookalikes, + { kind: 'sync', totalPlansInPhase: 3, percent: 20 }, + { clock: { localToday: () => '2026-09-07' } }, + ); + const changes = result.data.changes; + + assert.ok(changes.includes('Total Plans in Phase: 2 -> 3')); + assert.ok(changes.some((change) => change.startsWith('Progress: [█░░░░░░░░░] 10% -> '))); + assert.ok(changes.includes('Last Activity: 2026-01-01 -> 2026-09-07')); + assert.ok(result.content.includes('A note cites **Progress:** dashboards, **Total Plans in Phase:** aggregation, and **Last Activity:** retention.')); + assert.ok(result.content.includes('\t**Total Plans in Phase:** 3')); + assert.ok(result.content.includes(' **Progress:** [██░░░░░░░░] 20%')); + assert.ok(result.content.includes(' **Last Activity:** 2026-09-07')); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // #4243 — begin-phase: prose bold-lookalikes stay untouched (anchored bold // form in stateReplaceField) and frontmatter round-trips unknown keys