From 22ad65eb70e94944b601fbf11660a4c7330540ab Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 14:15:23 -0500 Subject: [PATCH 1/6] fix(templates): open generated artifacts with a top-level heading Generated proposal.md, design.md, spec.md and tasks.md started on a section header, so every OpenSpec artifact tripped markdownlint MD041 ("first line in a file should be a top-level heading") in editors that run it. The files were also, literally, documents without a title. Each packaged template now opens with `# Proposal`, `# Design`, `# Spec Delta` or `# Tasks` followed by a blank line, and `openspec schema init` scaffolds custom templates the same way. The schema's own examples and the customization docs match. Titles are inert to every reader downstream: the parsers anchor on `##` and `###`, and archive builds a new main spec from the delta's sections, so the main spec keeps its own generated `# Specification` and only that one. Closes #1138 Co-Authored-By: Claude Opus 5 --- .changeset/titled-artifact-templates.md | 5 ++ docs/customization.md | 7 ++ schemas/spec-driven/schema.yaml | 8 ++- schemas/spec-driven/templates/design.md | 2 + schemas/spec-driven/templates/proposal.md | 2 + schemas/spec-driven/templates/spec.md | 2 + schemas/spec-driven/templates/tasks.md | 2 + src/commands/schema.ts | 22 +++++-- test/commands/schema.test.ts | 41 ++++++++++++ test/core/archive.test.ts | 64 +++++++++++++++++++ .../artifact-graph/instruction-loader.test.ts | 15 +++++ 11 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 .changeset/titled-artifact-templates.md diff --git a/.changeset/titled-artifact-templates.md b/.changeset/titled-artifact-templates.md new file mode 100644 index 0000000000..b0c9b24526 --- /dev/null +++ b/.changeset/titled-artifact-templates.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Start generated proposal, spec, design, and tasks files with a top-level heading, so artifacts are complete markdown documents instead of files whose first line is a section header. Editors that run markdownlint no longer flag every OpenSpec artifact with MD041. `openspec schema init` scaffolds custom templates the same way. diff --git a/docs/customization.md b/docs/customization.md index b1143b9276..bf32b60b1a 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -276,6 +276,8 @@ Templates are markdown files that guide the AI. They're injected into the prompt ```markdown +# Proposal + ## Why @@ -294,6 +296,11 @@ Templates can include: - HTML comments with guidance for the AI - Example formats showing expected structure +Open each template with a top-level `#` heading. The artifact inherits it, so +the generated file is a complete markdown document and passes linters that +require a first-line heading (markdownlint MD041). `openspec schema` scaffolds +new templates this way already. + ### Validate Your Schema Before using a custom schema, validate it: diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index 1431c5ebb0..b0ba958d53 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -83,7 +83,7 @@ artifacts: - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently. - Every requirement MUST have at least one scenario. - New capabilities only: start the delta spec with a `## Purpose` section - + New capabilities only: the delta spec's first section is `## Purpose` - one or two sentences (50+ characters, or `openspec validate --strict` reports it as too brief) describing what the capability is for. Archive copies it into the main spec it creates; without it the new main spec is @@ -108,8 +108,10 @@ artifacts: Common pitfall: Using MODIFIED with partial content loses detail at archive time. If adding new concerns without changing existing behavior, use ADDED instead. - Example (a new capability, so it opens with `## Purpose`): + Example (a new capability, so its first section is `## Purpose`): ``` + # Spec Delta + ## Purpose Lets users take their data out of the product in a portable format. @@ -196,6 +198,8 @@ artifacts: Example: ``` + # Tasks + ## 1. Setup - [ ] 1.1 Create new module structure and verify expected files are present diff --git a/schemas/spec-driven/templates/design.md b/schemas/spec-driven/templates/design.md index 78fcc34345..dc6ca739e9 100644 --- a/schemas/spec-driven/templates/design.md +++ b/schemas/spec-driven/templates/design.md @@ -1,3 +1,5 @@ +# Design + ## Context diff --git a/schemas/spec-driven/templates/proposal.md b/schemas/spec-driven/templates/proposal.md index fe1aeb6acb..b075f32e2a 100644 --- a/schemas/spec-driven/templates/proposal.md +++ b/schemas/spec-driven/templates/proposal.md @@ -1,3 +1,5 @@ +# Proposal + ## Why diff --git a/schemas/spec-driven/templates/spec.md b/schemas/spec-driven/templates/spec.md index c12f44d7f5..da74750674 100644 --- a/schemas/spec-driven/templates/spec.md +++ b/schemas/spec-driven/templates/spec.md @@ -1,3 +1,5 @@ +# Spec Delta + ## Purpose diff --git a/schemas/spec-driven/templates/tasks.md b/schemas/spec-driven/templates/tasks.md index 88ce51ef78..148874e6fb 100644 --- a/schemas/spec-driven/templates/tasks.md +++ b/schemas/spec-driven/templates/tasks.md @@ -1,3 +1,5 @@ +# Tasks + ## 1. - [ ] 1.1 diff --git a/src/commands/schema.ts b/src/commands/schema.ts index c05d2956fe..bb7b097d95 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1408,11 +1408,17 @@ export function registerSchemaCommand(program: Command): void { /** * Create default template content for an artifact. + * + * Every template opens with a top-level heading so the artifact it produces is + * a well-formed markdown document rather than a file whose first line is a + * section header (markdownlint MD041, #1138). */ function createDefaultTemplate(artifactId: string): string { switch (artifactId) { case 'proposal': - return `## Why + return `# Proposal + +## Why @@ -1434,7 +1440,9 @@ function createDefaultTemplate(artifactId: string): string { `; case 'specs': - return `## ADDED Requirements + return `# Spec Delta + +## ADDED Requirements ### Requirement: Example requirement @@ -1446,7 +1454,9 @@ Description of the requirement. `; case 'design': - return `## Context + return `# Design + +## Context @@ -1473,7 +1483,9 @@ Description and rationale. `; case 'tasks': - return `## Implementation Tasks + return `# Tasks + +## Implementation Tasks - [ ] Task 1 - [ ] Task 2 @@ -1481,7 +1493,7 @@ Description and rationale. `; default: - return `## ${artifactId} + return `# ${artifactId} `; diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 2215dd61e0..7502efdfdb 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -487,6 +487,47 @@ artifacts: ).toContain('schema: my-workflow'); }); + it('scaffolds every template with a top-level heading', async () => { + const initialized = await runCLI( + [ + 'schema', + 'init', + 'lint-clean', + '--artifacts', + 'proposal,specs,design,tasks', + '--json', + ], + { cwd: tempDir } + ); + expect(initialized.exitCode).toBe(0); + + const templatesDir = path.join( + tempDir, + 'openspec', + 'schemas', + 'lint-clean', + 'templates' + ); + const templates = fs + .readdirSync(templatesDir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(path.relative(templatesDir, entry.parentPath), entry.name)) + .sort(); + expect(templates).toEqual([ + 'design.md', + 'proposal.md', + path.join('specs', 'spec.md'), + 'tasks.md', + ].sort()); + + // The artifact a template produces is a document in its own right, so it + // opens with an `# ` heading rather than a section header (#1138). + for (const template of templates) { + const content = fs.readFileSync(path.join(templatesDir, template), 'utf-8'); + expect(content.split('\n')[0]).toMatch(/^# \S/); + } + }); + describe.each(failureModes)('$label with --default', ({ force }) => { it('preserves the schema and invalid YAML config byte-for-byte', async () => { const { schemaDir, before } = prepareSchemaForFailure(force); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index bbdfcf1bed..071c210d66 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -7472,4 +7472,68 @@ This change exists to document greeting behavior thoroughly for the team, which await expect(fs.access(changeDir)).resolves.not.toThrow(); }); }); + // Every packaged template opens with an `# ` heading so the artifacts an + // agent writes are complete markdown documents (#1138). The delta spec is the + // one artifact archive reads back, so its title must stay inert: it belongs to + // the delta, not to the main spec archive builds from it. + describe('templates opening with a title (#1138)', () => { + it('keeps the delta spec title out of the main spec it creates', async () => { + const changeName = 'add-widget'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + await fs.mkdir(path.join(changeDir, 'specs', 'widget'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + [ + '# Proposal', + '', + '## Why', + 'Widgets are the one thing this product cannot assemble today.', + '', + '## What Changes', + '- Add the widget capability.', + '', + ].join('\n') + ); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + ['# Tasks', '', '## 1. Build', '', '- [x] 1.1 Build it', ''].join('\n') + ); + await fs.writeFile( + path.join(changeDir, 'specs', 'widget', 'spec.md'), + [ + '# Spec Delta', + '', + '## Purpose', + 'Lets users assemble widgets from parts in a repeatable way.', + '', + '## ADDED Requirements', + '', + '### Requirement: User can build a widget', + 'The system SHALL let a user build a widget.', + '', + '#### Scenario: Successful build', + '- **WHEN** a user requests a widget', + '- **THEN** the system builds it', + '', + ].join('\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + const mainSpec = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'widget', 'spec.md'), + 'utf-8' + ); + + // The main spec keeps its own generated title, and only that one. + expect(mainSpec.split('\n').filter((line) => line.startsWith('# '))).toEqual([ + '# widget Specification', + ]); + // The delta's title did not displace the Purpose archive carries over. + expect(mainSpec).toContain( + '## Purpose\nLets users assemble widgets from parts in a repeatable way.' + ); + expect(mainSpec).toContain('### Requirement: User can build a widget'); + }); + }); }); diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index ce3e153255..a181d55f21 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -23,6 +23,21 @@ describe('instruction-loader', () => { expect(template).toContain('exact existing path under openspec/specs/'); }); + it.each(['proposal.md', 'design.md', 'spec.md', 'tasks.md'])( + 'opens %s with a top-level heading', + (templateName) => { + // Artifacts inherit the template's opening line, so every packaged + // template starts the document with an `# ` heading instead of a + // section header. Without it every generated proposal.md, design.md, + // spec.md and tasks.md trips markdownlint MD041 (#1138). + const template = loadTemplate('spec-driven', templateName); + + const [firstLine, secondLine] = template.split('\n'); + expect(firstLine).toMatch(/^# \S/); + expect(secondLine).toBe(''); + } + ); + it('should throw TemplateLoadError for non-existent template', () => { expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow( TemplateLoadError From d9a088339cf14fd98f9bb9718f48b927e9a5c824 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 14:23:32 -0500 Subject: [PATCH 2/6] test(templates): compare template headings on normalized line endings The Windows runner checks out CRLF, so splitting the template on "\n" left the blank second line as "\r" and the new guards failed there while passing everywhere else. Normalize before splitting; verified against a CRLF copy of the templates locally. Co-Authored-By: Claude Opus 5 --- test/commands/schema.test.ts | 2 +- test/core/artifact-graph/instruction-loader.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 7502efdfdb..1089703972 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -524,7 +524,7 @@ artifacts: // opens with an `# ` heading rather than a section header (#1138). for (const template of templates) { const content = fs.readFileSync(path.join(templatesDir, template), 'utf-8'); - expect(content.split('\n')[0]).toMatch(/^# \S/); + expect(content.replace(/\r\n?/g, '\n').split('\n')[0]).toMatch(/^# \S/); } }); diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index a181d55f21..e44df27b73 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -32,7 +32,9 @@ describe('instruction-loader', () => { // spec.md and tasks.md trips markdownlint MD041 (#1138). const template = loadTemplate('spec-driven', templateName); - const [firstLine, secondLine] = template.split('\n'); + // The repository can be checked out with CRLF endings, so compare on + // normalized text rather than on the bytes on disk. + const [firstLine, secondLine] = template.replace(/\r\n?/g, '\n').split('\n'); expect(firstLine).toMatch(/^# \S/); expect(secondLine).toBe(''); } From 49ba9faf290d60b4d6d625774651f881840b6308 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 14:30:38 -0500 Subject: [PATCH 3/6] test(templates): pin each template to its own title Review feedback: the guards accepted any top-level heading, so a wrong title would have passed, and the archive check counted `# ` lines only, so a demoted `## Spec Delta` would have slipped through. Assert the exact heading per artifact, the blank line under it in both guards, and that no heading of any level named "Spec Delta" survives archive. Co-Authored-By: Claude Opus 5 --- test/commands/schema.test.ts | 12 +++++-- test/core/archive.test.ts | 2 ++ .../artifact-graph/instruction-loader.test.ts | 34 ++++++++++--------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/test/commands/schema.test.ts b/test/commands/schema.test.ts index 1089703972..5bd0e73231 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -521,10 +521,18 @@ artifacts: ].sort()); // The artifact a template produces is a document in its own right, so it - // opens with an `# ` heading rather than a section header (#1138). + // opens with a title and a blank line, not a section header (#1138). + const headings: Record = { + 'design.md': '# Design', + 'proposal.md': '# Proposal', + [path.join('specs', 'spec.md')]: '# Spec Delta', + 'tasks.md': '# Tasks', + }; for (const template of templates) { const content = fs.readFileSync(path.join(templatesDir, template), 'utf-8'); - expect(content.replace(/\r\n?/g, '\n').split('\n')[0]).toMatch(/^# \S/); + const [firstLine, secondLine] = content.replace(/\r\n?/g, '\n').split('\n'); + expect(firstLine).toBe(headings[template]); + expect(secondLine).toBe(''); } }); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 071c210d66..a72d702ae7 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -7529,6 +7529,8 @@ This change exists to document greeting behavior thoroughly for the team, which expect(mainSpec.split('\n').filter((line) => line.startsWith('# '))).toEqual([ '# widget Specification', ]); + // The delta's title is gone entirely, not demoted to a lower level. + expect(mainSpec).not.toMatch(/^#+\s+Spec Delta\s*$/m); // The delta's title did not displace the Purpose archive carries over. expect(mainSpec).toContain( '## Purpose\nLets users assemble widgets from parts in a repeatable way.' diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index e44df27b73..3f0e310a27 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -23,22 +23,24 @@ describe('instruction-loader', () => { expect(template).toContain('exact existing path under openspec/specs/'); }); - it.each(['proposal.md', 'design.md', 'spec.md', 'tasks.md'])( - 'opens %s with a top-level heading', - (templateName) => { - // Artifacts inherit the template's opening line, so every packaged - // template starts the document with an `# ` heading instead of a - // section header. Without it every generated proposal.md, design.md, - // spec.md and tasks.md trips markdownlint MD041 (#1138). - const template = loadTemplate('spec-driven', templateName); - - // The repository can be checked out with CRLF endings, so compare on - // normalized text rather than on the bytes on disk. - const [firstLine, secondLine] = template.replace(/\r\n?/g, '\n').split('\n'); - expect(firstLine).toMatch(/^# \S/); - expect(secondLine).toBe(''); - } - ); + it.each([ + ['proposal.md', '# Proposal'], + ['design.md', '# Design'], + ['spec.md', '# Spec Delta'], + ['tasks.md', '# Tasks'], + ])('opens %s with %s', (templateName, heading) => { + // Artifacts inherit the template's opening line, so every packaged + // template starts the document with an `# ` heading instead of a section + // header. Without it every generated proposal.md, design.md, spec.md and + // tasks.md trips markdownlint MD041 (#1138). + const template = loadTemplate('spec-driven', templateName); + + // The repository can be checked out with CRLF endings, so compare on + // normalized text rather than on the bytes on disk. + const [firstLine, secondLine] = template.replace(/\r\n?/g, '\n').split('\n'); + expect(firstLine).toBe(heading); + expect(secondLine).toBe(''); + }); it('should throw TemplateLoadError for non-existent template', () => { expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow( From 8440ed049fb8ec9b96baef389c6b40a3c65e0c58 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 3 Sep 2026 14:58:20 -0500 Subject: [PATCH 4/6] fix(workflows): teach the artifact titles everywhere the shape is shown The templates were only half the story. The onboarding walkthrough drafts each artifact in the conversation and then saves what it drafted, so its previews would have written untitled proposal.md, spec.md, design.md and tasks.md whatever the template said. The sync workflow's delta format reference had the same gap, sitting directly beneath a main-spec reference that does carry a title. Both now show the template's title, and `docs/opsx.md` no longer documents a `template` value the CLI never returned. Guards added: - The template guard now enumerates every artifact of every packaged schema from schema.yaml rather than a hardcoded list of four, and the exact-title table must name every artifact the schema declares. - A drift guard reads the titles out of the packaged templates and requires the onboard and sync surfaces to show those same titles, so guidance and template cannot part ways again. - Parser tests pin the claim the fix rests on: a title is inert, and a spec or proposal parses identically with and without one. Regenerated the skill mirrors and parity hashes for the two workflows touched; no other hash moved. Co-Authored-By: Claude Opus 5 --- docs/opsx.md | 2 +- skills/openspec-onboard/SKILL.md | 8 ++ skills/openspec-sync-specs/SKILL.md | 2 + src/core/templates/workflows/onboard.ts | 8 ++ src/core/templates/workflows/sync-specs.ts | 4 + .../artifact-graph/instruction-loader.test.ts | 79 +++++++++++++++---- test/core/parsers/markdown-parser.test.ts | 44 +++++++++++ .../templates/skill-templates-parity.test.ts | 67 ++++++++++++++-- 8 files changed, 190 insertions(+), 24 deletions(-) diff --git a/docs/opsx.md b/docs/opsx.md index c1b2dba37f..5aa092bf76 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -521,7 +521,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no │ │ $ openspec instructions specs --change "add-auth" --json │ │ │ │ │ │ │ │ { │ │ - │ │ "template": "# Specification\n\n## ADDED Requirements...", │ │ + │ │ "template": "# Spec Delta\n\n## ADDED Requirements...", │ │ │ │ "dependencies": [{"id": "proposal", "path": "...", "done": true}│ │ │ │ "unlocks": ["tasks"] │ │ │ │ } │ │ diff --git a/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index fb3f13bec7..c50ea0cabe 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -220,6 +220,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -287,6 +289,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -326,6 +330,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -371,6 +377,8 @@ Here are the implementation tasks: --- +# Tasks + ## 1. [Category or file] - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index d12d56b857..9c0662578a 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -166,6 +166,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Delta Spec Format Reference** ```markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 414c6e18b5..64a6cbb42c 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -230,6 +230,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -297,6 +299,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -336,6 +340,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -381,6 +387,8 @@ Here are the implementation tasks: --- +# Tasks + ## 1. [Category or file] - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index bedbaa7164..0d338379f6 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -168,6 +168,8 @@ ${STORE_SELECTION_GUIDANCE} **Delta Spec Format Reference** \`\`\`markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. @@ -430,6 +432,8 @@ ${STORE_SELECTION_GUIDANCE} **Delta Spec Format Reference** \`\`\`markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. diff --git a/test/core/artifact-graph/instruction-loader.test.ts b/test/core/artifact-graph/instruction-loader.test.ts index 3f0e310a27..33a8e6f19a 100644 --- a/test/core/artifact-graph/instruction-loader.test.ts +++ b/test/core/artifact-graph/instruction-loader.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { parseSchema } from '../../../src/core/artifact-graph/schema.js'; import { loadTemplate, loadChangeContext, @@ -10,6 +11,28 @@ import { TemplateLoadError, } from '../../../src/core/artifact-graph/instruction-loader.js'; +const PACKAGED_SCHEMAS_DIR = path.join(__dirname, '..', '..', '..', 'schemas'); + +/** Every artifact of every packaged schema, with the template it generates from. */ +function packagedArtifacts(): Array<[schema: string, artifactId: string, template: string]> { + return fs + .readdirSync(PACKAGED_SCHEMAS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const schemaPath = path.join(PACKAGED_SCHEMAS_DIR, entry.name, 'schema.yaml'); + const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); + return schema.artifacts.map( + (artifact): [string, string, string] => [entry.name, artifact.id, artifact.template] + ); + }); +} + +/** First line and the line under it, on normalized endings (the repo may be checked out CRLF). */ +function openingLines(template: string): [string, string] { + const [firstLine = '', secondLine = ''] = template.replace(/\r\n?/g, '\n').split('\n'); + return [firstLine, secondLine]; +} + describe('instruction-loader', () => { describe('loadTemplate', () => { it('should load template from schema directory', () => { @@ -23,23 +46,45 @@ describe('instruction-loader', () => { expect(template).toContain('exact existing path under openspec/specs/'); }); - it.each([ - ['proposal.md', '# Proposal'], - ['design.md', '# Design'], - ['spec.md', '# Spec Delta'], - ['tasks.md', '# Tasks'], - ])('opens %s with %s', (templateName, heading) => { - // Artifacts inherit the template's opening line, so every packaged - // template starts the document with an `# ` heading instead of a section - // header. Without it every generated proposal.md, design.md, spec.md and - // tasks.md trips markdownlint MD041 (#1138). - const template = loadTemplate('spec-driven', templateName); - - // The repository can be checked out with CRLF endings, so compare on - // normalized text rather than on the bytes on disk. - const [firstLine, secondLine] = template.replace(/\r\n?/g, '\n').split('\n'); - expect(firstLine).toBe(heading); - expect(secondLine).toBe(''); + // Artifacts inherit the template's opening line, so every packaged template + // starts the document with an `# ` title instead of a section header. + // Without it every generated proposal.md, design.md, spec.md and tasks.md + // trips markdownlint MD041 (#1138). + it.each(packagedArtifacts())( + 'opens the %s schema\'s %s template with a title', + (schemaName, _artifactId, templateName) => { + const [firstLine, secondLine] = openingLines(loadTemplate(schemaName, templateName)); + + expect(firstLine).toMatch(/^# \S/); + expect(secondLine).toBe(''); + } + ); + + describe('spec-driven titles', () => { + const TITLES: Record = { + proposal: '# Proposal', + specs: '# Spec Delta', + design: '# Design', + tasks: '# Tasks', + }; + + const artifacts = packagedArtifacts().filter(([schemaName]) => schemaName === 'spec-driven'); + + // Pins the wording, not just the shape: a template retitled by accident + // would pass the guard above. + it.each(artifacts)('titles %s\'s %s artifact', (schemaName, artifactId, templateName) => { + const [firstLine] = openingLines(loadTemplate(schemaName, templateName)); + + expect(firstLine).toBe(TITLES[artifactId]); + }); + + // And the table covers the whole schema, so a new artifact cannot be + // added without deciding what its document is called. + it('names every artifact the schema declares', () => { + expect(artifacts.map(([, artifactId]) => artifactId).sort()).toEqual( + Object.keys(TITLES).sort() + ); + }); }); it('should throw TemplateLoadError for non-existent template', () => { diff --git a/test/core/parsers/markdown-parser.test.ts b/test/core/parsers/markdown-parser.test.ts index 7083fd95f2..849e511441 100644 --- a/test/core/parsers/markdown-parser.test.ts +++ b/test/core/parsers/markdown-parser.test.ts @@ -512,4 +512,48 @@ The system SHALL do something real. ); }); }); + // Every packaged template now opens the artifact with an `# ` title (#1138). + // A title changes the section tree - `## Purpose` becomes a child of the + // title rather than a root - so pin that the parsers read the document the + // same either way. Main specs have carried a title all along; this says the + // change artifacts can too. + describe('an artifact title is inert (#1138)', () => { + const SPEC_BODY = `## Purpose +Lets users assemble widgets from parts in a repeatable way. + +## Requirements + +### Requirement: User can build a widget +The system SHALL let a user build a widget. + +#### Scenario: Successful build +- **WHEN** a user requests a widget +- **THEN** the system builds it +`; + + const PROPOSAL_BODY = `## Why +Widgets are the one thing this product cannot assemble today. + +## What Changes +- Add the widget capability. +`; + + it('parses a spec the same with and without one', () => { + const untitled = new MarkdownParser(SPEC_BODY).parseSpec('widget'); + const titled = new MarkdownParser(`# widget Specification\n\n${SPEC_BODY}`).parseSpec( + 'widget' + ); + + expect(titled).toEqual(untitled); + }); + + it('parses a proposal the same with and without one', () => { + const untitled = new MarkdownParser(PROPOSAL_BODY).parseChange('add-widget'); + const titled = new MarkdownParser(`# Proposal\n\n${PROPOSAL_BODY}`).parseChange( + 'add-widget' + ); + + expect(titled).toEqual(untitled); + }); + }); }); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 3f309bdaa3..ede8a6c824 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -1,4 +1,6 @@ import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -36,6 +38,26 @@ import { getSkillTemplates, } from '../../../src/core/shared/skill-generation.js'; import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; +import { parseSchema } from '../../../src/core/artifact-graph/schema.js'; + +/** + * The title `spec-driven` gives each artifact, read from the packaged templates + * so guidance and template cannot drift apart. + */ +function specDrivenTitles(): Record { + const schemaDir = path.join(__dirname, '..', '..', '..', 'schemas', 'spec-driven'); + const schema = parseSchema(fs.readFileSync(path.join(schemaDir, 'schema.yaml'), 'utf-8')); + + return Object.fromEntries( + schema.artifacts.map((artifact) => [ + artifact.id, + fs + .readFileSync(path.join(schemaDir, 'templates', artifact.template), 'utf-8') + .replace(/\r\n?/g, '\n') + .split('\n')[0], + ]) + ); +} const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: '6315fcc5c2eb848963bc8bca4c23e657412a99608e610daee59fb4e58cd21fd4', @@ -43,8 +65,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: 'efa6a70c111b18b61a7720250b9622afa9a212fb64edf609cf80e2182a9bdf8c', - getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', - getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', + getSyncSpecsSkillTemplate: 'b4964c2e02ad80c37ddb8f83d51860d644b47863f2b642320ce4d5dfe53c2916', + getOnboardSkillTemplate: '2162113374514ea32716565c4f9bb01966614b593238bd8321326ddded9ee93a', getOpsxExploreCommandTemplate: 'b4706a5b8fd280f7929eea610ecc9d41676b2d2dd6653d259cbbc2bfe01813d9', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', @@ -52,10 +74,10 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db', getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', - getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', + getOpsxSyncCommandTemplate: '017f70ef8b88a8341cfe8848cb79b3bd40d48a41fbf2cb6fef6037f8a6af1d63', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', - getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', + getOpsxOnboardCommandTemplate: 'b062a2aac0dd47e6faf164089a15d362b09bdaf20a2f5d9c37d07d2779541c5d', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', getOpsxProposeSkillTemplate: '9c0fbf0137151bd03ec30c45180f83daec96e8976ceaf517c63147f84b803446', @@ -71,11 +93,11 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623', - 'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', + 'openspec-sync-specs': '6b0d6fcab97801ec67ebefec1c5f095e1fe793bc80cf81de3fadd7ac9be4153e', 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', - 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', + 'openspec-onboard': '655ad20ebd94d92d245f0f5680a9f6505ae2bd0045a5785b4430dacf53e697f1', 'openspec-propose': 'e358b45102a88082cf20f5c4441cba02533724ad6eef8ed15ba174e3496cb6ed', 'openspec-update-change': '586547406aca94422dfeb3ffedce6c01049429b743f57ce829baa79ebc714d51', }; @@ -999,3 +1021,36 @@ describe('apply skill/command shared instruction core', () => { expect(getOpsxApplyCommandTemplate().content).toBe(core); }); }); + +describe('workflow guidance matches the packaged templates (#1138)', () => { + // Onboard drafts each artifact in the conversation and then saves what it + // drafted, so a preview missing the template's title writes an untitled file + // no matter what the template says. + it('shows every artifact title in the onboarding walkthrough', () => { + const titles = specDrivenTitles(); + const surfaces: Array<[string, string]> = [ + ['onboard skill', getOnboardSkillTemplate().instructions], + ['opsx onboard command', getOpsxOnboardCommandTemplate().content], + ]; + + for (const [surface, text] of surfaces) { + for (const artifactId of ['proposal', 'specs', 'design', 'tasks']) { + expect(text, `${surface} / ${artifactId}`).toContain(`\n${titles[artifactId]}\n`); + } + } + }); + + // The sync workflow prints a delta reference right beside the main-spec one. + // The two are only telling them apart if the delta carries its own title. + it('titles the delta spec in the sync format reference', () => { + const titles = specDrivenTitles(); + + for (const [surface, text] of [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['opsx sync command', getOpsxSyncCommandTemplate().content], + ] as Array<[string, string]>) { + expect(text, surface).toContain(`\n${titles.specs}\n\n## Purpose\n`); + expect(text, surface).toContain('\n# Specification\n'); + } + }); +}); From 9faba3272cdd07a5cb09005ec9875edf4ab86c90 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 7 Sep 2026 10:43:41 -0500 Subject: [PATCH 5/6] docs: move the artifact-title update to the canonical docs-lab tree The live site builds from docs-lab/, so the exact-format page there is the one readers see. It still showed all four templates opening on a section header and described the delta as starting with `## Purpose`. - reference/schemas/spec-driven/index.md: each template block now matches the shipped template byte for byte, and the quoted spec/tasks instructions match schema.yaml again. - customize/schemas.md: one line telling fork authors to keep the `#` title on the first line. Reverts the edits to docs/customization.md and docs/opsx.md: that tree is no longer published and docs-lab/README.md keeps it as source material only, so editing it would leave two versions of the same fact. Co-Authored-By: Claude Opus 5 --- docs-lab/customize/schemas.md | 2 +- docs-lab/reference/schemas/spec-driven/index.md | 16 ++++++++++++++-- docs/customization.md | 7 ------- docs/opsx.md | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs-lab/customize/schemas.md b/docs-lab/customize/schemas.md index a8efd2ac10..9d692884b9 100644 --- a/docs-lab/customize/schemas.md +++ b/docs-lab/customize/schemas.md @@ -125,7 +125,7 @@ The scaffold is bare. Artifacts come from the built-in four ids only, and the ge A fork has two kinds of files to edit: -- **templates/** change the skeleton of each document. Add a section to the tasks template and every new tasks.md starts with it. +- **templates/** change the skeleton of each document. Add a section to the tasks template and every new tasks.md starts with it. Keep the `#` title on the first line: the artifact inherits it, so every generated file opens as a titled document. - **schema.yaml** changes the workflow itself: which artifacts exist, what each one requires first, and the instruction the agent gets when creating it. For example, to drop the design document for a leaner flow: diff --git a/docs-lab/reference/schemas/spec-driven/index.md b/docs-lab/reference/schemas/spec-driven/index.md index be7f1c4c61..3e7ba62023 100644 --- a/docs-lab/reference/schemas/spec-driven/index.md +++ b/docs-lab/reference/schemas/spec-driven/index.md @@ -54,6 +54,8 @@ Establishes why the change is needed. The template the agent receives as the output format ([templates/proposal.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/proposal.md)): ```md +# Proposal + ## Why @@ -127,6 +129,8 @@ Defines what behavior changes, with one delta spec per capability the proposal l The template the agent receives as the output format ([templates/spec.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/spec.md)): ```md +# Spec Delta + ## Purpose @@ -188,7 +192,7 @@ Format requirements: - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently. - Every requirement MUST have at least one scenario. -New capabilities only: start the delta spec with a `## Purpose` section - +New capabilities only: the delta spec's first section is `## Purpose` - one or two sentences (50+ characters, or `openspec validate --strict` reports it as too brief) describing what the capability is for. Archive copies it into the main spec it creates; without it the new main spec is @@ -207,8 +211,10 @@ MODIFIED requirements workflow: Common pitfall: Using MODIFIED with partial content loses detail at archive time. If adding new concerns without changing existing behavior, use ADDED instead. -Example (a new capability, so it opens with `## Purpose`): +Example (a new capability, so its first section is `## Purpose`): ``` +# Spec Delta + ## Purpose Lets users take their data out of the product in a portable format. @@ -241,6 +247,8 @@ Explains how to implement the change. Drafted only when the change needs one. The template the agent receives as the output format ([templates/design.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/design.md)): ```md +# Design + ## Context @@ -305,6 +313,8 @@ Breaks the implementation into checkable tasks. [apply](#apply) tracks progress The template the agent receives as the output format ([templates/tasks.md](https://github.com/Fission-AI/OpenSpec/blob/main/schemas/spec-driven/templates/tasks.md)): ```md +# Tasks + ## 1. - [ ] 1.1 @@ -338,6 +348,8 @@ Guidelines: Example: ``` +# Tasks + ## 1. Setup - [ ] 1.1 Create new module structure diff --git a/docs/customization.md b/docs/customization.md index bf32b60b1a..b1143b9276 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -276,8 +276,6 @@ Templates are markdown files that guide the AI. They're injected into the prompt ```markdown -# Proposal - ## Why @@ -296,11 +294,6 @@ Templates can include: - HTML comments with guidance for the AI - Example formats showing expected structure -Open each template with a top-level `#` heading. The artifact inherits it, so -the generated file is a complete markdown document and passes linters that -require a first-line heading (markdownlint MD041). `openspec schema` scaffolds -new templates this way already. - ### Validate Your Schema Before using a custom schema, validate it: diff --git a/docs/opsx.md b/docs/opsx.md index 5aa092bf76..c1b2dba37f 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -521,7 +521,7 @@ Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, no │ │ $ openspec instructions specs --change "add-auth" --json │ │ │ │ │ │ │ │ { │ │ - │ │ "template": "# Spec Delta\n\n## ADDED Requirements...", │ │ + │ │ "template": "# Specification\n\n## ADDED Requirements...", │ │ │ │ "dependencies": [{"id": "proposal", "path": "...", "done": true}│ │ │ │ "unlocks": ["tasks"] │ │ │ │ } │ │ From 70ccebbfdd30508a43053f9888d6177cb386e265 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 15 Sep 2026 07:56:22 -0500 Subject: [PATCH 6/6] fix(show): keep the change id as title for a bare `# Proposal` heading The packaged proposal template now opens with `# Proposal`, which `extractTitle` read as the change's title, so `show --json` and `change list --json/--long` titled every templated change "Proposal" instead of its id. Treat that bare heading as untitled. Also re-quote the proposal and specs instructions in the canonical docs-lab schema page after #1700 changed schema.yaml. Co-Authored-By: Claude Opus 5 --- .changeset/titled-artifact-templates.md | 2 + .../reference/schemas/spec-driven/index.md | 16 ++++- src/commands/change.ts | 5 +- .../change-command.show-validate.test.ts | 58 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.changeset/titled-artifact-templates.md b/.changeset/titled-artifact-templates.md index b0c9b24526..be13b4d4e2 100644 --- a/.changeset/titled-artifact-templates.md +++ b/.changeset/titled-artifact-templates.md @@ -3,3 +3,5 @@ --- Start generated proposal, spec, design, and tasks files with a top-level heading, so artifacts are complete markdown documents instead of files whose first line is a section header. Editors that run markdownlint no longer flag every OpenSpec artifact with MD041. `openspec schema init` scaffolds custom templates the same way. + +`openspec show --json` and `openspec change list --json` keep naming a change by its id when its proposal opens with the template's bare `# Proposal` title. diff --git a/docs-lab/reference/schemas/spec-driven/index.md b/docs-lab/reference/schemas/spec-driven/index.md index 3e7ba62023..10fb281d8b 100644 --- a/docs-lab/reference/schemas/spec-driven/index.md +++ b/docs-lab/reference/schemas/spec-driven/index.md @@ -103,7 +103,19 @@ Sections: - **Impact**: Affected code, APIs, dependencies, or systems. IMPORTANT: The Capabilities section is critical. It creates the contract between -proposal and specs phases. Research existing specs before filling this in. +proposal and specs phases. Research existing specs before filling this in: +run `openspec list --specs` for the project's capability inventory, then +`openspec show "" --type spec --json --no-scenarios` for any that +look related - that returns a capability's purpose and requirement texts +without pulling whole spec files into context. Append `--store ""` to +both commands only for a registered standalone store, and keep `--type +spec`: a change and a spec sharing a name is otherwise an ambiguous-item +error. `openspec list` without `--specs` lists in-flight changes, not +specs - it never shows what the project already covers. Reuse an existing +capability's exact path instead of introducing a near-duplicate name. +The filtered read is only an overview. Before deciding what is already +covered or what should change, read each relevant spec in full, including +scenarios, with `openspec show "" --type spec` (same `--store` rule). Each capability listed here will need a corresponding spec file. Every change must either declare at least one capability (new or @@ -172,7 +184,7 @@ Create one spec file per capability listed in the proposal's Capabilities sectio `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path: - New capabilities: use the exact path from the proposal at `specs//spec.md`. Any path segment newly introduced in the proposal must be kebab-case. Follow the project's existing organization; do not add a new domain level when the project uses a flat layout. -- Modified capabilities: use the exact existing path from `openspec/specs//` when creating the delta at `specs//spec.md`. Do not move or rename the capability. +- Modified capabilities: use the exact existing path from `openspec/specs//` when creating the delta at `specs//spec.md`. Run `openspec list --specs` to confirm that path before writing the delta, appending `--store ""` only for a registered standalone store - a mistyped or invented path targets a capability that does not exist rather than the one you meant. Do not move or rename the capability. There must be at least one spec file unless the change's `.openspec.yaml` sets `skip_specs: true` (no spec-level behavior change) - `openspec validate` diff --git a/src/commands/change.ts b/src/commands/change.ts index 8cd23b3030..a5489e82a5 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -562,7 +562,10 @@ export class ChangeCommand { private extractTitle(content: string, changeName: string): string { const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im); - return match ? match[1].trim() : changeName; + const title = match?.[1].trim(); + // The packaged template opens every proposal with a bare `# Proposal`, + // which names the document rather than the change. + return title && title.toLowerCase() !== 'proposal' ? title : changeName; } private printNextSteps(issues: Array<{ message: string }> = []): void { diff --git a/test/core/commands/change-command.show-validate.test.ts b/test/core/commands/change-command.show-validate.test.ts index b732067cd0..15053d1d68 100644 --- a/test/core/commands/change-command.show-validate.test.ts +++ b/test/core/commands/change-command.show-validate.test.ts @@ -189,3 +189,61 @@ describe('ChangeCommand.show/validate', () => { await expect(cmd.validate(path.join('..', '..', 'outside'))).rejects.toThrow(/not found at/u); }); }); + +describe('ChangeCommand title from the packaged proposal template (#1138)', () => { + // The spec-driven template opens every proposal with the same `# Proposal` + // heading. That names the document, not the change, so it must not become + // the title of every change. + let cmd: ChangeCommand; + let tempRoot: string; + let originalCwd: string; + + beforeAll(async () => { + cmd = new ChangeCommand(); + originalCwd = process.cwd(); + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-template-title-')); + const proposals: Record = { + 'templated-change': '# Proposal\n\n## Why\nTemplate shape.\n\n## What Changes\n- **auth:** Add requirement\n', + 'named-change': '# Proposal: Named Change\n\n## Why\nNamed.\n\n## What Changes\n- **auth:** Add requirement\n', + }; + for (const [name, proposal] of Object.entries(proposals)) { + const dir = path.join(tempRoot, 'openspec', 'changes', name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'proposal.md'), proposal, 'utf-8'); + } + process.chdir(tempRoot); + }); + + afterAll(async () => { + process.chdir(originalCwd); + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + async function captureLog(fn: () => Promise): Promise { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg?: any, ...args: any[]) => { + logs.push([msg, ...args].filter(Boolean).join(' ')); + }; + try { + await fn(); + } finally { + console.log = origLog; + } + return logs.join('\n'); + } + + it('show --json falls back to the change id for a bare `# Proposal` title', async () => { + const parsed = JSON.parse(await captureLog(() => cmd.show('templated-change', { json: true }))); + expect(parsed.title).toBe('templated-change'); + }); + + it('list --json falls back to the change id and keeps authored titles', async () => { + const parsed = JSON.parse(await captureLog(() => cmd.list({ json: true }))); + const titles = Object.fromEntries(parsed.map((c: { id: string; title: string }) => [c.id, c.title])); + expect(titles).toEqual({ + 'named-change': 'Proposal: Named Change', + 'templated-change': 'templated-change', + }); + }); +});