fix: CRLF frontmatter parsing, duplicate cwd crash, STATE.md phase transitions (#1105)

- 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
This commit is contained in:
Tom Boucher
2026-03-16 15:40:21 -04:00
committed by GitHub
parent 80605d2051
commit 2842f076ea
5 changed files with 99 additions and 6 deletions

View File

@@ -14,6 +14,7 @@
* state update <field> <value> 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 <agent-type> Get model for agent based on profile
* find-phase <phase> Find phase directory by number
* commit <message> [--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);
}

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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.
</step>
<step name="discover_and_group_plans">

View File

@@ -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