diff --git a/.changeset/archive-carries-delta-purpose.md b/.changeset/archive-carries-delta-purpose.md new file mode 100644 index 0000000000..0bd89ca20a --- /dev/null +++ b/.changeset/archive-carries-delta-purpose.md @@ -0,0 +1,13 @@ +--- +"@fission-ai/openspec": patch +--- + +A delta spec that introduces a brand-new capability can now open with a `## Purpose`, and `openspec archive` uses it as the Purpose of the main spec it creates instead of writing the `TBD - created by archiving change . Update Purpose after archive.` placeholder over it. The `specs` artifact instruction, its example, the delta template and the `openspec-sync-specs` skill all tell authors and agents to write one, so the CLI and agent-driven sync paths produce the same main spec. + +Archive keeps the placeholder when the delta has no usable `## Purpose`: + +- no `## Purpose` header outside a code fence or HTML comment, or a body that is only a code fence or only a comment +- a body that would leave a spec its own parser cannot read — a heading or requirement header that truncates a section, an unterminated fence, or any HTML comment +- in the second case archive also says why, and still completes rather than aborting + +A carried Purpose under 50 characters is kept but warned about, since `openspec validate --strict` reports it as too brief. The Purpose of an existing main spec is never touched; archive warns when it ignores a delta's Purpose there. diff --git a/docs/concepts.md b/docs/concepts.md index cafb78fd0c..caca2bc140 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -393,6 +393,7 @@ The system MUST expire sessions after 15 minutes of inactivity. | `## ADDED Requirements` | New behavior | Appended to main spec | | `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement | | `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec | +| `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists | ### Why Deltas Instead of Full Specs diff --git a/docs/writing-specs.md b/docs/writing-specs.md index 9e21e6cd80..c894c8f2cb 100644 --- a/docs/writing-specs.md +++ b/docs/writing-specs.md @@ -58,6 +58,8 @@ A change describes its edits to the specs with three section types. Using the ri On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there. +One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs//spec.md` directly to change one. + ## Right-size the change The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes. diff --git a/openspec/specs/cli-archive/spec.md b/openspec/specs/cli-archive/spec.md index f5f12ccfe4..586075ec24 100644 --- a/openspec/specs/cli-archive/spec.md +++ b/openspec/specs/cli-archive/spec.md @@ -90,6 +90,38 @@ Before moving the change to archive, the command SHALL apply delta changes to ma - **THEN** abort with error message showing the conflict - **AND** suggest manual resolution +#### Scenario: New main spec inherits the delta's Purpose + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** the delta spec has a line-initial `## Purpose` header that is not inside a fenced code block or an HTML comment +- **AND** the section body, ignoring fenced blocks and HTML comments, is not empty +- **THEN** write the section body into the new main spec, trimmed but otherwise verbatim, fenced code blocks included +- **AND** the section body runs to the next `## ` heading outside a fenced block + +#### Scenario: New main spec without an authored Purpose + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** the delta spec has no such `## Purpose` header, or that section's body is empty once fenced blocks and HTML comments are ignored +- **THEN** write the TBD placeholder Purpose naming the change to update after archive + +#### Scenario: Delta Purpose that would leave the new main spec unreadable + +- **WHEN** a delta creates a main spec that does not exist yet +- **AND** carrying its `## Purpose` body over would leave a spec that reads differently to different readers - a heading or requirement header that truncates a section, an unterminated code fence that swallows one, or any HTML comment, which the section scan skips but the file keeps +- **THEN** write the TBD placeholder Purpose instead and warn that the delta Purpose was ignored +- **AND** complete the archive rather than aborting it + +#### Scenario: Carried Purpose shorter than the strict-mode minimum + +- **WHEN** the Purpose parsed back out of the new main spec is shorter than the minimum Purpose length strict validation enforces +- **THEN** carry it over unchanged and warn that `openspec validate --strict` reports it as too brief + +#### Scenario: Delta Purpose for a capability that already has a main spec + +- **WHEN** a delta carries a `## Purpose` and the target main spec already exists +- **THEN** leave the existing Purpose untouched +- **AND** warn that the delta Purpose was ignored, naming the spec file to edit directly, but only when that spec has a Purpose of its own and it differs from the delta's + ### Requirement: Confirmation Behavior The spec update confirmation SHALL provide clear visibility into changes before they are applied. diff --git a/openspec/specs/openspec-conventions/spec.md b/openspec/specs/openspec-conventions/spec.md index 5a1b8b9619..b47a98eb3e 100644 --- a/openspec/specs/openspec-conventions/spec.md +++ b/openspec/specs/openspec-conventions/spec.md @@ -150,10 +150,18 @@ Change proposals SHALL store only the additions, modifications, and removals to The `changes/[name]/specs/` directory SHALL contain: - Delta files showing only what changes - Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements +- An optional `## Purpose` section on deltas that introduce a new capability - Normalized header matching for requirement identification - Complete requirements using the structured format - Clear indication of change type for each requirement +#### Scenario: Introducing a new capability + +- **WHEN** a delta introduces a capability that has no main spec yet +- **THEN** the delta MAY open with a `## Purpose` section describing the capability +- **AND** that Purpose SHALL seed the main spec created for it +- **AND** a delta for a capability that already has a main spec SHOULD NOT carry a `## Purpose`, because the existing Purpose is authoritative and the delta's is ignored + #### Scenario: Using standard output symbols - **WHEN** displaying delta operations in CLI output diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md index 8cc0e081a2..2232637fb0 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -55,6 +55,8 @@ The agent SHALL reconcile main specs with delta specs using the delta operation #### Scenario: New capability spec - **WHEN** delta spec exists for a capability not in main specs - **THEN** create new main spec file at `openspec/specs//spec.md` +- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does +- **AND** write a brief TBD placeholder Purpose only when the delta has none #### Scenario: Merged main spec keeps canonical structure - **WHEN** the agent writes a main spec during sync diff --git a/schemas/spec-driven/schema.yaml b/schemas/spec-driven/schema.yaml index fd0a2e131f..3f94206079 100644 --- a/schemas/spec-driven/schema.yaml +++ b/schemas/spec-driven/schema.yaml @@ -81,6 +81,16 @@ 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 - + 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 + left with a `TBD ... Update Purpose after archive` placeholder to fill in + by hand. Do NOT add `## Purpose` to a delta for an existing capability - + that spec already has one and the delta's is ignored. To change an + existing capability's Purpose - including a leftover `TBD` placeholder - + edit `openspec/specs//spec.md` directly. + MODIFIED requirements workflow: 1. Locate the existing requirement in openspec/specs//spec.md 2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios) @@ -90,8 +100,12 @@ 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: + Example (a new capability, so it opens with `## Purpose`): ``` + ## Purpose + + Lets users take their data out of the product in a portable format. + ## ADDED Requirements ### Requirement: User can export data diff --git a/schemas/spec-driven/templates/spec.md b/schemas/spec-driven/templates/spec.md index 095d711c8f..c12f44d7f5 100644 --- a/schemas/spec-driven/templates/spec.md +++ b/schemas/spec-driven/templates/spec.md @@ -1,3 +1,6 @@ +## Purpose + + ## ADDED Requirements ### Requirement: diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 122a7b6400..36af5dcd05 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -76,9 +76,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e **RENAMED Requirements:** - Find the FROM requirement, rename to TO + **`## Purpose` in the delta:** + - The main spec already has one and it is authoritative - leave it alone + (this is what `openspec archive` does; it warns and moves on) + d. **Create new main spec** if capability doesn't exist yet: - Create `/openspec/specs//spec.md` - - Add Purpose section (can be brief, mark as TBD) + - Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one + (this is what `openspec archive` does); only write a brief TBD placeholder when it does not - Add Requirements section with the ADDED requirements - Follow the **Main Spec Format Reference** below @@ -87,10 +92,16 @@ This is an **agent-driven** operation - you will read delta specs and directly e After applying all changes, summarize: - Which capabilities were updated - What changes were made (requirements added/modified/removed/renamed) + - Any new main spec left with a TBD Purpose placeholder, so it gets written + now rather than lingering **Delta Spec Format Reference** ```markdown +## Purpose + +Only on a delta that introduces a brand-new capability. Seeds the new main spec. + ## ADDED Requirements ### Requirement: New Feature diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 7e85fa4314..563769b63a 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -15,7 +15,10 @@ import { type RequirementBlock, } from './parsers/requirement-blocks.js'; import { findMainSpecStructureIssues } from './parsers/spec-structure.js'; +import { buildCodeFenceMask } from './parsers/code-fence.js'; +import { MarkdownParser } from './parsers/markdown-parser.js'; import { Validator } from './validation/validator.js'; +import { MIN_PURPOSE_LENGTH } from './validation/constants.js'; import { discoverSpecFiles } from '../utils/spec-discovery.js'; // ----------------------------------------------------------------------------- @@ -200,10 +203,28 @@ export async function buildUpdatedSpec( } // Load or create base target content + const deltaPurpose = extractPurposeSection(changeContent); let targetContent: string; let isNewSpec = false; try { targetContent = await fs.readFile(update.target, 'utf-8'); + // A delta Purpose only seeds a spec that does not exist yet. Say so rather + // than dropping it silently - the specs instruction tells authors to write + // one for new capabilities, and the delta file looks identical either way. + // Only when the spec really does have a different Purpose: claiming it + // "already has one" would be false when it has none, and saying anything at + // all is noise when the two bodies match. + if (deltaPurpose && !options.silent) { + const existingPurpose = extractPurposeSection(targetContent); + if (existingPurpose && existingPurpose !== deltaPurpose) { + console.log( + chalk.yellow( + `⚠️ Warning: ${specName} - delta Purpose ignored; ${specName} already has one. ` + + `Edit ${update.target} directly to change it.` + ) + ); + } + } } catch { // Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs // REMOVED will be ignored with a warning since there's nothing to remove @@ -221,7 +242,30 @@ export async function buildUpdatedSpec( ); } isNewSpec = true; - targetContent = buildSpecSkeleton(specName, changeName); + targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose); + const overview = deltaPurpose ? readableOverview(targetContent, specName) : null; + if (deltaPurpose && !overview) { + // Keep the placeholder rather than turning this into a failure: these + // deltas archived cleanly before the Purpose carry-over existed. + targetContent = buildSpecSkeleton(specName, changeName); + if (!options.silent) { + console.log( + chalk.yellow( + `⚠️ Warning: ${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.` + ) + ); + } + } else if (overview && overview.length < MIN_PURPOSE_LENGTH && !options.silent) { + // The placeholder always cleared this threshold, so a carried Purpose is + // the first way archive can leave a spec that `validate --strict` fails. + // Measured on the parsed overview, which is what the validator reads. + console.log( + chalk.yellow( + `⚠️ Warning: ${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` + + `openspec validate --strict reports it as too brief.` + ) + ); + } } const structureIssues = findMainSpecStructureIssues(targetContent); @@ -399,12 +443,102 @@ export async function writeUpdatedSpec( if (counts.renamed) console.log(` → ${counts.renamed} renamed`); } +/** Blank out `` spans, preserving line count so indices stay aligned. */ +function maskHtmlComments(content: string): string { + const blank = (text: string) => text.replace(/[^\n]/g, ' '); + // `--!>` is a comment terminator as well as `-->`. + const masked = content.replace(/` hides + // nothing and renders as text - rejecting it would throw away a Purpose over + // prose like "ingest --> transform". + if (skeleton.includes(' + +## Purpose + +Manages the loyalty program end to end across the storefront and admin console. + +## ADDED Requirements + +### Requirement: Earn Points +The system SHALL award loyalty points. + +#### Scenario: Points earned +- **WHEN** an order completes +- **THEN** points are credited +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'loyalty-v2', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain('Manages the loyalty program end to end'); + expect(updatedContent).not.toContain('Draft purpose the author commented out'); + expect(updatedContent).not.toContain('-->'); + }); + + it.each([ + [ + 'a section header hidden in a comment', + 'requirements-hidden-in-comment', + 'hidden-reqs', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. + +Widgets are the core unit of work. +`, + ], + [ + 'a requirement header hidden in a comment', + 'requirement-header-in-comment', + 'hidden-req-header', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. + +`, + ], + [ + 'an unterminated comment', + 'unterminated-comment', + 'dangling-comment', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. + terminator', + 'bang-terminated-comment', + 'bang-comment', + `## Purpose +Tracks widgets and keeps their state consistent across restarts. + +`, + ], + ])( + 'should fall back to the placeholder when the delta Purpose has %s (issue #1413)', + async (_label, changeName, specFolder, purposeBlock) => { + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', specFolder); + await fs.mkdir(changeSpecDir, { recursive: true }); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `${purposeBlock} +## ADDED Requirements + +### Requirement: Widget Tracking +The system SHALL track widgets. + +#### Scenario: Widget tracked +- **WHEN** a widget is created +- **THEN** it is tracked +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', specFolder, 'spec.md'), + 'utf-8' + ); + // Markdown hidden in a comment is skipped by the section scan but still + // lands in the file, where it can hide the headers the parsers rely on + // and blank the document out in a markdown renderer. + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain(''], + ['unterminated', ''], + ])( + 'should not read a Purpose out of a %s comment that opens above the header (issue #1413)', + async (label, terminator) => { + const changeName = `commented-out-purpose-${label}`; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', `co-${label}`); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // An unterminated comment runs to end of file, so the header below it is + // commented out just as surely as it is inside a closed comment. + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `` is not a comment opener; it renders as text and hides nothing, so + // it must not be mistaken for the HTML-comment hazard. + const specContent = `## Purpose + +Routes events through the pipeline: ingest --> transform --> sink, retrying each hop. + +## ADDED Requirements + +### Requirement: Route Events +The system SHALL route events through the pipeline. + +#### Scenario: Event routed +- **WHEN** an event arrives +- **THEN** it is routed +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'pipeline', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain('ingest --> transform --> sink'); + expect(updatedContent).not.toContain('TBD - created by archiving change'); + }); + + it('should keep the TBD placeholder when the delta Purpose is only a code fence (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-only-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'fenced-only'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // A code sample is not a description of the capability, so it counts as + // an absent Purpose rather than one worth carrying. + const specContent = `## Purpose + +\`\`\`yaml +retries: 3 +\`\`\` + +## ADDED Requirements + +### Requirement: Retry Requests +The system SHALL retry failed requests. + +#### Scenario: Request retried +- **WHEN** a request fails +- **THEN** it is retried +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'fenced-only', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('retries: 3'); + }); + + it('should end the Purpose at the next heading outside a code fence (issue #1413)', async () => { + const changeName = 'new-spec-with-fenced-heading'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'fenced-heading'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // The fenced `## Requirements` must not be mistaken for the end of the + // Purpose section, nor for a real section once the spec is written. + const specContent = `## Purpose + +Documents the main spec shape for readers. A main spec looks like: + +\`\`\`markdown +## Requirements + +### Requirement: Illustrative Only +\`\`\` + +## ADDED Requirements + +### Requirement: Real Requirement +The system SHALL do the real thing. + +#### Scenario: Real thing done +- **WHEN** asked +- **THEN** done +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'fenced-heading', 'spec.md'), + 'utf-8' + ); + // The whole fenced sample stays inside Purpose... + expect(updatedContent).toContain('Documents the main spec shape for readers.'); + expect(updatedContent).toContain('### Requirement: Illustrative Only'); + // ...and none of it is read as real structure. + expect(findMainSpecStructureIssues(updatedContent)).toHaveLength(0); + const spec = new MarkdownParser(updatedContent).parseSpec('fenced-heading'); + expect(spec.requirements).toHaveLength(1); + }); + + it('should keep the placeholder when the delta Purpose is only an HTML comment (issue #1413)', async () => { + const changeName = 'new-spec-with-unfilled-template'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'unfilled'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + // This is the shipped delta template left unfilled. + const specContent = `## Purpose + + +## ADDED Requirements + +### Requirement: Do Thing +The system SHALL do the thing. + +#### Scenario: Thing done +- **WHEN** asked +- **THEN** done +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'unfilled', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + `TBD - created by archiving change ${changeName}. Update Purpose after archive.` + ); + expect(updatedContent).not.toContain('New capabilities only'); + }); + + it('should warn when a carried Purpose is under the strict-mode minimum (issue #1413)', async () => { + const changeName = 'new-spec-with-brief-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'points'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const specContent = `## Purpose + +Tracks loyalty points. + +## ADDED Requirements + +### Requirement: Track Points +The system SHALL track points. + +#### Scenario: Points tracked +- **WHEN** an order completes +- **THEN** points are tracked +`; + await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'points', 'spec.md'), + 'utf-8' + ); + // The author's words are kept - the warning exists so the strict-mode + // failure is not a surprise later. + expect(updatedContent).toContain('Tracks loyalty points.'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('carried Purpose is under 50 characters') + ); + }); + + it('should not overwrite the Purpose of an existing main spec (issue #1413)', async () => { + const changeName = 'existing-spec-with-purpose'; + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'billing'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'billing'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# billing Specification + +## Purpose +The established purpose that must survive archiving. + +## Requirements + +### Requirement: Charge Card +The system SHALL charge the card on file. + +#### Scenario: Card charged +- **WHEN** an invoice is due +- **THEN** the card is charged +` + ); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## Purpose + +A purpose written in the delta that must be ignored for an existing spec. + +## ADDED Requirements + +### Requirement: Refund Card +The system SHALL refund the card on file. + +#### Scenario: Refund issued +- **WHEN** a refund is approved +- **THEN** the card is refunded +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8'); + expect(updatedContent).toContain('The established purpose that must survive archiving.'); + expect(updatedContent).not.toContain('A purpose written in the delta that must be ignored'); + expect(updatedContent).toContain('### Requirement: Refund Card'); + // Dropping it silently would be indistinguishable from it having worked. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('billing - delta Purpose ignored; billing already has one') + ); + }); + + it.each([ + [ + 'the existing spec has no Purpose at all', + 'existing-spec-without-purpose', + 'no-purpose-yet', + `# no-purpose-yet Specification + +## Requirements + +### Requirement: Old Thing +The system SHALL do the old thing. + +#### Scenario: Old done +- **WHEN** asked +- **THEN** done +`, + ], + [ + 'the existing Purpose is identical to the delta Purpose', + 'existing-spec-with-same-purpose', + 'same-purpose', + `# same-purpose Specification + +## Purpose +Shared purpose text that both files carry verbatim for this test case. + +## Requirements + +### Requirement: Old Thing +The system SHALL do the old thing. + +#### Scenario: Old done +- **WHEN** asked +- **THEN** done +`, + ], + ])( + 'should not warn about an ignored delta Purpose when %s (issue #1413)', + async (_label, changeName, specFolder, mainSpec) => { + const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', specFolder); + await fs.mkdir(changeSpecDir, { recursive: true }); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', specFolder); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainSpec); + + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## Purpose + +Shared purpose text that both files carry verbatim for this test case. + +## ADDED Requirements + +### Requirement: New Thing +The system SHALL do the new thing. + +#### Scenario: New done +- **WHEN** asked +- **THEN** done +` + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + // "already has one" is false when it has none, and noise when the two + // bodies match. + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('already has one')); + } + ); + it('should still error on MODIFIED when creating new spec file', async () => { const changeName = 'new-spec-with-modified'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 2d41198058..ab84ea92dc 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -42,7 +42,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getContinueChangeSkillTemplate: '5cc6cf74c055ae67b08373421d934ece65dacbccafbc7452ab5636df3eb9e862', getApplyChangeSkillTemplate: '0f5a15fc7fb9ad6059a5643d0e01365d27642637a4aaebf182f9eabb45348197', getFfChangeSkillTemplate: '097a9ff9533900f227cac0523289eae4e19f06a081e5f355a8374dbecf3ff55d', - getSyncSpecsSkillTemplate: '32c3169e1ee0345a174c0bacb8fd16db73477cc006d8cedbedc6077233c5461b', + getSyncSpecsSkillTemplate: '8a0e6a41250d9e5f893dd016c375ffb5773823693cb4e481ca74775bbfb9bfb9', getOnboardSkillTemplate: 'bc2216b72724b01c3a733e63b8bf4aff457f561c0e9ff7288bdacc39780a37a7', getOpsxExploreCommandTemplate: 'eef1f8b4fd90ade6d70be46f0f8c3e6722f221fed175a6f9cf626287ef504a94', getOpsxNewCommandTemplate: '57c600cce318d16b9b4308a18d0d983ea3c0673034e606a7cceec07b4c705e87', @@ -51,7 +51,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxFfCommandTemplate: '264b514cc4849f91fb4414f639484c4181f1e5850d0d788ef276c851efa92859', getArchiveChangeSkillTemplate: '206a22b6778e97c30da9145ef51fdad449b8c995538f6fc25752ef551a37b675', getBulkArchiveChangeSkillTemplate: '2b74b1f73380ff32e35f580734780d843c6161a2748c39edb07f1e00453771b4', - getOpsxSyncCommandTemplate: '68dc44c9be2ec1ef719a4ed59830e5a0bc74c3ba6113070650266e1b0d153071', + getOpsxSyncCommandTemplate: 'df0240a79f7b4943a54c7413ab088ee48f5bf5fe19f9347c170d695c8ec777a4', getVerifyChangeSkillTemplate: 'cab4db01b5d2b1243d63d90c53747d8b39e488c60f76eba3fe8b994467f69267', getOpsxArchiveCommandTemplate: '7dea65d0e2e17db366bb666ba6ae5e205ea02707b8c5c7707565200875c78916', getOpsxOnboardCommandTemplate: '9430a0fb6530791ab720e068f4b172bc3dfc4e96a1ae29102bee0b92c2afe7b5', @@ -70,7 +70,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': '02ec4de061ad6277866b877497a1e66142ba364e12b83dd7dedb838579ea88db', 'openspec-apply-change': '09c0e1cdf5ccc82416d0969d6bd715cc70616bdbc3531358a5c36057f78be55a', 'openspec-ff-change': 'ff3bd3eac427a1e50071ad7c70f73b556cffa3db43e90da2726e96849c3fc886', - 'openspec-sync-specs': 'd1bcd420bf8fb55a13f58a2857e6ebde58eb6f9e721a3bf6876bd9f640a63859', + 'openspec-sync-specs': '74de778dd8a8fd4987a09621147358cc32505bb58110492ab2b4ffe7f35aa48f', 'openspec-archive-change': '64b1611dd7aee04ca268820d1b193e8bf0a39ff3672ec6ba21fb0a1bcb1786c2', 'openspec-bulk-archive-change': '49d410bda408c0411decd584be9c2355335e3b3db760fc6a0adcd82c172a280f', 'openspec-verify-change': '57693d22940f06080c6cf8d590ac2f48240d4a5e9ce7074dacd0f8d3c9945afa',