diff --git a/.changeset/clever-deer-wander.md b/.changeset/clever-deer-wander.md new file mode 100644 index 000000000..14709e09e --- /dev/null +++ b/.changeset/clever-deer-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 640 +--- +**`gsd-tools query summary-extract` no longer drops snake_case `requirements_completed`** — the reader now accepts both the kebab `requirements-completed` and the snake `requirements_completed` key forms (the snake form is what the tool's own JSON output and the milestone audit `--pick` emit), so a round-tripped requirements field is no longer silently read back as empty. diff --git a/src/commands.cts b/src/commands.cts index b23c9ae80..a269e0a5a 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -750,7 +750,11 @@ function cmdSummaryExtract(cwd: string, summaryPath: string | undefined, fields: tech_added: (techStack && techStack['added']) || [], patterns: fm['patterns-established'] || [], decisions: parseDecisions(fm['key-decisions']), - requirements_completed: fm['requirements-completed'] || [], + // Tolerate both key forms: the template/reader use kebab `requirements-completed`, + // but the tool's own JSON output and the milestone audit `--pick` use snake + // `requirements_completed`. Reading both prevents a snake-keyed SUMMARY (the form the + // tool emits) from being silently dropped to []. See #628. + requirements_completed: fm['requirements-completed'] ?? fm['requirements_completed'] ?? [], }; // If fields specified, filter to only those fields diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index e4424fe35..96052db29 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -424,6 +424,61 @@ one-liner: Minimal summary assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty'); }); + test('reads requirements in snake_case form the tool itself emits (#628)', () => { + // Regression: the tool's JSON output key and the milestone-audit `--pick` both use the + // snake form `requirements_completed`, so operators naturally write that into SUMMARY + // frontmatter. The reader must accept it, not silently drop it to []. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Snake-keyed summary +requirements_completed: + - REQ-1 + - REQ-2 +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.requirements_completed, ['REQ-1', 'REQ-2'], + 'snake-case requirements_completed should be read, not dropped to []'); + }); + + test('prefers kebab requirements-completed when both key forms are present (#628)', () => { + // kebab is the documented template form and must win the tolerance fallback. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Both key forms present +requirements-completed: + - KEBAB-1 +requirements_completed: + - SNAKE-1 +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.requirements_completed, ['KEBAB-1'], + 'kebab key should take precedence over snake when both are present'); + }); + test('parses key-decisions with rationale', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true });