From 1405728292cc2d1bb9718e0f3293332448633ada Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 26 Apr 2026 13:37:43 -0400 Subject: [PATCH] fix: parseMustHavesBlock quoted strings + gsd state complete-phase (#2744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: parseMustHavesBlock quoted strings + gsd state complete-phase Bug #2734: parseMustHavesBlock dropped quoted truths containing ':' because fully-quoted strings like `"App-side UUIDv4: generated locally"` fell into the kv-parse branch, the regex failed (value starts with '"'), and current stayed as empty {}. Fix: detect fully-quoted strings before the ':' check and extract them directly. Two regression tests added to frontmatter.test.cjs. Bug #2735: `gsd state complete-phase` subcommand was missing — unknown subcommands fell through to cmdStateLoad. Added cmdStateCompletePhase to state.cjs (updates Status, Last Activity, and Current Position to COMPLETE), exported it, and wired it into the case 'state': dispatch in gsd-tools.cjs. Closes #2734 Closes #2735 Co-Authored-By: Claude Sonnet 4.6 * fix(state): unknown subcommand returns explicit error instead of silent fallthrough Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 6 ++- get-shit-done/bin/lib/frontmatter.cjs | 7 ++- get-shit-done/bin/lib/state.cjs | 76 +++++++++++++++++++++++++++ tests/dispatcher.test.cjs | 7 +++ tests/frontmatter.test.cjs | 35 ++++++++++++ 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 1b752fd05..14f787584 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -483,14 +483,18 @@ async function runCommand(command, args, cwd, raw, defaultValue) { } else if (subcommand === 'prune') { const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']); state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw); + } else if (subcommand === 'complete-phase') { + state.cmdStateCompletePhase(cwd, args, raw); } else if (subcommand === 'milestone-switch') { // Bug #2630: reset STATE.md frontmatter + Current Position for new milestone. // NB: the flag is `--milestone`, not `--version` — gsd-tools reserves // `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above). const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']); state.cmdStateMilestoneSwitch(cwd, milestone, name, raw); - } else { + } else if (subcommand === undefined || subcommand === 'load') { state.cmdStateLoad(cwd, raw); + } else { + error(`Unknown state subcommand: "${subcommand}". Available: load, json, get, patch, update, advance-plan, record-metric, update-progress, add-decision, add-blocker, resolve-blocker, record-session, begin-phase, signal-waiting, signal-resume, planned-phase, validate, sync, prune, complete-phase, milestone-switch`); } break; } diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index b7c4d8de7..8879611cd 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -244,8 +244,13 @@ function parseMustHavesBlock(content, blockName) { if (current) items.push(current); current = {}; const afterDash = trimmed.slice(2); + const trimmedAfterDash = afterDash.trim(); + // Check if it's a fully-quoted string (may contain ':' inside the quotes) + if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) || + (trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) { + current = trimmedAfterDash.slice(1, -1); // Check if it's a simple string item (no colon means not a key-value) - if (!afterDash.includes(':')) { + } else if (!afterDash.includes(':')) { current = afterDash.replace(/^["']|["']$/g, ''); } else { // Key-value on same line as dash: "- path: value" diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index ab7d82716..0ec833df5 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -1683,6 +1683,81 @@ function cmdStatePrune(cwd, options, raw) { }, raw, totalPruned > 0 ? 'true' : 'false'); } +/** + * Mark the current phase as COMPLETE in STATE.md. + * Updates Status, Last Activity, and the Current Position section to reflect + * that the phase execution is finished and the project is ready for the next phase. + * Implements the `gsd state complete-phase` subcommand (issue #2735). + */ +function cmdStateCompletePhase(cwd, args, raw) { + const statePath = planningPaths(cwd).state; + if (!fs.existsSync(statePath)) { + output({ error: 'STATE.md not found' }, raw); + return; + } + + const today = new Date().toISOString().split('T')[0]; + const updated = []; + + readModifyWriteStateMd(statePath, (content) => { + // Read the current phase number for descriptive messages + const currentPhase = stateExtractField(content, 'Current Phase') || + stateExtractField(content, 'Phase') || + '?'; + + // Update Status field + const statusValue = `Phase ${currentPhase} complete`; + let result = stateReplaceField(content, 'Status', statusValue); + if (result) { content = result; updated.push('Status'); } + + // Update Last Activity date + result = stateReplaceField(content, 'Last Activity', today); + if (result) { content = result; updated.push('Last Activity'); } + + // Update Last Activity Description + const activityDesc = `Phase ${currentPhase} marked complete`; + result = stateReplaceField(content, 'Last Activity Description', activityDesc); + if (result) { content = result; updated.push('Last Activity Description'); } + + // Update ## Current Position section + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const positionMatch = content.match(positionPattern); + if (positionMatch) { + const header = positionMatch[1]; + let posBody = positionMatch[2]; + + // Update Phase line to show COMPLETE + const newPhase = `Phase: ${currentPhase} — COMPLETE`; + if (/^Phase:/m.test(posBody)) { + posBody = posBody.replace(/^Phase:.*$/m, newPhase); + } + + // Update Status line if present + const newStatus = `Status: Phase ${currentPhase} complete`; + if (/^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, newStatus); + } + + // Update Last activity line if present + const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`; + if (/^Last activity:/im.test(posBody)) { + posBody = posBody.replace(/^Last activity:.*$/im, newActivity); + } + + content = content.replace(positionPattern, `${header}${posBody}`); + updated.push('Current Position'); + } + + return content; + }, cwd); + + output( + { updated, phase: stateExtractField(fs.readFileSync(planningPaths(cwd).state, 'utf-8'), 'Current Phase') || '?' }, + raw, + updated.length > 0 ? 'true' : 'false', + ); +} + module.exports = { stateExtractField, stateReplaceField, @@ -1705,6 +1780,7 @@ module.exports = { cmdStateJson, cmdStateBeginPhase, cmdStatePlannedPhase, + cmdStateCompletePhase, cmdStateValidate, cmdStateSync, cmdStatePrune, diff --git a/tests/dispatcher.test.cjs b/tests/dispatcher.test.cjs index a4a32a2e5..43e09e5a4 100644 --- a/tests/dispatcher.test.cjs +++ b/tests/dispatcher.test.cjs @@ -66,6 +66,13 @@ describe('dispatcher error paths', () => { assert.ok(result.error.includes('Invalid --cwd'), `Expected "Invalid --cwd" in stderr, got: ${result.error}`); }); + // Unknown subcommand: state + test('state unknown subcommand errors', () => { + const result = runGsdTools('state bogus', tmpDir); + assert.strictEqual(result.success, false, 'Should exit non-zero'); + assert.ok(result.error.includes('Unknown state subcommand'), `Expected "Unknown state subcommand" in stderr, got: ${result.error}`); + }); + // Unknown subcommand: template test('template unknown subcommand errors', () => { const result = runGsdTools('template bogus', tmpDir); diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index 7926a5ce5..5925ea602 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -512,6 +512,41 @@ must_haves: assert.strictEqual(result[0].min_lines, 100); }); + test('#2734: quoted truth containing ":" is preserved as a string — not dropped', () => { + // When a dash-item is a fully-quoted string that contains ':', the old code + // fell into the key-value branch, failed the kvMatch regex (because the value + // started with '"'), and silently left current as {}, losing the string. + const content = `--- +phase: 01 +must_haves: + truths: + - "App-side UUIDv4: generated locally" + - "No colon in this one" + - "Another colon: example" +--- +`; + const result = parseMustHavesBlock(content, 'truths'); + assert.ok(Array.isArray(result), 'should return an array'); + assert.strictEqual(result.length, 3, `expected 3 truths, got ${result.length}: ${JSON.stringify(result)}`); + assert.strictEqual(result[0], 'App-side UUIDv4: generated locally'); + assert.strictEqual(result[1], 'No colon in this one'); + assert.strictEqual(result[2], 'Another colon: example'); + }); + + test('#2734: single-quoted truth containing ":" is preserved as a string', () => { + const content = `--- +phase: 01 +must_haves: + truths: + - 'Key: value pattern preserved' +--- +`; + const result = parseMustHavesBlock(content, 'truths'); + assert.ok(Array.isArray(result), 'should return an array'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0], 'Key: value pattern preserved'); + }); + test('handles nested arrays within artifact objects', () => { const content = `--- phase: 01