From 2842f076ea7530576fdc8bc0371bdde9e87febf5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 15:40:21 -0400 Subject: [PATCH] fix: CRLF frontmatter parsing, duplicate cwd crash, STATE.md phase transitions (#1105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(frontmatter): handle CRLF line endings in extractFrontmatter, spliceFrontmatter, and parseMustHavesBlock — fixes wave parsing on Windows where all plans reported as wave 1 (#1085) - fix(hooks): remove duplicate const cwd declaration in gsd-context-monitor.js that caused SyntaxError on every PostToolUse invocation (#1091, #1092, #1094) - feat(state): add 'state begin-phase' command that updates STATUS, Last Activity, Current focus, Current Position, and plan counts when a new phase starts executing (#1102, #1103, #1104) - docs(workflow): add state begin-phase call to execute-phase workflow validate_phase step so STATE.md is current from the start --- get-shit-done/bin/gsd-tools.cjs | 12 ++++ get-shit-done/bin/lib/frontmatter.cjs | 10 ++-- get-shit-done/bin/lib/state.cjs | 76 ++++++++++++++++++++++++ get-shit-done/workflows/execute-phase.md | 6 ++ hooks/gsd-context-monitor.js | 1 - 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index e5ce3c6c9..16975e8f2 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -14,6 +14,7 @@ * state update Update a STATE.md field * state get [section] Get STATE.md content or section * state patch --field val ... Batch update STATE.md fields + * state begin-phase --phase N --name S --plans C Update STATE.md for new phase start * resolve-model Get model for agent based on profile * find-phase Find phase directory by number * commit [--files f1 f2] Commit planning docs @@ -243,6 +244,17 @@ async function main() { stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null, resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None', }, raw); + } else if (subcommand === 'begin-phase') { + const phaseIdx = args.indexOf('--phase'); + const nameIdx = args.indexOf('--name'); + const plansIdx = args.indexOf('--plans'); + state.cmdStateBeginPhase( + cwd, + phaseIdx !== -1 ? args[phaseIdx + 1] : null, + nameIdx !== -1 ? args[nameIdx + 1] : null, + plansIdx !== -1 ? parseInt(args[plansIdx + 1], 10) : null, + raw + ); } else { state.cmdStateLoad(cwd, raw); } diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index 67ee4466a..e5f500a68 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -10,11 +10,11 @@ const { safeReadFile, normalizeMd, output, error } = require('./core.cjs'); function extractFrontmatter(content) { const frontmatter = {}; - const match = content.match(/^---\n([\s\S]+?)\n---/); + const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!match) return frontmatter; const yaml = match[1]; - const lines = yaml.split('\n'); + const lines = yaml.split(/\r?\n/); // Stack to track nested objects: [{obj, key, indent}] // obj = object to write to, key = current key collecting array items, indent = indentation level @@ -149,7 +149,7 @@ function reconstructFrontmatter(obj) { function spliceFrontmatter(content, newObj) { const yamlStr = reconstructFrontmatter(newObj); - const match = content.match(/^---\n[\s\S]+?\n---/); + const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/); if (match) { return `---\n${yamlStr}\n---` + content.slice(match[0].length); } @@ -159,7 +159,7 @@ function spliceFrontmatter(content, newObj) { function parseMustHavesBlock(content, blockName) { // Extract a specific block from must_haves in raw frontmatter YAML // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}] - const fmMatch = content.match(/^---\n([\s\S]+?)\n---/); + const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); if (!fmMatch) return []; const yaml = fmMatch[1]; @@ -169,7 +169,7 @@ function parseMustHavesBlock(content, blockName) { if (blockStart === -1) return []; const afterBlock = yaml.slice(blockStart); - const blockLines = afterBlock.split('\n').slice(1); // skip the header line + const blockLines = afterBlock.split(/\r?\n/).slice(1); // skip the header line const items = []; let current = null; diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index f8caad9c1..40bf8d2cc 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -703,6 +703,81 @@ function cmdStateJson(cwd, raw) { output(fm, raw, JSON.stringify(fm, null, 2)); } +/** + * Update STATE.md when a new phase begins execution. + * Updates body text fields (Current focus, Status, Last Activity, Current Position) + * and synchronizes frontmatter via writeStateMd. + * Fixes: #1102 (plan counts), #1103 (status/last_activity), #1104 (body text). + */ +function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) { + const statePath = path.join(cwd, '.planning', 'STATE.md'); + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + let content = fs.readFileSync(statePath, 'utf-8'); + const today = new Date().toISOString().split('T')[0]; + const updated = []; + + // Update Status field + const statusValue = `Executing Phase ${phaseNumber}`; + let result = stateReplaceField(content, 'Status', statusValue); + if (result) { content = result; updated.push('Status'); } + + // Update Last Activity + result = stateReplaceField(content, 'Last Activity', today); + if (result) { content = result; updated.push('Last Activity'); } + + // Update Last Activity Description if it exists + const activityDesc = `Phase ${phaseNumber} execution started`; + result = stateReplaceField(content, 'Last Activity Description', activityDesc); + if (result) { content = result; updated.push('Last Activity Description'); } + + // Update Current Phase + result = stateReplaceField(content, 'Current Phase', String(phaseNumber)); + if (result) { content = result; updated.push('Current Phase'); } + + // Update Current Phase Name + if (phaseName) { + result = stateReplaceField(content, 'Current Phase Name', phaseName); + if (result) { content = result; updated.push('Current Phase Name'); } + } + + // Update Current Plan to 1 (starting from the first plan) + result = stateReplaceField(content, 'Current Plan', '1'); + if (result) { content = result; updated.push('Current Plan'); } + + // Update Total Plans in Phase + if (planCount) { + result = stateReplaceField(content, 'Total Plans in Phase', String(planCount)); + if (result) { content = result; updated.push('Total Plans in Phase'); } + } + + // Update **Current focus:** body text line (#1104) + const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`; + const focusPattern = /(\*\*Current focus:\*\*\s*).*/i; + if (focusPattern.test(content)) { + content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`); + updated.push('Current focus'); + } + + // Update ## Current Position section (#1104) + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const positionMatch = content.match(positionPattern); + if (positionMatch) { + const newPosition = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING\nPlan: 1 of ${planCount || '?'}\n`; + content = content.replace(positionPattern, (_match, header) => `${header}${newPosition}`); + updated.push('Current Position'); + } + + if (updated.length > 0) { + writeStateMd(statePath, content, cwd); + } + + output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false'); +} + module.exports = { stateExtractField, stateReplaceField, @@ -720,4 +795,5 @@ module.exports = { cmdStateRecordSession, cmdStateSnapshot, cmdStateJson, + cmdStateBeginPhase, }; diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index a11ff5fc0..9a6dc4b02 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -54,6 +54,12 @@ All subsequent commits go to this branch. User handles merging. From init JSON: `phase_dir`, `plan_count`, `incomplete_count`. Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)" + +**Update STATE.md for phase start:** +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" state begin-phase --phase "${PHASE_NUMBER}" --name "${PHASE_NAME}" --plans "${PLAN_COUNT}" +``` +This updates Status, Last Activity, Current focus, Current Position, and plan counts in STATE.md so frontmatter and body text reflect the active phase immediately. diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index 3f65a75cd..d7a5eff06 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -115,7 +115,6 @@ process.stdin.on('end', () => { fs.writeFileSync(warnPath, JSON.stringify(warnData)); // Detect if GSD is active (has .planning/STATE.md in working directory) - const cwd = data.cwd || process.cwd(); const isGsdActive = fs.existsSync(path.join(cwd, '.planning', 'STATE.md')); // Build advisory warning message (never use imperative commands that