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:
@@ -14,6 +14,7 @@
|
|||||||
* state update <field> <value> Update a STATE.md field
|
* state update <field> <value> Update a STATE.md field
|
||||||
* state get [section] Get STATE.md content or section
|
* state get [section] Get STATE.md content or section
|
||||||
* state patch --field val ... Batch update STATE.md fields
|
* 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
|
* resolve-model <agent-type> Get model for agent based on profile
|
||||||
* find-phase <phase> Find phase directory by number
|
* find-phase <phase> Find phase directory by number
|
||||||
* commit <message> [--files f1 f2] Commit planning docs
|
* commit <message> [--files f1 f2] Commit planning docs
|
||||||
@@ -243,6 +244,17 @@ async function main() {
|
|||||||
stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null,
|
stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null,
|
||||||
resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None',
|
resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None',
|
||||||
}, raw);
|
}, 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 {
|
} else {
|
||||||
state.cmdStateLoad(cwd, raw);
|
state.cmdStateLoad(cwd, raw);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ const { safeReadFile, normalizeMd, output, error } = require('./core.cjs');
|
|||||||
|
|
||||||
function extractFrontmatter(content) {
|
function extractFrontmatter(content) {
|
||||||
const frontmatter = {};
|
const frontmatter = {};
|
||||||
const match = content.match(/^---\n([\s\S]+?)\n---/);
|
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||||
if (!match) return frontmatter;
|
if (!match) return frontmatter;
|
||||||
|
|
||||||
const yaml = match[1];
|
const yaml = match[1];
|
||||||
const lines = yaml.split('\n');
|
const lines = yaml.split(/\r?\n/);
|
||||||
|
|
||||||
// Stack to track nested objects: [{obj, key, indent}]
|
// Stack to track nested objects: [{obj, key, indent}]
|
||||||
// obj = object to write to, key = current key collecting array items, indent = indentation level
|
// 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) {
|
function spliceFrontmatter(content, newObj) {
|
||||||
const yamlStr = reconstructFrontmatter(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) {
|
if (match) {
|
||||||
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ function spliceFrontmatter(content, newObj) {
|
|||||||
function parseMustHavesBlock(content, blockName) {
|
function parseMustHavesBlock(content, blockName) {
|
||||||
// Extract a specific block from must_haves in raw frontmatter YAML
|
// Extract a specific block from must_haves in raw frontmatter YAML
|
||||||
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
|
// 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 [];
|
if (!fmMatch) return [];
|
||||||
|
|
||||||
const yaml = fmMatch[1];
|
const yaml = fmMatch[1];
|
||||||
@@ -169,7 +169,7 @@ function parseMustHavesBlock(content, blockName) {
|
|||||||
if (blockStart === -1) return [];
|
if (blockStart === -1) return [];
|
||||||
|
|
||||||
const afterBlock = yaml.slice(blockStart);
|
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 = [];
|
const items = [];
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|||||||
@@ -703,6 +703,81 @@ function cmdStateJson(cwd, raw) {
|
|||||||
output(fm, raw, JSON.stringify(fm, null, 2));
|
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 = {
|
module.exports = {
|
||||||
stateExtractField,
|
stateExtractField,
|
||||||
stateReplaceField,
|
stateReplaceField,
|
||||||
@@ -720,4 +795,5 @@ module.exports = {
|
|||||||
cmdStateRecordSession,
|
cmdStateRecordSession,
|
||||||
cmdStateSnapshot,
|
cmdStateSnapshot,
|
||||||
cmdStateJson,
|
cmdStateJson,
|
||||||
|
cmdStateBeginPhase,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ All subsequent commits go to this branch. User handles merging.
|
|||||||
From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.
|
From init JSON: `phase_dir`, `plan_count`, `incomplete_count`.
|
||||||
|
|
||||||
Report: "Found {plan_count} plans in {phase_dir} ({incomplete_count} incomplete)"
|
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>
|
||||||
|
|
||||||
<step name="discover_and_group_plans">
|
<step name="discover_and_group_plans">
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ process.stdin.on('end', () => {
|
|||||||
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
fs.writeFileSync(warnPath, JSON.stringify(warnData));
|
||||||
|
|
||||||
// Detect if GSD is active (has .planning/STATE.md in working directory)
|
// 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'));
|
const isGsdActive = fs.existsSync(path.join(cwd, '.planning', 'STATE.md'));
|
||||||
|
|
||||||
// Build advisory warning message (never use imperative commands that
|
// Build advisory warning message (never use imperative commands that
|
||||||
|
|||||||
Reference in New Issue
Block a user