fix(state): support both bold and plain field formats in all state.cjs functions

Expand dual-format parsing to cover all 8 field extraction and
replacement locations in state.cjs. The previous fix only covered
cmdStateSnapshot and buildStateFrontmatter (read path), leaving 6
write-path functions with bold-only regex that silently failed on
plain-format STATE.md files.

Functions fixed:
- stateExtractField: shared read helper (cascades to cmdStateAdvancePlan,
  cmdStateRecordSession)
- stateReplaceField: shared write helper (cascades to all state mutations)
- cmdStateGet: individual field lookup
- cmdStatePatch: batch field updates
- cmdStateUpdate: single field updates
- cmdStateUpdateProgress: progress bar writes
- cmdStateSnapshot session section: Last Date, Stopped At, Resume File

Each function now tries **Field:** bold format first (preserving
existing behavior), then falls back to plain Field: format. This
eliminates the read/write asymmetry where state-snapshot could read
plain-format fields but state-update/state-patch could not modify them.

Closes #730

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tibsfox
2026-02-28 05:34:43 -08:00
parent 4155e67354
commit 8c017034a3

View File

@@ -65,11 +65,19 @@ function cmdStateGet(cwd, section, raw) {
// Try to find markdown section or field
const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Check for **field:** value
const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const fieldMatch = content.match(fieldPattern);
if (fieldMatch) {
output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
// Check for **field:** value (bold format)
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) {
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
return;
}
// Check for field: value (plain format)
const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im');
const plainMatch = content.match(plainPattern);
if (plainMatch) {
output({ [section]: plainMatch[1].trim() }, raw, plainMatch[1].trim());
return;
}
@@ -106,10 +114,15 @@ function cmdStatePatch(cwd, patches, raw) {
for (const [field, value] of Object.entries(patches)) {
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
if (pattern.test(content)) {
content = content.replace(pattern, (_match, prefix) => `${prefix}${value}`);
if (boldPattern.test(content)) {
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
results.updated.push(field);
} else {
results.failed.push(field);
@@ -135,9 +148,15 @@ function cmdStateUpdate(cwd, field, value) {
try {
let content = fs.readFileSync(statePath, 'utf-8');
const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
if (pattern.test(content)) {
content = content.replace(pattern, (_match, prefix) => `${prefix}${value}`);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
if (boldPattern.test(content)) {
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
writeStateMd(statePath, content, cwd);
output({ updated: true });
} else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
writeStateMd(statePath, content, cwd);
output({ updated: true });
} else {
@@ -152,16 +171,26 @@ function cmdStateUpdate(cwd, field, value) {
function stateExtractField(content, fieldName) {
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
const match = content.match(pattern);
return match ? match[1].trim() : null;
// Try **Field:** bold format first
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
// Fall back to plain Field: format
const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
function stateReplaceField(content, fieldName, newValue) {
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (pattern.test(content)) {
return content.replace(pattern, (_match, prefix) => `${prefix}${newValue}`);
// Try **Field:** bold format first, then plain Field: format
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix) => `${prefix}${newValue}`);
}
return null;
}
@@ -256,9 +285,15 @@ function cmdStateUpdateProgress(cwd, raw) {
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`;
const progressPattern = /(\*\*Progress:\*\*\s*).*/i;
if (progressPattern.test(content)) {
content = content.replace(progressPattern, (_match, prefix) => `${prefix}${progressStr}`);
// Try **Progress:** bold format first, then plain Progress: format
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
const plainProgressPattern = /^(Progress:\s*).*/im;
if (boldProgressPattern.test(content)) {
content = content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
writeStateMd(statePath, content, cwd);
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
} else if (plainProgressPattern.test(content)) {
content = content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
writeStateMd(statePath, content, cwd);
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
} else {
@@ -483,9 +518,12 @@ function cmdStateSnapshot(cwd, raw) {
const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
if (sessionMatch) {
const sessionSection = sessionMatch[1];
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i)
|| sessionSection.match(/^Last Date:\s*(.+)/im);
const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i)
|| sessionSection.match(/^Stopped At:\s*(.+)/im);
const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i)
|| sessionSection.match(/^Resume File:\s*(.+)/im);
if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();