fix(#950): emit status: complete in quick-task SUMMARY frontmatter (#951)

* fix(#950): emit status: complete in quick-task SUMMARY frontmatter

Add `status: complete` to all four SUMMARY templates (summary.md,
summary-minimal.md, summary-standard.md, summary-complex.md), to the
executor agent's documented frontmatter field list, and to the quick.md
executor constraints block. The audit-open milestone-close scanner
(scanQuickTasks) reads this field to decide whether a quick task is done;
without it the scanner falls back to `[unknown]` and false-flags finished
tasks as open. Writer-side fix; the scanner is correct and unchanged.

Blast-radius: no other scanner reads `status:` from phase-plan SUMMARY
files. Phase disk_status is derived from file-count heuristics only.
Adding the field to the shared template is therefore safe and the value
`complete` is semantically accurate for a finished plan.

Regression test: tests/bug-950-quick-summary-status-complete.test.cjs
- RED: 4 template-contract tests fail before fix, behavioral tests pass
- GREEN: all 8 tests pass after fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: add changeset for fix/950-quick-summary-status-complete (#951)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#950): assert writer-path contract + scope template checks to YAML frontmatter (adversarial review)

- Add `// allow-test-rule: source-text-is-the-product` at file top (before block comment)
- Add `extractFrontmatter()` helper that handles both leading-frontmatter files
  (summary-minimal/standard/complex.md) and fenced-frontmatter files (summary.md,
  whose frontmatter is embedded inside a ```markdown fence) — assertions now
  target the actual YAML block, not the whole file
- Scope all four [TEMPLATE CONTRACT] tests through extractFrontmatter() so a stray
  `status: complete` in prose/examples cannot produce a false green; error messages
  now print the extracted block to aid diagnosis
- Add [WRITER-PATH] quick.md test: asserts the <constraints> block instructs the
  executor to write `status: complete` in SUMMARY frontmatter
- Add [WRITER-PATH] gsd-executor.md test: asserts the Frontmatter spec documents
  `status: complete` as a required field
- Sanity-checked: guards fail when `status: complete` is removed from a template
  or from quick.md, and pass once restored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 00:22:33 -04:00
committed by GitHub
parent 46967baae8
commit a313a7e304
8 changed files with 312 additions and 2 deletions

View File

@@ -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]`.

View File

@@ -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`

View File

@@ -21,6 +21,7 @@ patterns-established:
- "Pattern 1: description"
duration: Xmin
completed: YYYY-MM-DD
status: complete
---
# Phase [X]: [Name] Summary (Complex)

View File

@@ -15,6 +15,7 @@ key-files:
key-decisions: []
duration: Xmin
completed: YYYY-MM-DD
status: complete
---
# Phase [X]: [Name] Summary (Minimal)

View File

@@ -16,6 +16,7 @@ key-decisions:
- "Decision 1"
duration: Xmin
completed: YYYY-MM-DD
status: complete
---
# Phase [X]: [Name] Summary

View File

@@ -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

View File

@@ -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 <submodule_commit_guard> 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)
</constraints>

View File

@@ -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 <constraints> 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.'
);
});
});