diff --git a/.changeset/titled-artifact-templates.md b/.changeset/titled-artifact-templates.md new file mode 100644 index 0000000000..be13b4d4e2 --- /dev/null +++ b/.changeset/titled-artifact-templates.md @@ -0,0 +1,7 @@ +--- +"@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. + +`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/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 41e14d54f1..cf613bbf75 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 @@ -101,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 @@ -129,6 +143,8 @@ Each delta spec is the `spec.md` inside its capability folder. `openspec validat 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 @@ -170,7 +186,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` @@ -190,7 +206,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 @@ -209,8 +225,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. @@ -243,6 +261,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 @@ -307,6 +327,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 @@ -343,6 +365,8 @@ Guidelines: Example: ``` +# Tasks + ## 1. Setup - [ ] 1.1 Create new module structure diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index 4977be6aef..688c38a20b 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -95,7 +95,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 @@ -120,8 +120,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. @@ -211,6 +213,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/skills/openspec-onboard/SKILL.md b/skills/openspec-onboard/SKILL.md index 7beeac03c7..66de72f16c 100644 --- a/skills/openspec-onboard/SKILL.md +++ b/skills/openspec-onboard/SKILL.md @@ -231,6 +231,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -298,6 +300,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -337,6 +341,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -382,6 +388,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 41654b814f..5683f84d5a 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -192,6 +192,8 @@ In both branches, never create the root as a side effect: do not run `openspec i **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/commands/change.ts b/src/commands/change.ts index 7b61c41cfb..fa13b8655f 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -573,7 +573,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/src/commands/schema.ts b/src/commands/schema.ts index 828eaa93d9..23f00c7add 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1422,11 +1422,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 @@ -1448,7 +1454,9 @@ function createDefaultTemplate(artifactId: string): string { `; case 'specs': - return `## ADDED Requirements + return `# Spec Delta + +## ADDED Requirements ### Requirement: Example requirement @@ -1460,7 +1468,9 @@ Description of the requirement. `; case 'design': - return `## Context + return `# Design + +## Context @@ -1487,7 +1497,9 @@ Description and rationale. `; case 'tasks': - return `## Implementation Tasks + return `# Tasks + +## Implementation Tasks - [ ] Task 1 - [ ] Task 2 @@ -1495,7 +1507,7 @@ Description and rationale. `; default: - return `## ${artifactId} + return `# ${artifactId} `; diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 8b4f3b5f6a..127ce8ca24 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -295,6 +295,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -362,6 +364,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -401,6 +405,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -446,6 +452,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 45280dff4f..13a199323e 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -186,6 +186,8 @@ ${PROJECT_ROOT_GUARD} **Delta Spec Format Reference** \`\`\`markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. @@ -465,6 +467,8 @@ ${PROJECT_ROOT_GUARD} **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/commands/schema.test.ts b/test/commands/schema.test.ts index 2215dd61e0..5bd0e73231 100644 --- a/test/commands/schema.test.ts +++ b/test/commands/schema.test.ts @@ -487,6 +487,55 @@ 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 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'); + const [firstLine, secondLine] = content.replace(/\r\n?/g, '\n').split('\n'); + expect(firstLine).toBe(headings[template]); + expect(secondLine).toBe(''); + } + }); + 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 1150e54d25..3ad143aa78 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -8216,4 +8216,70 @@ 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 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.' + ); + 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..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,6 +46,47 @@ describe('instruction-loader', () => { expect(template).toContain('exact existing path under openspec/specs/'); }); + // 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', () => { expect(() => loadTemplate('spec-driven', 'nonexistent.md')).toThrow( TemplateLoadError 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', + }); + }); +}); 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 18e7933b0b..3cbe25b3a3 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 { @@ -38,6 +40,7 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; import { resolveOptionalWorkflows } from '../../../src/core/templates/optional-workflow.js'; import { ALL_WORKFLOWS } from '../../../src/core/profiles.js'; +import { parseSchema } from '../../../src/core/artifact-graph/schema.js'; /** * Templates carry optional-workflow conditionals that the production registry @@ -53,14 +56,33 @@ const asDeployed = (template: SkillTemplate): SkillTemplate => ({ ), }); +/** + * 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: 'b17a409b5634b5e48864a87f038f2111a74c2442288e9f4cb7a704f86b6d75e7', getNewChangeSkillTemplate: '0e5035b7b42198afc430206a1dbc9579096650ef0813d85e837d5a6cd0b98a85', getContinueChangeSkillTemplate: '550dc22bc8e0921b1ca5cef867379c4f370c5f1902c420bf9fa3bbfa75cea933', getApplyChangeSkillTemplate: '04ae407c97b5f9cb0cc15199fe877ccc7cd1eff78bfe10ad70c16a112b10a661', getFfChangeSkillTemplate: '6fb5492e78b9ceec068949080ec9f2e0d2a8baff75a2fe33d07ad33ffe542b65', - getSyncSpecsSkillTemplate: '2ba7107351ed2644542f089b0c224d345aca37142d7dc0db7ee8000d5425ebdf', - getOnboardSkillTemplate: '234e8237286ab7ae426923d058e66e32842211fd2d9180d62a17952f7a22d8ec', + getSyncSpecsSkillTemplate: 'bc80fe9b07eaa289e5eb8a3ce65eb7df722a16d864e37283c678220712e4f230', + getOnboardSkillTemplate: '7d92756ffc0b30053838716005610daf3f65c3fa011f3f4d29b6488f303f9cfb', getOpsxExploreCommandTemplate: 'f6cf22825643281d653355745623a6c1a4566db46cc2f262d2282243c6d8169a', getOpsxNewCommandTemplate: '6d504fef1e0d4ced7c423f4cc9d9d2cee11b1a6224edf685e06a3f0757e0ebff', getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11', @@ -68,10 +90,10 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxFfCommandTemplate: '04cb49b0bf3ebe364b45268a283564ee4fd50b78b01ec1d3f975bcae68179d2d', getArchiveChangeSkillTemplate: '8447a2489240bf0c27f863065d61453dd0264842d1dabafe27b577d6bff96eb3', getBulkArchiveChangeSkillTemplate: 'f17399959921ff98c7798e4591c8888825b7c9a83b0a90f09d98c7e0984ab793', - getOpsxSyncCommandTemplate: '1664f4c4d8698ba058d03eaf977ff1de1718f6d86110e7987f681b6d2a6a9b1f', + getOpsxSyncCommandTemplate: '60550b7bb9829421656d6324a9e4c951bc912f48f88882d1a07ce7f78397a5e7', getVerifyChangeSkillTemplate: '2e069a277dac23818b13bb50b66e806ab405bc3b7f535400e1ebf81b84153699', getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd', - getOpsxOnboardCommandTemplate: '4176f03b0be1a96668aa8c4234e8dcc1d61aba1226c1cbee9f4498a54cd0d546', + getOpsxOnboardCommandTemplate: '9cad751f7b938eea039b0ba207247776269c81bec5923eb335bee468f515f244', getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446', getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f', getOpsxProposeSkillTemplate: '1aa2f2eb9c8cbc4dcab9d777bf8832b92ca04f9ef91d0494f1224a566aefdfe8', @@ -87,11 +109,11 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': '182f015de6a1a114c79a6106c0565fd71f368d629641d0ad088de54bd871b52f', 'openspec-apply-change': 'f3e92c229fab8d77df9f0a77dcb117cf46279b53a208d53aed89bfe0bab2ac09', 'openspec-ff-change': '8ffad1b1a2deea5f097eb7294fb8b9474d5dfb1c31ee2fd3311d9a9d78259323', - 'openspec-sync-specs': 'f1d78a7f931330a766bafe5f619e587926fbd976474165eb6b8c0bc7e7e24dc1', + 'openspec-sync-specs': '3909936a236a21a9a6d5bf495f90b396b3b68fc9220d7b2c1894668653beb2e4', 'openspec-archive-change': '305a21a9c76a925055f3bdbaac504f208660ef6948d78f73928de166250609bf', 'openspec-bulk-archive-change': '4bd638a50111d2ee3a667752a2355ed513f770695b137b93fc28848ca7bf60d2', 'openspec-verify-change': 'ad8a3098bd27d852721687c47a12db7107ed8b8dfc7f071406bb19961652e7ee', - 'openspec-onboard': '41e50fb271850ff3de99c58cabdcd31338b86b592fcb1c97e1d4ed14f0fb850d', + 'openspec-onboard': 'd4c5f3e24c19c8e389950544ea0d1844027753def14748c9684210ae4c6cd5e5', 'openspec-propose': '66e3395adf9f2d93a09e8ef1d20e4efb010e5e8d4811f2d42a9316e4d1ca5a8b', 'openspec-update-change': '19163b8c1b40ccdc0840019aa8005877a90a3a1cd9f7aadb87f76ccce1342f19', }; @@ -1151,3 +1173,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'); + } + }); +});