From 4e00c50022e337f3e8f1bf469b69f22a5e5dfdfe Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 28 Feb 2026 04:48:04 -0800 Subject: [PATCH 1/5] fix(phase): add ROADMAP.md fallback to cmdPhaseComplete next-phase scan cmdPhaseComplete determines is_last_phase and next_phase by scanning .planning/phases/ directories on disk. Phases defined in ROADMAP.md but not yet planned (no directory created) are invisible to this scan, causing premature is_last_phase:true when only the first phase has been scaffolded. Add a fallback that parses ROADMAP.md phase headings when the filesystem scan finds no next phase. Uses the existing comparePhaseNum utility for consistent ordering with letter suffixes and decimals. Closes #709 Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/lib/phase.cjs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 4e4cbff60..408d95df0 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -783,7 +783,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { } } - // Find next phase + // Find next phase — check both filesystem AND roadmap + // Phases may be defined in ROADMAP.md but not yet scaffolded to disk, + // so a filesystem-only scan would incorrectly report is_last_phase:true let nextPhaseNum = null; let nextPhaseName = null; let isLastPhase = true; @@ -806,6 +808,24 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { } } catch {} + // Fallback: if filesystem found no next phase, check ROADMAP.md + // for phases that are defined but not yet planned (no directory on disk) + if (isLastPhase && fs.existsSync(roadmapPath)) { + try { + const roadmapForPhases = fs.readFileSync(roadmapPath, 'utf-8'); + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + let pm; + while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { + if (comparePhaseNum(pm[1], phaseNum) > 0) { + nextPhaseNum = pm[1]; + nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'); + isLastPhase = false; + break; + } + } + } catch {} + } + // Update STATE.md if (fs.existsSync(statePath)) { let stateContent = fs.readFileSync(statePath, 'utf-8'); From 81a6aaad15d288c393f4fea1759905cf20c1cc10 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 28 Feb 2026 04:48:09 -0800 Subject: [PATCH 2/5] fix(core): prefer in-progress milestone marker in getMilestoneInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMilestoneInfo matches the first version string in ROADMAP.md, which is typically the oldest shipped milestone. For list-format roadmaps that use emoji markers (e.g. "🚧 **v2.1 Belgium**"), the function now checks for the in-progress marker first before falling back to the heading-based and bare version matching. Closes #700 Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/lib/core.cjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 6ef6ccb2a..fbca469d3 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -388,7 +388,18 @@ function generateSlugInternal(text) { function getMilestoneInfo(cwd) { try { const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); - // Strip
...
blocks so shipped milestones don't interfere + + // First: check for list-format roadmaps using 🚧 (in-progress) marker + // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)" + const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+\.\d+)\s+([^*]+)\*\*/); + if (inProgressMatch) { + return { + version: 'v' + inProgressMatch[1], + name: inProgressMatch[2].trim(), + }; + } + + // Second: heading-format roadmaps — strip shipped milestones in
blocks const cleaned = roadmap.replace(/
[\s\S]*?<\/details>/gi, ''); // Extract version and name from the same ## heading for consistency const headingMatch = cleaned.match(/## .*v(\d+\.\d+)[:\s]+([^\n(]+)/); From 78eaabc3da2cf9547d311e97c5fe87f05af208d2 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 28 Feb 2026 04:48:16 -0800 Subject: [PATCH 3/5] fix(state): support both bold and plain field formats in state parsing extractField in state-snapshot and buildStateFrontmatter only matches the **Field:** bold markdown format, but STATE.md may use plain Field: format depending on how it was generated. When all fields return null, progress routing freezes with no matching condition. Add dual-format parsing: try **Field:** first, fall back to plain Field: with line-start anchor. Both instances in cmdStateSnapshot and buildStateFrontmatter are updated consistently. Closes #730 Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/lib/state.cjs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 915f51c45..ba4ef8697 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -414,11 +414,17 @@ function cmdStateSnapshot(cwd, raw) { const content = fs.readFileSync(statePath, 'utf-8'); - // Helper to extract **Field:** value patterns + // Helper to extract field values — supports both **Field:** bold format + // and plain Field: format (STATE.md may use either depending on version) const extractField = (fieldName) => { - const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); - const match = content.match(pattern); - return match ? match[1].trim() : null; + // Try **Field:** format first (bold markdown) + const boldPattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); + const boldMatch = content.match(boldPattern); + if (boldMatch) return boldMatch[1].trim(); + // Fall back to plain Field: format + const plainPattern = new RegExp(`^${fieldName}:\\s*(.+)`, 'im'); + const plainMatch = content.match(plainPattern); + return plainMatch ? plainMatch[1].trim() : null; }; // Extract basic fields @@ -513,10 +519,14 @@ function cmdStateSnapshot(cwd, raw) { * reliably via `state json` instead of fragile regex parsing. */ function buildStateFrontmatter(bodyContent, cwd) { + // Supports both **Field:** bold and plain Field: format (see state-snapshot) const extractField = (fieldName) => { - const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); - const match = bodyContent.match(pattern); - return match ? match[1].trim() : null; + const boldPattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i'); + const boldMatch = bodyContent.match(boldPattern); + if (boldMatch) return boldMatch[1].trim(); + const plainPattern = new RegExp(`^${fieldName}:\\s*(.+)`, 'im'); + const plainMatch = bodyContent.match(plainPattern); + return plainMatch ? plainMatch[1].trim() : null; }; const currentPhase = extractField('Current Phase'); From 4155e673546371c24aedec2c60dc988d324a9923 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 28 Feb 2026 04:48:22 -0800 Subject: [PATCH 4/5] fix(cli): preserve multi-word commit messages in CLI router The commit case in the CLI router reads only args[1] for the message, which captures just the first word when the shell strips quotes before passing arguments to Node.js. This silently truncates every multi-word commit message (e.g. "docs(40): create phase plan" becomes "docs(40):"). Collect all positional args between the command name and the first flag (--files, --amend), then join them. Works correctly whether the shell preserves quotes (single arg) or strips them (multiple args). Closes #733 Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/gsd-tools.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index fa404eb49..48cb9cf56 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -259,9 +259,13 @@ async function main() { case 'commit': { const amend = args.includes('--amend'); - const message = args[1]; - // Parse --files flag (collect args after --files, stopping at other flags) const filesIndex = args.indexOf('--files'); + // Collect all positional args between command name and first flag, + // then join them — handles both quoted ("multi word msg") and + // unquoted (multi word msg) invocations from different shells + const endIndex = filesIndex !== -1 ? filesIndex : args.length; + const messageArgs = args.slice(1, endIndex).filter(a => !a.startsWith('--')); + const message = messageArgs.join(' ') || undefined; const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : []; commands.cmdCommit(cwd, message, files, raw, amend); break; From 8c017034a3d19b2179012d78af75ea9803d6675f Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sat, 28 Feb 2026 05:34:43 -0800 Subject: [PATCH 5/5] 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 --- get-shit-done/bin/lib/state.cjs | 84 ++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index ba4ef8697..732e1b652 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -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();