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;
diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs
index 042c15045..ac5e82276 100644
--- a/get-shit-done/bin/lib/core.cjs
+++ b/get-shit-done/bin/lib/core.cjs
@@ -392,7 +392,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(]+)/);
diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs
index bf37a74fd..f5ceb98aa 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;
@@ -809,6 +811,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');
diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs
index 18a4bc760..7263fb1c0 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 {
@@ -414,11 +449,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
@@ -477,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();
@@ -513,10 +557,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');