diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 2334e7b4..aa60d3ff 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -1756,11 +1756,28 @@ function resolveReasoningEffortInternal(cwd, agentType) { */ function extractOneLinerFromBody(content) { if (!content) return null; + // Normalize EOLs so matching works for LF and CRLF files. + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); // Strip frontmatter first - const body = content.replace(/^---\n[\s\S]*?\n---\n*/, ''); - // Find the first **...** line after a # heading - const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m); - return match ? match[1].trim() : null; + const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, ''); + // Find the first **...** span on a line after a # heading. + // Two supported template forms: + // 1) Labeled: **One-liner:** Real prose here. (bug #2660 — new template) + // 2) Bare: **Real prose here.** (legacy template) + // For (1), the first bold span ends in a colon and the prose that follows + // on the same line is the one-liner. For (2), the bold span itself is the + // one-liner. + const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m); + if (!match) return null; + const boldInner = match[1].trim(); + const afterBold = match[2]; + // Labeled form: bold span is a "Label:" prefix — capture prose after it. + if (/:\s*$/.test(boldInner)) { + const prose = afterBold.trim(); + return prose.length > 0 ? prose : null; + } + // Bare form: the bold content itself is the one-liner. + return boldInner.length > 0 ? boldInner : null; } // ─── Misc utilities ─────────────────────────────────────────────────────────── diff --git a/tests/bug-2660-one-liner-extraction.test.cjs b/tests/bug-2660-one-liner-extraction.test.cjs new file mode 100644 index 00000000..75121e81 --- /dev/null +++ b/tests/bug-2660-one-liner-extraction.test.cjs @@ -0,0 +1,82 @@ +/** + * Bug #2660: `gsd-tools milestone complete ` writes MILESTONES.md + * bullets that read "- One-liner:" (the literal label) instead of the prose + * after the label. + * + * Root cause: extractOneLinerFromBody() matches the first **...** span. In + * `**One-liner:** prose`, the first span contains only `One-liner:` so the + * function returns the label instead of the prose after it. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); + +const { extractOneLinerFromBody } = require( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs') +); + +describe('bug #2660: extractOneLinerFromBody', () => { + test('a) body-style **One-liner:** label returns prose after the label', () => { + const content = + '# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.'); + }); + + test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => { + const content = + '---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n'; + assert.strictEqual(extractOneLinerFromBody(content), null); + }); + + test('c) no one-liner at all returns null', () => { + const content = + '# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n'; + assert.strictEqual(extractOneLinerFromBody(content), null); + }); + + test('d) bold spans inside the prose are preserved', () => { + const content = + '# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'This is **important** stuff.' + ); + }); + + test('e) empty prose after label returns null (no bogus bullet)', () => { + const empty = + '# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n'; + const whitespace = + '# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n'; + assert.strictEqual(extractOneLinerFromBody(empty), null); + assert.strictEqual(extractOneLinerFromBody(whitespace), null); + }); + + test('f) legacy bare **prose** format still works (no label, no colon)', () => { + // Preserve pre-existing behavior: SUMMARY files historically used + // `**bold prose**` with no label. See tests/commands.test.cjs:366 and + // tests/milestone.test.cjs:451 — both assert this form. + const content = + '---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'JWT auth with refresh rotation using jose library' + ); + }); + + test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => { + const content = + '# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.'); + }); + + test('h) CRLF line endings (Windows) are handled', () => { + const content = + '---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n'; + assert.strictEqual( + extractOneLinerFromBody(content), + 'Windows-authored prose.' + ); + }); +});