diff --git a/.changeset/silly-pandas-munch.md b/.changeset/silly-pandas-munch.md new file mode 100644 index 000000000..70c33a34a --- /dev/null +++ b/.changeset/silly-pandas-munch.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 951 +--- +**`audit-open` no longer false-flags completed quick tasks** — quick-task SUMMARYs now carry `status: complete` in frontmatter by construction, so the milestone-close auditor stops reporting finished quick tasks as `[unknown]`. diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 2a70dbea5..9f342f194 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -604,7 +604,7 @@ This file is the canonical output of this step. The orchestrator reads `.plannin **Use template:** @~/.claude/gsd-core/templates/summary.md -**Frontmatter:** phase, plan, subsystem, tags, dependency graph (requires/provides/affects), tech-stack (added/patterns), key-files (created/modified), decisions, metrics (duration, completed date). +**Frontmatter:** phase, plan, subsystem, tags, dependency graph (requires/provides/affects), tech-stack (added/patterns), key-files (created/modified), decisions, metrics (duration, completed date), status (`status: complete` — required so the audit-open scanner recognises the summary as done). **Title:** `# Phase [X] Plan [Y]: [Name] Summary` diff --git a/gsd-core/templates/summary-complex.md b/gsd-core/templates/summary-complex.md index ccc8aac2d..c20b4028b 100644 --- a/gsd-core/templates/summary-complex.md +++ b/gsd-core/templates/summary-complex.md @@ -21,6 +21,7 @@ patterns-established: - "Pattern 1: description" duration: Xmin completed: YYYY-MM-DD +status: complete --- # Phase [X]: [Name] Summary (Complex) diff --git a/gsd-core/templates/summary-minimal.md b/gsd-core/templates/summary-minimal.md index 3dc1ba9e8..78c382736 100644 --- a/gsd-core/templates/summary-minimal.md +++ b/gsd-core/templates/summary-minimal.md @@ -15,6 +15,7 @@ key-files: key-decisions: [] duration: Xmin completed: YYYY-MM-DD +status: complete --- # Phase [X]: [Name] Summary (Minimal) diff --git a/gsd-core/templates/summary-standard.md b/gsd-core/templates/summary-standard.md index 674f14658..77cc154a9 100644 --- a/gsd-core/templates/summary-standard.md +++ b/gsd-core/templates/summary-standard.md @@ -16,6 +16,7 @@ key-decisions: - "Decision 1" duration: Xmin completed: YYYY-MM-DD +status: complete --- # Phase [X]: [Name] Summary diff --git a/gsd-core/templates/summary.md b/gsd-core/templates/summary.md index c66799b86..3d5d84528 100644 --- a/gsd-core/templates/summary.md +++ b/gsd-core/templates/summary.md @@ -43,6 +43,7 @@ requirements-completed: [] # REQUIRED — Copy ALL requirement IDs from this pl # Metrics duration: Xmin completed: YYYY-MM-DD +status: complete --- # Phase [X]: [Name] Summary diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index b7c2cc740..c35f03f8d 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -728,7 +728,7 @@ SUMMARY.md and stop — the user must rerun with worktrees disabled. - Execute all tasks in the plan - Commit each task atomically (code changes only) - Run the bash block before every \`git commit\` if SUBMODULE_PATHS is non-empty -- Create summary at: ${QUICK_DIR}/${quick_id}-SUMMARY.md +- Create summary at: ${QUICK_DIR}/${quick_id}-SUMMARY.md with `status: complete` in SUMMARY frontmatter (required so the audit-open milestone-close scanner recognises the task as done, not [unknown]) - Do NOT commit docs artifacts (SUMMARY.md, STATE.md, PLAN.md) — the orchestrator handles the docs commit in Step 8 - Do NOT update ROADMAP.md (quick tasks are separate from planned phases) diff --git a/tests/bug-950-quick-summary-status-complete.test.cjs b/tests/bug-950-quick-summary-status-complete.test.cjs new file mode 100644 index 000000000..1f079c999 --- /dev/null +++ b/tests/bug-950-quick-summary-status-complete.test.cjs @@ -0,0 +1,301 @@ +// allow-test-rule: source-text-is-the-product +/** + * Regression tests for bug #950 + * + * audit-open chronically flagged genuinely-complete quick tasks as [unknown] + * because NO shipped summary template carried a `status:` frontmatter field — + * so status was only emitted when the writing agent improvised it. + * + * The fix: add `status: complete` to all four summary templates and enforce it + * in the executor agent + quick.md workflow. Tests here exercise the scanner + * directly via auditOpenArtifacts() and also guard template text as a secondary + * contract check. + * + * Primary guard: behavioral audit-scanner tests (tasks read by scanQuickTasks) + * Secondary guard: template-contract text checks (template text IS the runtime contract) + * Writer-path guard: contract assertions on quick.md + gsd-executor.md (source-text-is-the-product) + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const auditModule = require('../gsd-core/bin/lib/audit.cjs'); +const { auditOpenArtifacts } = auditModule; +const { cleanup } = require('./helpers.cjs'); + +const TEMPLATES_DIR = path.resolve(__dirname, '..', 'gsd-core', 'templates'); +const QUICK_MD = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'quick.md'); +const EXECUTOR_MD = path.resolve(__dirname, '..', 'agents', 'gsd-executor.md'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'bug-950-')); +} + +/** + * Extract the first YAML frontmatter block from a file's content. + * + * Two layouts are handled: + * - Leading frontmatter: file starts with `---\n…\n---` (summary-minimal/standard/complex.md) + * - Fenced frontmatter: frontmatter lives inside a ```markdown … ``` fence (summary.md, + * whose content IS a markdown example showing the template). In that case we extract + * the `---\n…\n---` block that sits immediately after the opening fence line. + * + * Returns the raw text of the YAML block (between the two `---` delimiters, exclusive), + * or null if no frontmatter could be found. + */ +function extractFrontmatter(content) { + // Case 1: file begins with --- (leading frontmatter) + if (/^---\r?\n/.test(content)) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + return match ? match[1] : null; + } + + // Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n) + const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/); + if (fenceMatch) { + // Strip the outer --- delimiters to get just the YAML body + const block = fenceMatch[1]; + const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/); + return inner ? inner[1] : null; + } + + return null; +} + +describe('bug #950: quick-task SUMMARY must carry status: complete', () => { + // Ensure GSD env vars do not redirect planningDir() away from our fixture. + let prevProject, prevWorkstream; + before(() => { + prevProject = process.env.GSD_PROJECT; + prevWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + after(() => { + if (prevProject !== undefined) process.env.GSD_PROJECT = prevProject; + if (prevWorkstream !== undefined) process.env.GSD_WORKSTREAM = prevWorkstream; + }); + + // ── Behavioral: scanner recognizes complete quick tasks ─────────────────── + + test('[PRIMARY] quick task SUMMARY with status: complete is NOT flagged open', () => { + // Simulates an executor that correctly wrote the SUMMARY with status: complete + // (as required after the fix). The scanner must report 0 open quick tasks. + const cwd = mkTmp(); + try { + const quickId = '260609-test-status-complete'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + [ + '---', + 'status: complete', + 'date: 2026-06-09', + 'slug: test-status-complete', + '---', + '', + '# Quick Task Summary', + '', + 'Task completed successfully.', + ].join('\n'), + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 0, + `quick task SUMMARY with status: complete must NOT appear as open; ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + assert.equal(result.counts.quick_tasks, 0); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] quick task SUMMARY without status: field is still flagged [unknown]', () => { + // Negative case: a SUMMARY that lacks status: still surfaces as [unknown]. + // This proves the scanner still catches real gaps — the fix must be on the writer side. + const cwd = mkTmp(); + try { + const quickId = '260609-test-no-status'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + [ + '---', + 'date: 2026-06-09', + 'slug: test-no-status', + '---', + '', + '# Quick Task Summary', + '', + 'Task done, but no status field.', + ].join('\n'), + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 1, + `quick task SUMMARY without status: must appear as open (unknown); ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + assert.equal(realQuickTasks[0].status, 'unknown', 'expected status to be unknown'); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] quick task without any SUMMARY is still flagged [missing]', () => { + // Proves the missing-SUMMARY case still surfaces. + const cwd = mkTmp(); + try { + const quickId = '260609-test-missing-summary'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + // No SUMMARY file at all. + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal(realQuickTasks.length, 1, 'missing SUMMARY must still be flagged'); + assert.equal(realQuickTasks[0].status, 'missing'); + } finally { + cleanup(cwd); + } + }); + + test('[PRIMARY] SUMMARY with status: COMPLETE (uppercase) is also recognized', () => { + // Scanner lowercases before comparing — verify case-insensitivity holds. + const cwd = mkTmp(); + try { + const quickId = '260609-test-uppercase-complete'; + const taskDir = path.join(cwd, '.planning', 'quick', quickId); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync( + path.join(taskDir, `${quickId}-SUMMARY.md`), + '---\nstatus: COMPLETE\n---\n# Summary\nDone.\n', + 'utf-8' + ); + + const result = auditOpenArtifacts(cwd); + const realQuickTasks = result.items.quick_tasks.filter( + i => !i.scan_error && !i._remainder_count + ); + + assert.equal( + realQuickTasks.length, + 0, + `quick task SUMMARY with status: COMPLETE (uppercase) must not appear as open; ` + + `got: ${JSON.stringify(realQuickTasks)}` + ); + } finally { + cleanup(cwd); + } + }); + + // ── Secondary: template-contract checks ────────────────────────────────── + // Assertions are scoped to the actual YAML frontmatter block, not the whole file, + // so a stray `status: complete` in prose or examples cannot produce a false green. + + test('[TEMPLATE CONTRACT] summary.md contains status: complete in frontmatter', () => { + // summary.md is a documentation template — its frontmatter lives inside a + // ```markdown fence. extractFrontmatter() finds and returns that block. + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary.md: could not locate a YAML frontmatter block (leading --- or fenced ```markdown --- block)' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-minimal.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-minimal.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-minimal.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-minimal.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-standard.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-standard.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-standard.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-standard.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + test('[TEMPLATE CONTRACT] summary-complex.md contains status: complete in frontmatter', () => { + const content = fs.readFileSync(path.join(TEMPLATES_DIR, 'summary-complex.md'), 'utf-8'); + const fm = extractFrontmatter(content); + assert.ok( + fm !== null, + 'gsd-core/templates/summary-complex.md: could not locate a leading YAML frontmatter block' + ); + assert.ok( + /^status:\s*complete\s*$/m.test(fm), + `gsd-core/templates/summary-complex.md: \`status: complete\` not found in the frontmatter block.\n` + + `Frontmatter extracted:\n${fm}` + ); + }); + + // ── Writer-path contract checks ─────────────────────────────────────────── + // Guards quick.md and gsd-executor.md so a future edit removing `status: complete` + // from the SUMMARY-creation instructions would fail the suite before the bug recurs. + // (source-text-is-the-product: the deployed .md text IS the runtime contract for agents) + + test('[WRITER-PATH] quick.md constraints require status: complete in SUMMARY frontmatter', () => { + const content = fs.readFileSync(QUICK_MD, 'utf-8'); + assert.ok( + /status:\s*complete/.test(content), + 'gsd-core/workflows/quick.md must instruct the executor to write `status: complete` in the SUMMARY frontmatter. ' + + 'The block must contain the `status: complete` requirement so a future edit cannot silently drop it.' + ); + }); + + test('[WRITER-PATH] gsd-executor.md frontmatter spec requires status: complete', () => { + const content = fs.readFileSync(EXECUTOR_MD, 'utf-8'); + assert.ok( + /status[\s\S]{0,40}complete/.test(content), + 'agents/gsd-executor.md must document `status: complete` as a required SUMMARY frontmatter field. ' + + 'The Frontmatter section must include `status: complete` so the executor always emits it.' + ); + }); +});