From 9f3dc0c6f11632d86675b556dcd7e356bd4cefff Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 19 Aug 2026 11:14:25 -0500 Subject: [PATCH 1/7] fix(workflows): create the main spec when a capability is new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-driven archive workflow told agents to "compare each delta spec with its corresponding main spec" and said nothing about the case where that main spec does not exist yet. Comparing against nothing reads as "already synced", so the agent took the archive branch and the new capability's main spec was never written — the change landed in changes/archive/ with openspec/specs/ still empty. `openspec archive` already handles this: buildUpdatedSpec creates the spec from the delta's ADDED requirements, rejects MODIFIED/RENAMED with "only ADDED requirements are allowed for new specs", and warns past REMOVED. The guidance now says the same thing, so the agent path and the CLI path agree: - archive-change: a missing main spec counts as changes needed and is named in the summary as a spec the sync will create — never as already synced. - sync-specs: MODIFIED and RENAMED have no requirement to act on when the main spec is absent, so the sync stops and reports rather than inventing one; REMOVED is skipped with a warning. Guidance text only — no CLI, parser, or archive behavior changes. Closes #1222 Closes #1264 Co-Authored-By: Claude Opus 5 --- .changeset/tidy-spiders-shave.md | 18 ++++++ openspec/specs/opsx-archive-skill/spec.md | 8 +++ openspec/specs/specs-sync-skill/spec.md | 7 +++ skills/openspec-archive-change/SKILL.md | 1 + skills/openspec-sync-specs/SKILL.md | 7 +++ .../templates/workflows/archive-change.ts | 2 + src/core/templates/workflows/sync-specs.ts | 14 +++++ .../templates/skill-templates-parity.test.ts | 63 +++++++++++++++++-- 8 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 .changeset/tidy-spiders-shave.md diff --git a/.changeset/tidy-spiders-shave.md b/.changeset/tidy-spiders-shave.md new file mode 100644 index 0000000000..44a83420d4 --- /dev/null +++ b/.changeset/tidy-spiders-shave.md @@ -0,0 +1,18 @@ +--- +"@fission-ai/openspec": patch +--- + +fix(workflows): create the main spec when a capability is new + +The agent-driven archive workflow told agents to compare each delta spec with +its main spec, but said nothing about the case where that main spec does not +exist yet. Agents read "nothing to compare" as "already synced", archived the +change, and the capability's main spec was never written — the exact case +`openspec archive` handles by creating the spec from the delta's ADDED +requirements. The archive workflow now counts a missing main spec as changes +needed and names it as a spec the sync will create. + +The sync workflow gained the matching rule for the other half: MODIFIED and +RENAMED have no requirement to act on when the main spec does not exist, so it +stops and reports instead of inventing one, matching the CLI's +"only ADDED requirements are allowed for new specs" error. diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 2c76461e54..90259b0217 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -82,6 +82,14 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **AND** stop without archiving if the sync fails or any capability does not verify - **AND** archive only after verification passes, or when the user explicitly chose to archive without syncing or to archive already-synced specs +#### Scenario: Delta spec whose main spec does not exist yet + +- **WHEN** agent compares a delta spec against its main spec at `openspec/specs//spec.md` +- **AND** that main spec does not exist yet +- **THEN** count that capability as needing sync rather than as already synced +- **AND** name it in the summary as a main spec the sync will create +- **AND** never treat the missing main spec as nothing to apply + #### Scenario: No delta specs - **WHEN** agent checks for delta specs diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md index 3d14288802..a293caf477 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -75,6 +75,13 @@ The agent SHALL reconcile main specs with delta specs using the delta operation - **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: MODIFIED or RENAMED against a capability with no main spec +- **WHEN** delta contains `## MODIFIED Requirements` or `## RENAMED Requirements` +- **AND** the capability has no main spec yet +- **THEN** stop the sync for that capability and report that only ADDED requirements are allowed for a new spec, matching what `openspec archive` does +- **AND** never invent the missing requirement +- **AND** skip any `## REMOVED Requirements` with a warning, since there is nothing to remove + #### Scenario: Merged main spec keeps canonical structure - **WHEN** the agent writes a main spec during sync - **THEN** every requirement lives under a single `## Requirements` section diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index 5f34ed53a7..42ca9146a9 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -94,6 +94,7 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as `openspec archive` does. Count that capability as changes needed and name it in the summary (`: new main spec will be created`). Never read a missing main spec as nothing to apply. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index d12d56b857..7a532f6c89 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -95,6 +95,13 @@ This is an **agent-driven** operation - you will read delta specs and directly e b. **Read the main spec** at `/openspec/specs//spec.md` (may not exist yet) + **If it does not exist yet** (a new capability), match what `openspec archive` does: + only ADDED requirements may be applied - step d creates the spec from them. + MODIFIED and RENAMED have no requirement to act on, so stop the sync for that + capability and report that its main spec does not exist and only ADDED is allowed + for a new spec; never invent the missing requirement. REMOVED has nothing to + remove - skip it and warn. + c. **Apply changes intelligently**: **ADDED Requirements:** diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 2dae74d436..cd238edcb8 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -96,6 +96,7 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -278,6 +279,7 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index bedbaa7164..ca6ceddbcf 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -97,6 +97,13 @@ ${STORE_SELECTION_GUIDANCE} b. **Read the main spec** at \`/openspec/specs//spec.md\` (may not exist yet) + **If it does not exist yet** (a new capability), match what \`openspec archive\` does: + only ADDED requirements may be applied - step d creates the spec from them. + MODIFIED and RENAMED have no requirement to act on, so stop the sync for that + capability and report that its main spec does not exist and only ADDED is allowed + for a new spec; never invent the missing requirement. REMOVED has nothing to + remove - skip it and warn. + c. **Apply changes intelligently**: **ADDED Requirements:** @@ -359,6 +366,13 @@ ${STORE_SELECTION_GUIDANCE} b. **Read the main spec** at \`/openspec/specs//spec.md\` (may not exist yet) + **If it does not exist yet** (a new capability), match what \`openspec archive\` does: + only ADDED requirements may be applied - step d creates the spec from them. + MODIFIED and RENAMED have no requirement to act on, so stop the sync for that + capability and report that its main spec does not exist and only ADDED is allowed + for a new spec; never invent the missing requirement. REMOVED has nothing to + remove - skip it and warn. + c. **Apply changes intelligently**: **ADDED Requirements:** diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index decad7b0b5..713eca5a75 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -43,18 +43,18 @@ const EXPECTED_FUNCTION_HASHES: Record = { getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: '5501740e7ec36ab23ab8c3a0d6dd0655a5e2f35433c7b90e82904fef5e7a326a', - getSyncSpecsSkillTemplate: 'b099e2ff31859c9b10d928066e662524f9aad9ecf2be12fceacb732d718c4146', + getSyncSpecsSkillTemplate: 'd09df989123f2e1fe3115910b449dda3cd423646365cac66851858a6099cd842', getOnboardSkillTemplate: '29b1d825179cff92fbc7b790694c1baef138575ea3de56848715e27d7e367946', getOpsxExploreCommandTemplate: 'd2f70d11588f902c15c1e5ce9908cc4124c6b82fe78dc766ac5c3599c9e2a6f1', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', + getArchiveChangeSkillTemplate: 'fefb60130185c97761567f537ca7b91e53b4675c45634496f43545e0d614a825', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', - getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', + getOpsxSyncCommandTemplate: '406cc9eb7818b8ba054ada2c67ce979bc312210b3e6c0070d1d7af7eef8ce8ea', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', + getOpsxArchiveCommandTemplate: 'a794e6c3da28a771e42c811b753e87ac6eff94865163545320e82c6398a626ee', getOpsxOnboardCommandTemplate: '7e251da66e2fdf539a09326463ee3ed0d01fe665ecb1d8f36f941fed00a01891', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', @@ -71,8 +71,8 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', - 'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', - 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', + 'openspec-sync-specs': '1ce9170b46b5205adb4a5abbb47c0eb96eb47fcf974a3a7b82b1ae999c06f25a', + 'openspec-archive-change': '698e8b45ab4ced57afaf96564488e595b3da6110d503c3f6780631ed69865e38', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'd53403b4910ab64307862ccf97e70bd8f7174ee44508088fb239c880f0939331', @@ -447,6 +447,57 @@ describe('skill templates split parity', () => { } }); + it('treats a missing main spec as a sync that must run, not as already synced (#1222, #1264)', () => { + // `openspec archive` creates the main spec from the delta's ADDED requirements + // when it does not exist yet (`buildUpdatedSpec`, specs-apply.ts). The agent + // workflow only told the agent to "compare each delta spec with its + // corresponding main spec", so a capability with no main spec compared against + // nothing, read as "already synced", and the change archived with the spec + // never written. Assertions are scoped to the sync-assessment step so they + // cannot pass on unrelated text elsewhere in the body. + const archiveVariants: Array<[string, string]> = [ + ['archive skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'PARITY-BASELINE')], + ['archive opsx command', getOpsxArchiveCommandTemplate().content], + ]; + + for (const [variant, content] of archiveVariants) { + const start = content.indexOf('**Assess delta spec sync state**'); + const end = content.indexOf('**Perform the archive**'); + expect(start, variant).toBeGreaterThan(-1); + expect(end, variant).toBeGreaterThan(start); + const assessStep = content.slice(start, end); + + expect(assessStep, variant).toContain( + 'A main spec that does not exist yet is **not** "already synced"' + ); + expect(assessStep, variant).toContain('is an *output* of the sync, not an input'); + expect(assessStep, variant).toContain("created from the delta's ADDED requirements"); + expect(assessStep, variant).toContain('Count that capability as changes needed'); + expect(assessStep, variant).toContain('Never read a missing main spec as nothing to apply'); + } + + // The sync itself must not invent a requirement that has no base to modify: + // the CLI throws "only ADDED requirements are allowed for new specs". + const syncVariants: Array<[string, string]> = [ + ['sync skill', getSyncSpecsSkillTemplate().instructions], + ['sync command', getOpsxSyncCommandTemplate().content], + ]; + + for (const [variant, content] of syncVariants) { + const start = content.indexOf('b. **Read the main spec**'); + const end = content.indexOf('c. **Apply changes intelligently**'); + expect(start, variant).toBeGreaterThan(-1); + expect(end, variant).toBeGreaterThan(start); + const readStep = content.slice(start, end); + + expect(readStep, variant).toContain('**If it does not exist yet** (a new capability)'); + expect(readStep, variant).toContain('only ADDED requirements may be applied'); + expect(readStep, variant).toContain('MODIFIED and RENAMED have no requirement to act on'); + expect(readStep, variant).toContain('never invent the missing requirement'); + expect(readStep, variant).toContain('REMOVED has nothing to'); + } + }); + it('gates bulk archive on inline synchronous spec sync and verification before moving change root', () => { const generatedSkill = generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); const commandContent = getOpsxBulkArchiveCommandTemplate().content; From f55ba15e563cdd361438d3a86400a9a6ef2748e6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 19 Aug 2026 11:26:54 -0500 Subject: [PATCH 2/7] fix(sync): never create a main spec with nothing to put in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught a gap in the previous commit: step 4b now tells the agent a REMOVED-only delta has nothing to remove, but step 4d still read as "create the main spec if the capability doesn't exist yet" unconditionally. Following both would write a spec whose `## Requirements` section is empty. Verified against the CLI on a REMOVED-only delta targeting a capability with no main spec: Specs to update: parking: create ⚠️ Warning: parking - 1 REMOVED requirement(s) ignored for new spec. Validation errors in rebuilt spec for parking (will not write changes): ✗ Spec must have at least one requirement Aborted. No files were changed. So step 4d is now gated on the delta having ADDED requirements to seed the spec with, and says what the CLI reports when it does not. Co-Authored-By: Claude Opus 5 --- openspec/specs/specs-sync-skill/spec.md | 7 ++++++ skills/openspec-sync-specs/SKILL.md | 5 +++++ src/core/templates/workflows/sync-specs.ts | 10 +++++++++ .../templates/skill-templates-parity.test.ts | 22 ++++++++++++++++--- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md index a293caf477..34872ada57 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -82,6 +82,13 @@ The agent SHALL reconcile main specs with delta specs using the delta operation - **AND** never invent the missing requirement - **AND** skip any `## REMOVED Requirements` with a warning, since there is nothing to remove +#### Scenario: Nothing to put in a new spec +- **WHEN** a delta targets a capability with no main spec +- **AND** the delta has no `## ADDED Requirements` to seed it with +- **THEN** create no main spec and leave the specs directory untouched +- **AND** report it, matching `openspec archive`, which aborts the same case with `Spec must have at least one requirement` +- **AND** never write an empty `## Requirements` section + #### Scenario: Merged main spec keeps canonical structure - **WHEN** the agent writes a main spec during sync - **THEN** every requirement lives under a single `## Requirements` section diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 7a532f6c89..9a254f463c 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -149,6 +149,11 @@ This is an **agent-driven** operation - you will read delta specs and directly e (this is what `openspec archive` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: + - Only when the delta has ADDED requirements to put in it. With nothing to add - + a REMOVED-only delta against a capability that has no main spec - create nothing, + leave the specs directory untouched, and report it; `openspec archive` aborts the + same case with `Spec must have at least one requirement`. Never write an empty + `## Requirements` section. - Create `/openspec/specs//spec.md` - 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 diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index ca6ceddbcf..0ac2fd497e 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -151,6 +151,11 @@ ${STORE_SELECTION_GUIDANCE} (this is what \`openspec archive\` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: + - Only when the delta has ADDED requirements to put in it. With nothing to add - + a REMOVED-only delta against a capability that has no main spec - create nothing, + leave the specs directory untouched, and report it; \`openspec archive\` aborts the + same case with \`Spec must have at least one requirement\`. Never write an empty + \`## Requirements\` section. - Create \`/openspec/specs//spec.md\` - 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 @@ -420,6 +425,11 @@ ${STORE_SELECTION_GUIDANCE} (this is what \`openspec archive\` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: + - Only when the delta has ADDED requirements to put in it. With nothing to add - + a REMOVED-only delta against a capability that has no main spec - create nothing, + leave the specs directory untouched, and report it; \`openspec archive\` aborts the + same case with \`Spec must have at least one requirement\`. Never write an empty + \`## Requirements\` section. - Create \`/openspec/specs//spec.md\` - 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 diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 713eca5a75..f7abacc226 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -43,7 +43,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: '5501740e7ec36ab23ab8c3a0d6dd0655a5e2f35433c7b90e82904fef5e7a326a', - getSyncSpecsSkillTemplate: 'd09df989123f2e1fe3115910b449dda3cd423646365cac66851858a6099cd842', + getSyncSpecsSkillTemplate: '5b6da3170398f6f8c2d6ab58e985d2771023e644e7d1bde74d2c29ea07f2b1bf', getOnboardSkillTemplate: '29b1d825179cff92fbc7b790694c1baef138575ea3de56848715e27d7e367946', getOpsxExploreCommandTemplate: 'd2f70d11588f902c15c1e5ce9908cc4124c6b82fe78dc766ac5c3599c9e2a6f1', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', @@ -52,7 +52,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', getArchiveChangeSkillTemplate: 'fefb60130185c97761567f537ca7b91e53b4675c45634496f43545e0d614a825', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', - getOpsxSyncCommandTemplate: '406cc9eb7818b8ba054ada2c67ce979bc312210b3e6c0070d1d7af7eef8ce8ea', + getOpsxSyncCommandTemplate: '382c724044c0532b5174b111cba220d20d4ff6519caad5b1849c1831530d3eb5', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', getOpsxArchiveCommandTemplate: 'a794e6c3da28a771e42c811b753e87ac6eff94865163545320e82c6398a626ee', getOpsxOnboardCommandTemplate: '7e251da66e2fdf539a09326463ee3ed0d01fe665ecb1d8f36f941fed00a01891', @@ -71,7 +71,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', - 'openspec-sync-specs': '1ce9170b46b5205adb4a5abbb47c0eb96eb47fcf974a3a7b82b1ae999c06f25a', + 'openspec-sync-specs': '5fcd207922b55a78b19fbc2872690c104ef83be09f1369de8b4b48778a234d75', 'openspec-archive-change': '698e8b45ab4ced57afaf96564488e595b3da6110d503c3f6780631ed69865e38', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', @@ -495,6 +495,22 @@ describe('skill templates split parity', () => { expect(readStep, variant).toContain('MODIFIED and RENAMED have no requirement to act on'); expect(readStep, variant).toContain('never invent the missing requirement'); expect(readStep, variant).toContain('REMOVED has nothing to'); + + // ...and the creation step must not then write the empty spec the CLI refuses: + // a REMOVED-only delta against a capability with no main spec aborts with + // "Spec must have at least one requirement" and leaves the tree untouched. + const createStart = content.indexOf("d. **Create new main spec**"); + const createEnd = content.indexOf('**Validate updated main specs**'); + expect(createStart, variant).toBeGreaterThan(-1); + expect(createEnd, variant).toBeGreaterThan(createStart); + const createStep = content.slice(createStart, createEnd); + + expect(createStep, variant).toContain( + 'Only when the delta has ADDED requirements to put in it' + ); + expect(createStep, variant).toContain('create nothing'); + expect(createStep, variant).toContain('Spec must have at least one requirement'); + expect(createStep, variant).toContain('Never write an empty'); } }); From cce935bc03e1150b72762fe55ae8ca367f4fec58 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 19 Aug 2026 12:06:48 -0500 Subject: [PATCH 3/7] docs: define "main spec" and close the no-ADDED archive loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening pass over the two fixes in this branch. Guidance: the archive step's verification pass re-runs the same comparison the fix touched, so a delta that can create nothing — no ADDED requirements, no main spec to merge into — would have been reported as "still needs sync" after a sync that correctly created nothing, and an agent could loop on it. That case now short-circuits with the reason, matching `openspec archive`, which refuses it with "Spec must have at least one requirement". Docs: the glossary defined "delta spec" but never "main spec", which is half of #1647's terminology complaint. It now defines the term and says that for a new capability the main spec is created by the archive rather than written up front; concepts.md says the same in the delta-section table and the archive process. The docs site generates from docs/ at build time, so no website files change. Changeset rewritten in the house style (prose, no commit header; the changelog-github action supplies attribution) and renamed descriptively. All three CLI branches this guidance describes were verified end to end: ADDED against a greenfield repo creates the spec and carries its Purpose; MODIFIED reports "target spec does not exist; only ADDED requirements are allowed for new specs"; REMOVED-only aborts with "Spec must have at least one requirement" and writes nothing. Co-Authored-By: Claude Opus 5 --- .../create-main-spec-for-new-capability.md | 5 +++++ .changeset/tidy-spiders-shave.md | 18 ------------------ docs/concepts.md | 6 +++--- docs/glossary.md | 4 +++- openspec/specs/opsx-archive-skill/spec.md | 1 + skills/openspec-archive-change/SKILL.md | 2 +- src/core/templates/workflows/archive-change.ts | 4 ++-- .../templates/skill-templates-parity.test.ts | 17 ++++++++++++++--- 8 files changed, 29 insertions(+), 28 deletions(-) create mode 100644 .changeset/create-main-spec-for-new-capability.md delete mode 100644 .changeset/tidy-spiders-shave.md diff --git a/.changeset/create-main-spec-for-new-capability.md b/.changeset/create-main-spec-for-new-capability.md new file mode 100644 index 0000000000..1117475a7b --- /dev/null +++ b/.changeset/create-main-spec-for-new-capability.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +The agent-driven archive and sync workflows now create a capability's main spec when it does not exist yet. The archive workflow told agents to compare each delta spec against its main spec but said nothing about a main spec that is not there; comparing against nothing read as "already synced", so the agent archived the change and the new capability's main spec was never written — in a greenfield project every change archived with `openspec/specs/` still empty. A missing main spec now counts as work the sync must do and is named in the summary as a spec that will be created. The sync workflow gained the matching rules `openspec archive` has always enforced: `MODIFIED` and `RENAMED` have no requirement to act on when there is no main spec, so the sync stops and reports instead of inventing one, and a `REMOVED`-only delta creates nothing rather than writing a spec with an empty `## Requirements` section. The docs now define "main spec" and state that for a new capability it is created by the archive rather than written up front. Fixes #1222. Fixes #1264. diff --git a/.changeset/tidy-spiders-shave.md b/.changeset/tidy-spiders-shave.md deleted file mode 100644 index 44a83420d4..0000000000 --- a/.changeset/tidy-spiders-shave.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@fission-ai/openspec": patch ---- - -fix(workflows): create the main spec when a capability is new - -The agent-driven archive workflow told agents to compare each delta spec with -its main spec, but said nothing about the case where that main spec does not -exist yet. Agents read "nothing to compare" as "already synced", archived the -change, and the capability's main spec was never written — the exact case -`openspec archive` handles by creating the spec from the delta's ADDED -requirements. The archive workflow now counts a missing main spec as changes -needed and names it as a spec the sync will create. - -The sync workflow gained the matching rule for the other half: MODIFIED and -RENAMED have no requirement to act on when the main spec does not exist, so it -stops and reports instead of inventing one, matching the CLI's -"only ADDED requirements are allowed for new specs" error. diff --git a/docs/concepts.md b/docs/concepts.md index 10106c5b78..23665ec37a 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -390,8 +390,8 @@ The system MUST expire sessions after 15 minutes of inactivity. | Section | Meaning | What Happens on Archive | |---------|---------|------------------------| -| `## ADDED Requirements` | New behavior | Appended to main spec | -| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement | +| `## ADDED Requirements` | New behavior | Appended to the main spec, which is created when the capability is new | +| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement; rejected when the capability has no main spec yet | | `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec; removing the last requirement retires the capability and deletes its spec file, when the change declares `retire_capabilities: true` | | `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists | @@ -542,7 +542,7 @@ openspec/ ### The Archive Process -1. **Merge deltas.** Each delta spec section (ADDED/MODIFIED/REMOVED) is applied to the corresponding main spec. +1. **Merge deltas.** Each delta spec section (ADDED/MODIFIED/REMOVED) is applied to the corresponding main spec. When the change introduces a capability that has no main spec yet, the archive creates it from the delta's ADDED requirements — the main spec is an output of this step, not something that must already exist. 2. **Move to archive.** The change folder moves to `changes/archive/` with a date prefix for chronological ordering. diff --git a/docs/glossary.md b/docs/glossary.md index 345125f38a..daa590d599 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -16,6 +16,8 @@ Terms are grouped by topic, then alphabetized within each group. **Delta spec.** A spec inside a change that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMOVED` sections, rather than restating the entire spec. This is what lets OpenSpec edit existing systems cleanly. See [Concepts](concepts.md#delta-specs). +**Main spec.** The spec in `openspec/specs/` that a delta spec edits — the durable, current-state counterpart to a change's delta. For a capability that already exists, the main spec is the file the delta merges into. For a brand-new capability, there is nothing to merge into yet: the main spec is *created* by the archive from the delta's `ADDED` requirements. It is an output of finishing a change, not something you write up front. + **Domain.** A logical grouping for specs, like `auth/`, `payments/`, or `ui/`. You choose domains that match how you think about your system. ## Inside a spec @@ -38,7 +40,7 @@ Terms are grouped by topic, then alphabetized within each group. **Archive.** The act of finishing a change. Its delta specs merge into the main specs, and the change folder moves to `openspec/changes/archive/YYYY-MM-DD-/`. After archiving, your specs describe the new reality. See [Concepts](concepts.md#archive). -**Sync.** Merging a change's delta specs into the main specs *without* archiving the change. Usually automatic (archive offers to do it), but available on its own as `/opsx:sync` for long-running changes. See [Commands](commands.md#opsxsync). +**Sync.** Merging a change's delta specs into the main specs *without* archiving the change — creating a main spec from scratch when the change introduces a capability that has none yet. Usually automatic (archive offers to do it), but available on its own as `/opsx:sync` for long-running changes. See [Commands](commands.md#opsxsync). ## Workflow and commands diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 90259b0217..d56ce5905f 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -89,6 +89,7 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **THEN** count that capability as needing sync rather than as already synced - **AND** name it in the summary as a main spec the sync will create - **AND** never treat the missing main spec as nothing to apply +- **AND** when that delta has no `## ADDED Requirements` to seed the new spec with, report that nothing can be created and stop instead of prompting to sync, since the verification pass would re-read the same missing spec #### Scenario: No delta specs diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index 42ca9146a9..ef6bea90cd 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -94,7 +94,7 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as `openspec archive` does. Count that capability as changes needed and name it in the summary (`: new main spec will be created`). Never read a missing main spec as nothing to apply. + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as `openspec archive` does. Count that capability as changes needed and name it in the summary (`: new main spec will be created`). Never read a missing main spec as nothing to apply. The one case a sync cannot fix is a delta with no ADDED requirements to seed the new spec with: nothing can be created, so report that and stop rather than prompting to sync and re-checking the same missing spec - `openspec archive` refuses it too, with `Spec must have at least one requirement`. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index cd238edcb8..2fa7b5ca01 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -96,7 +96,7 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. The one case a sync cannot fix is a delta with no ADDED requirements to seed the new spec with: nothing can be created, so report that and stop rather than prompting to sync and re-checking the same missing spec - \`openspec archive\` refuses it too, with \`Spec must have at least one requirement\`. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -279,7 +279,7 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. + - A main spec that does not exist yet is **not** "already synced". For a new capability the main spec is an *output* of the sync, not an input: it gets created from the delta's ADDED requirements, exactly as \`openspec archive\` does. Count that capability as changes needed and name it in the summary (\`: new main spec will be created\`). Never read a missing main spec as nothing to apply. The one case a sync cannot fix is a delta with no ADDED requirements to seed the new spec with: nothing can be created, so report that and stop rather than prompting to sync and re-checking the same missing spec - \`openspec archive\` refuses it too, with \`Spec must have at least one requirement\`. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index f7abacc226..055d28c808 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -50,11 +50,11 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: 'fefb60130185c97761567f537ca7b91e53b4675c45634496f43545e0d614a825', + getArchiveChangeSkillTemplate: '67ec0a3bbecaf583abe1a0827f62484e2a0d0e5b271107f8f673d921f29b17ff', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', getOpsxSyncCommandTemplate: '382c724044c0532b5174b111cba220d20d4ff6519caad5b1849c1831530d3eb5', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: 'a794e6c3da28a771e42c811b753e87ac6eff94865163545320e82c6398a626ee', + getOpsxArchiveCommandTemplate: '5502c72166d36b506ce88de27b1ab4d8706ac44f5e0f3d222f028f08f6a0b9f6', getOpsxOnboardCommandTemplate: '7e251da66e2fdf539a09326463ee3ed0d01fe665ecb1d8f36f941fed00a01891', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', @@ -72,7 +72,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', 'openspec-sync-specs': '5fcd207922b55a78b19fbc2872690c104ef83be09f1369de8b4b48778a234d75', - 'openspec-archive-change': '698e8b45ab4ced57afaf96564488e595b3da6110d503c3f6780631ed69865e38', + 'openspec-archive-change': '2e8e6fbdcfd1e7c7305b89ebdaea5ffd0d3feb0480acd36a6df617f32a9c85f1', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'd53403b4910ab64307862ccf97e70bd8f7174ee44508088fb239c880f0939331', @@ -474,6 +474,17 @@ describe('skill templates split parity', () => { expect(assessStep, variant).toContain("created from the delta's ADDED requirements"); expect(assessStep, variant).toContain('Count that capability as changes needed'); expect(assessStep, variant).toContain('Never read a missing main spec as nothing to apply'); + + // The verification pass re-runs this same comparison, so a delta that can + // create nothing must short-circuit here instead of prompting to sync and + // re-checking the still-missing spec forever. + expect(assessStep, variant).toContain( + 'The one case a sync cannot fix is a delta with no ADDED requirements' + ); + expect(assessStep, variant).toContain( + 'report that and stop rather than prompting to sync' + ); + expect(assessStep, variant).toContain('Spec must have at least one requirement'); } // The sync itself must not invent a requirement that has no base to modify: From e3577c375b5907e2bd364b6af29ba652462ef6b6 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 19 Aug 2026 12:15:47 -0500 Subject: [PATCH 4/7] docs(glossary): a standalone sync creates the main spec too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Main spec" entry said the spec is created "by the archive", but the "Sync" entry two sections down says /opsx:sync creates it as well, without archiving — and specs-sync-skill's "New capability spec" scenario is the sync's own behavior. Names both paths so the two entries agree. Co-Authored-By: Claude Opus 5 --- docs/glossary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/glossary.md b/docs/glossary.md index daa590d599..3c9c26ed4b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -16,7 +16,7 @@ Terms are grouped by topic, then alphabetized within each group. **Delta spec.** A spec inside a change that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMOVED` sections, rather than restating the entire spec. This is what lets OpenSpec edit existing systems cleanly. See [Concepts](concepts.md#delta-specs). -**Main spec.** The spec in `openspec/specs/` that a delta spec edits — the durable, current-state counterpart to a change's delta. For a capability that already exists, the main spec is the file the delta merges into. For a brand-new capability, there is nothing to merge into yet: the main spec is *created* by the archive from the delta's `ADDED` requirements. It is an output of finishing a change, not something you write up front. +**Main spec.** The spec in `openspec/specs/` that a delta spec edits — the durable, current-state counterpart to a change's delta. For a capability that already exists, the main spec is the file the delta merges into. For a brand-new capability, there is nothing to merge into yet: the main spec is *created* from the delta's `ADDED` requirements — by the archive, or by a standalone sync before you archive. It is an output of the work, not something you write up front. **Domain.** A logical grouping for specs, like `auth/`, `payments/`, or `ui/`. You choose domains that match how you think about your system. From da4d4c837c757fb18691b1f31481a53f5b86f3a9 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 24 Aug 2026 09:27:27 -0500 Subject: [PATCH 5/7] fix(workflows): clarify removed deltas for missing specs --- openspec/specs/opsx-archive-skill/spec.md | 5 ++++- skills/openspec-archive-change/SKILL.md | 4 ++-- src/core/templates/workflows/archive-change.ts | 8 ++++---- .../core/templates/skill-templates-parity.test.ts | 15 +++++++++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 0acf86ab42..17149960a3 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -91,6 +91,8 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **THEN** count that capability as needing sync rather than as already synced - **AND** name it in the summary as a main spec the sync will create - **AND** never treat the missing main spec as nothing to apply +- **AND** if the delta also has `## REMOVED Requirements`, warn that they will be ignored because there is no main spec to remove them from +- **AND** create the main spec from only the delta's `## ADDED Requirements` #### Scenario: Unsupported delta operation whose main spec does not exist yet @@ -103,7 +105,8 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **WHEN** a delta targets a capability whose main spec does not exist yet - **AND** the delta has no `## ADDED Requirements` -- **THEN** report that nothing can be created +- **THEN** report that no sync is possible +- **AND** if the delta has only `## REMOVED Requirements`, warn that there is no main spec to remove them from and leave the main-spec tree unchanged - **AND** stop instead of prompting to sync, since the verification pass would re-read the same missing spec #### Scenario: No delta specs diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index b550c6e960..91072e6de3 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -96,8 +96,8 @@ Archive a completed change in the experimental workflow. - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that nothing can be created and stop instead of prompting to sync. `openspec archive` refuses the same case with `Spec must have at least one requirement`. - - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). The sync creates it from the delta's ADDED requirements, exactly as `openspec archive` does. + - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the same case with `Spec must have at least one requirement`. + - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 4eb49a953d..b4b2e79951 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -98,8 +98,8 @@ ${STORE_SELECTION_GUIDANCE} - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that nothing can be created and stop instead of prompting to sync. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. - - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). The sync creates it from the delta's ADDED requirements, exactly as \`openspec archive\` does. + - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. + - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -284,8 +284,8 @@ ${STORE_SELECTION_GUIDANCE} - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that nothing can be created and stop instead of prompting to sync. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. - - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). The sync creates it from the delta's ADDED requirements, exactly as \`openspec archive\` does. + - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. + - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index d524329cdd..0d8fd8a21c 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -50,11 +50,11 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: 'ab9f73a319c73bf66168883ed766e97748460d41599eb8b6f54df4f404885624', + getArchiveChangeSkillTemplate: '5eac76fa2e9ae19136b8d4246bb5cfe4e8ac7925f2e253a93c86856b15ce9f16', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', getOpsxSyncCommandTemplate: '382c724044c0532b5174b111cba220d20d4ff6519caad5b1849c1831530d3eb5', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: 'fced8b751cbe0654ed56f34c167b627a6113b9353b24bc424e9154549e06a82a', + getOpsxArchiveCommandTemplate: '5e754505e7baf80f8c6959a6fa5cacfabc6b50f8bf5ec94428e82095abf5d361', getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', @@ -72,7 +72,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', 'openspec-sync-specs': '5fcd207922b55a78b19fbc2872690c104ef83be09f1369de8b4b48778a234d75', - 'openspec-archive-change': 'f562fb03ce265fed7c27cbf73d452e12db67a963314820a5f403328b1651809a', + 'openspec-archive-change': '238c6e04cca29ab8b8635bafd39d836b40f8290ccec110a2e6ed1bf8618773bf', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', @@ -509,10 +509,17 @@ describe('skill templates split parity', () => { expect(assessStep, variant).toContain('only ADDED requirements can create'); expect(assessStep, variant).toContain('Never invent a requirement'); expect(assessStep, variant).toContain('If the delta has no ADDED requirements'); + expect(assessStep, variant).toContain('report that no sync is possible'); + expect(assessStep, variant).toContain('For a REMOVED-only delta'); + expect(assessStep, variant).toContain('leave the main-spec tree unchanged'); expect(assessStep, variant).toContain('stop instead of prompting to sync'); expect(assessStep, variant).toContain('Spec must have at least one requirement'); expect(assessStep, variant).toContain('Otherwise, count the capability as needing sync'); - expect(assessStep, variant).toContain("creates it from the delta's ADDED requirements"); + expect(assessStep, variant).toContain('If the delta also has REMOVED requirements'); + expect(assessStep, variant).toContain('warn that they will be ignored'); + expect(assessStep, variant).toContain( + "creates the main spec from only the delta's ADDED requirements" + ); } // The sync itself must not invent a requirement that has no base to modify: From 3a763c02b69a44306262662f1cac112981d0a5f1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 27 Aug 2026 13:39:44 -0500 Subject: [PATCH 6/7] fix(workflows): preserve explicitly retired missing specs --- .../create-main-spec-for-new-capability.md | 2 +- openspec/specs/opsx-archive-skill/spec.md | 11 +++- openspec/specs/specs-sync-skill/spec.md | 5 +- skills/openspec-archive-change/SKILL.md | 7 +-- skills/openspec-sync-specs/SKILL.md | 13 +++-- .../templates/workflows/archive-change.ts | 14 ++--- src/core/templates/workflows/sync-specs.ts | 26 +++++---- test/core/archive.test.ts | 25 ++++++--- .../templates/skill-templates-parity.test.ts | 54 +++++++++++++++---- 9 files changed, 114 insertions(+), 43 deletions(-) diff --git a/.changeset/create-main-spec-for-new-capability.md b/.changeset/create-main-spec-for-new-capability.md index 1c86bb8b74..fea833df5e 100644 --- a/.changeset/create-main-spec-for-new-capability.md +++ b/.changeset/create-main-spec-for-new-capability.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": patch --- -Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They stop rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta. Fixes #1222 and #1264. +Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They stop rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta. A REMOVED-only delta with `retire_capabilities: true` remains already synced when its main spec is gone. Fixes #1222 and #1264. diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 17149960a3..686a63013c 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -101,10 +101,19 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **THEN** report that only ADDED requirements can create a new main spec - **AND** stop instead of prompting to sync -#### Scenario: Nothing to put in a missing main spec +#### Scenario: Explicitly retired capability whose main spec is missing + +- **WHEN** a delta contains only `## REMOVED Requirements` and its main spec is missing +- **AND** the change's `.openspec.yaml` declares `retire_capabilities: true` +- **THEN** count that capability as already synced and report that it is already retired +- **AND** warn that there is nothing left to remove and do not recreate the main spec +- **AND** apply the same rule when verifying a completed sync, so retiring a capability does not block archiving + +#### Scenario: Nothing to put in a missing main spec without a declared retirement - **WHEN** a delta targets a capability whose main spec does not exist yet - **AND** the delta has no `## ADDED Requirements` +- **AND** it is not a REMOVED-only delta with `retire_capabilities: true` - **THEN** report that no sync is possible - **AND** if the delta has only `## REMOVED Requirements`, warn that there is no main spec to remove them from and leave the main-spec tree unchanged - **AND** stop instead of prompting to sync, since the verification pass would re-read the same missing spec diff --git a/openspec/specs/specs-sync-skill/spec.md b/openspec/specs/specs-sync-skill/spec.md index 34872ada57..abb99ce16d 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -71,6 +71,7 @@ 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 +- **AND** it has ADDED requirements and no MODIFIED or RENAMED requirements - **THEN** create new main spec file at `openspec/specs//spec.md`, preserving the delta's path relative to `specs/` - **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 @@ -86,7 +87,9 @@ The agent SHALL reconcile main specs with delta specs using the delta operation - **WHEN** a delta targets a capability with no main spec - **AND** the delta has no `## ADDED Requirements` to seed it with - **THEN** create no main spec and leave the specs directory untouched -- **AND** report it, matching `openspec archive`, which aborts the same case with `Spec must have at least one requirement` +- **AND** for a REMOVED-only delta with `retire_capabilities: true` in the change's `.openspec.yaml`, report the capability as already retired and continue without recreating it +- **AND** without that marker, report a REMOVED-only sync as blocked, matching `openspec archive`, which aborts with `Spec must have at least one requirement` +- **AND** report an empty delta as blocked because it has no operations to sync - **AND** never write an empty `## Requirements` section #### Scenario: Merged main spec keeps canonical structure diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index 91072e6de3..182ccb04c5 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -94,9 +94,10 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: + - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the same case with `Spec must have at least one requirement`. + - If the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`. - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -122,7 +123,7 @@ Archive a completed change in the experimental workflow. Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. - Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match diff --git a/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index 9a254f463c..1babf3b613 100644 --- a/skills/openspec-sync-specs/SKILL.md +++ b/skills/openspec-sync-specs/SKILL.md @@ -149,11 +149,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e (this is what `openspec archive` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: - - Only when the delta has ADDED requirements to put in it. With nothing to add - - a REMOVED-only delta against a capability that has no main spec - create nothing, - leave the specs directory untouched, and report it; `openspec archive` aborts the - same case with `Spec must have at least one requirement`. Never write an empty - `## Requirements` section. + - Only when the delta has ADDED requirements to put in it and no MODIFIED or + RENAMED requirements blocked this capability in step b. Otherwise create nothing + and leave the specs directory untouched. For a REMOVED-only delta, if the change's + `.openspec.yaml` declares `retire_capabilities: true`, report it as already retired + and continue without recreating the spec. Without that marker, report the sync as blocked: + `openspec archive` rejects it with `Spec must have at least one requirement`. + An empty delta has no operations to sync; report it as blocked too. + Never write an empty `## Requirements` section. - Create `/openspec/specs//spec.md` - 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 diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index b4b2e79951..a825e95bef 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -96,9 +96,10 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: + - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. + - If the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -124,7 +125,7 @@ ${STORE_SELECTION_GUIDANCE} Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. - Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match @@ -282,9 +283,10 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - - A main spec that does not exist yet is **not** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: + - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the same case with \`Spec must have at least one requirement\`. + - If the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - Show a combined summary before prompting @@ -310,7 +312,7 @@ ${STORE_SELECTION_GUIDANCE} Then run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. - Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match diff --git a/src/core/templates/workflows/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index 0ac2fd497e..3f9e7aa362 100644 --- a/src/core/templates/workflows/sync-specs.ts +++ b/src/core/templates/workflows/sync-specs.ts @@ -151,11 +151,14 @@ ${STORE_SELECTION_GUIDANCE} (this is what \`openspec archive\` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: - - Only when the delta has ADDED requirements to put in it. With nothing to add - - a REMOVED-only delta against a capability that has no main spec - create nothing, - leave the specs directory untouched, and report it; \`openspec archive\` aborts the - same case with \`Spec must have at least one requirement\`. Never write an empty - \`## Requirements\` section. + - Only when the delta has ADDED requirements to put in it and no MODIFIED or + RENAMED requirements blocked this capability in step b. Otherwise create nothing + and leave the specs directory untouched. For a REMOVED-only delta, if the change's + \`.openspec.yaml\` declares \`retire_capabilities: true\`, report it as already retired + and continue without recreating the spec. Without that marker, report the sync as blocked: + \`openspec archive\` rejects it with \`Spec must have at least one requirement\`. + An empty delta has no operations to sync; report it as blocked too. + Never write an empty \`## Requirements\` section. - Create \`/openspec/specs//spec.md\` - 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 @@ -425,11 +428,14 @@ ${STORE_SELECTION_GUIDANCE} (this is what \`openspec archive\` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: - - Only when the delta has ADDED requirements to put in it. With nothing to add - - a REMOVED-only delta against a capability that has no main spec - create nothing, - leave the specs directory untouched, and report it; \`openspec archive\` aborts the - same case with \`Spec must have at least one requirement\`. Never write an empty - \`## Requirements\` section. + - Only when the delta has ADDED requirements to put in it and no MODIFIED or + RENAMED requirements blocked this capability in step b. Otherwise create nothing + and leave the specs directory untouched. For a REMOVED-only delta, if the change's + \`.openspec.yaml\` declares \`retire_capabilities: true\`, report it as already retired + and continue without recreating the spec. Without that marker, report the sync as blocked: + \`openspec archive\` rejects it with \`Spec must have at least one requirement\`. + An empty delta has no operations to sync; report it as blocked too. + Never write an empty \`## Requirements\` section. - Create \`/openspec/specs//spec.md\` - 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 diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index bbdfcf1bed..d90deb213f 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -4766,22 +4766,35 @@ The system SHALL do the thing differently. ); }); - it('archives a REMOVED-only delta whose main spec was already deleted', async () => { + it.each([true, false])('handles an already-deleted main spec with retirement declared: %s', async (declareRetirement) => { // The issue's second dead end: pre-deleting the spec made the delta look // like a create, which landed on an empty spec and failed the same way. const changeName = 'retire-already-gone'; - await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const changeDir = await createChange(changeName, 'legacy-layer', REMOVE_ALL, { declareRetirement }); await archiveCommand.execute(changeName, { yes: true }); - expect(process.exitCode).not.toBe(1); // Nothing was recreated. await expect( fs.access(path.join(tempDir, 'openspec', 'specs', 'legacy-layer')) ).rejects.toThrow(); - await expect( - fs.access(path.join(tempDir, 'openspec', 'changes', changeName)) - ).rejects.toThrow(); + if (declareRetirement) { + expect(process.exitCode).not.toBe(1); + await expect(fs.access(changeDir)).rejects.toThrow(); + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const [archivedName] = await fs.readdir(archiveDir); + await expect( + fs.readFile(path.join(archiveDir, archivedName, 'specs', 'legacy-layer', 'spec.md'), 'utf-8') + ).resolves.toBe(REMOVE_ALL); + } else { + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS) + ); + await expect(fs.readFile(path.join(changeDir, 'specs', 'legacy-layer', 'spec.md'), 'utf-8')) + .resolves.toBe(REMOVE_ALL); + expect(await fs.readdir(path.join(tempDir, 'openspec', 'changes', 'archive'))).toEqual([]); + } }); // The requirement-block count and the validator do NOT agree on what a diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 26144e8eb0..a053da04bc 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -43,18 +43,18 @@ const EXPECTED_FUNCTION_HASHES: Record = { getContinueChangeSkillTemplate: '012136f6411a99c8fa228e2f9444cb64b0a89e0f56fdeac2fe03b2f5bee0c5d7', getApplyChangeSkillTemplate: 'd1e7d5ceb85193c0964057dbb88e9651526754bd33f84020e2440ff0621d5dbb', getFfChangeSkillTemplate: '5501740e7ec36ab23ab8c3a0d6dd0655a5e2f35433c7b90e82904fef5e7a326a', - getSyncSpecsSkillTemplate: '5b6da3170398f6f8c2d6ab58e985d2771023e644e7d1bde74d2c29ea07f2b1bf', + getSyncSpecsSkillTemplate: '271825029d9fa83c19ceb07e6f1ca3c10a17cf0822899d7709180a7487b9ae29', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', getOpsxExploreCommandTemplate: '1460fcb4fbdf22244e9e76608102e611db598cd4cca8c5dbd001292854bcba6e', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: '5eac76fa2e9ae19136b8d4246bb5cfe4e8ac7925f2e253a93c86856b15ce9f16', + getArchiveChangeSkillTemplate: '6a41b0512281a2eb0166b3df2b471f1471347d9331124afd2c56428e77bf0162', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', - getOpsxSyncCommandTemplate: '382c724044c0532b5174b111cba220d20d4ff6519caad5b1849c1831530d3eb5', + getOpsxSyncCommandTemplate: 'b4b2bbcfa7e3709bc7cfdfefed7d4c5921878d03ba483e6c97bd33eb94cb583c', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: '5e754505e7baf80f8c6959a6fa5cacfabc6b50f8bf5ec94428e82095abf5d361', + getOpsxArchiveCommandTemplate: 'f4ca46ebde3e6185429537f98201ed01b28bc78cc85f26226a73dbdcac2ec02a', getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', @@ -71,8 +71,8 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-continue-change': 'bb6194a16c54891cdb253678e8f70ce53b2af86735243980f366ce551d37e42e', 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', - 'openspec-sync-specs': '5fcd207922b55a78b19fbc2872690c104ef83be09f1369de8b4b48778a234d75', - 'openspec-archive-change': '238c6e04cca29ab8b8635bafd39d836b40f8290ccec110a2e6ed1bf8618773bf', + 'openspec-sync-specs': '0690c2290e74b3f7ce8f19d3204fb2d5630eb06290d1c6a79370026099758c98', + 'openspec-archive-change': '36f3a5a06d5073583e97cc24cf0f4a2ce627243bdb6fc44420ec3ae21bf96b88', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', @@ -481,7 +481,7 @@ describe('skill templates split parity', () => { } }); - it('treats a missing main spec as a sync that must run, not as already synced (#1222, #1264)', () => { + it('requires sync to create a missing main spec from ADDED requirements (#1222, #1264)', () => { // `openspec archive` creates the main spec from the delta's ADDED requirements // when it does not exist yet (`buildUpdatedSpec`, specs-apply.ts). The agent // workflow only told the agent to "compare each delta spec with its @@ -502,13 +502,13 @@ describe('skill templates split parity', () => { const assessStep = content.slice(start, end); expect(assessStep, variant).toContain( - 'A main spec that does not exist yet is **not** "already synced"' + 'A missing main spec is **not automatically** "already synced"' ); expect(assessStep, variant).toContain('is an *output* of the sync, not an input'); expect(assessStep, variant).toContain('If the delta has MODIFIED or RENAMED'); expect(assessStep, variant).toContain('only ADDED requirements can create'); expect(assessStep, variant).toContain('Never invent a requirement'); - expect(assessStep, variant).toContain('If the delta has no ADDED requirements'); + expect(assessStep, variant).toContain('Otherwise, if the delta has no ADDED requirements'); expect(assessStep, variant).toContain('report that no sync is possible'); expect(assessStep, variant).toContain('For a REMOVED-only delta'); expect(assessStep, variant).toContain('leave the main-spec tree unchanged'); @@ -543,7 +543,7 @@ describe('skill templates split parity', () => { expect(readStep, variant).toContain('REMOVED has nothing to'); // ...and the creation step must not then write the empty spec the CLI refuses: - // a REMOVED-only delta against a capability with no main spec aborts with + // an unmarked REMOVED-only delta against a capability with no main spec aborts with // "Spec must have at least one requirement" and leaves the tree untouched. const createStart = content.indexOf("d. **Create new main spec**"); const createEnd = content.indexOf('**Validate updated main specs**'); @@ -554,12 +554,46 @@ describe('skill templates split parity', () => { expect(createStep, variant).toContain( 'Only when the delta has ADDED requirements to put in it' ); + expect(createStep, variant).toContain('RENAMED requirements blocked this capability in step b'); expect(createStep, variant).toContain('create nothing'); expect(createStep, variant).toContain('Spec must have at least one requirement'); expect(createStep, variant).toContain('Never write an empty'); } }); + it('recognizes explicitly retired missing specs without blocking archive verification', () => { + for (const content of [ + getArchiveChangeSkillTemplate().instructions, + getOpsxArchiveCommandTemplate().content, + ]) { + const assessment = content.slice( + content.indexOf('**If delta specs exist:**'), + content.indexOf('**Prompt options:**') + ); + const retirement = assessment.indexOf('If the delta has only REMOVED requirements'); + expect(retirement).toBeGreaterThan(-1); + expect(retirement).toBeLessThan(assessment.indexOf('Otherwise, if the delta has no ADDED requirements')); + expect(assessment).toContain('`retire_capabilities: true`'); + expect(assessment).toContain('count it as already synced'); + expect(assessment).toContain('do not recreate the main spec'); + expect(content).toContain('including the explicitly retired, missing-spec case'); + } + + for (const content of [ + getSyncSpecsSkillTemplate().instructions, + getOpsxSyncCommandTemplate().content, + ]) { + const createStep = content.slice( + content.indexOf('d. **Create new main spec**'), + content.indexOf('**Validate updated main specs**') + ); + expect(createStep).toContain('`retire_capabilities: true`'); + expect(createStep).toContain('report it as already retired'); + expect(createStep).toContain('Without that marker, report the sync as blocked'); + expect(createStep).toContain('create nothing'); + } + }); + it('gates bulk archive on inline synchronous spec sync and verification before moving change root', () => { const generatedSkill = generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'PARITY-BASELINE'); const commandContent = getOpsxBulkArchiveCommandTemplate().content; From ce476f6f88a6a96e7b34547351c1922ca3e4d35c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 28 Aug 2026 14:06:38 -0500 Subject: [PATCH 7/7] fix(archive): preserve explicit skip-sync choice for blocked deltas --- .../create-main-spec-for-new-capability.md | 2 +- openspec/specs/opsx-archive-skill/spec.md | 14 ++++++-- skills/openspec-archive-change/SKILL.md | 15 +++++---- .../templates/workflows/archive-change.ts | 30 +++++++++-------- test/core/archive.test.ts | 23 +++++++++++-- .../templates/skill-templates-parity.test.ts | 33 ++++++++++++++++--- 6 files changed, 85 insertions(+), 32 deletions(-) diff --git a/.changeset/create-main-spec-for-new-capability.md b/.changeset/create-main-spec-for-new-capability.md index fea833df5e..d578818462 100644 --- a/.changeset/create-main-spec-for-new-capability.md +++ b/.changeset/create-main-spec-for-new-capability.md @@ -2,4 +2,4 @@ "@fission-ai/openspec": patch --- -Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They stop rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta. A REMOVED-only delta with `retire_capabilities: true` remains already synced when its main spec is gone. Fixes #1222 and #1264. +Agent-driven archive and sync workflows now create a missing main spec from `ADDED` requirements instead of treating it as already synced. They block sync rather than inventing `MODIFIED` or `RENAMED` requirements or writing an empty spec for a `REMOVED`-only delta, while preserving the user's explicit choice to archive without syncing. A REMOVED-only delta with `retire_capabilities: true` remains already synced when its main spec is gone. Fixes #1222 and #1264. diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 686a63013c..26c1c66f60 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -99,7 +99,7 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **WHEN** a delta targets a capability whose main spec does not exist yet - **AND** the delta has `## MODIFIED Requirements` or `## RENAMED Requirements` - **THEN** report that only ADDED requirements can create a new main spec -- **AND** stop instead of prompting to sync +- **AND** mark the capability as sync-blocked without writing a main spec #### Scenario: Explicitly retired capability whose main spec is missing @@ -116,7 +116,17 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist. - **AND** it is not a REMOVED-only delta with `retire_capabilities: true` - **THEN** report that no sync is possible - **AND** if the delta has only `## REMOVED Requirements`, warn that there is no main spec to remove them from and leave the main-spec tree unchanged -- **AND** stop instead of prompting to sync, since the verification pass would re-read the same missing spec +- **AND** mark the capability as sync-blocked, since the verification pass would re-read the same missing spec + +#### Scenario: Sync-blocked capability during archive assessment + +- **WHEN** any capability is sync-blocked during the initial assessment +- **THEN** assess the remaining capabilities and summarize the blockers before prompting +- **AND** offer only "Archive without syncing" and "Cancel" +- **AND** archive without writing main specs only if the user explicitly chooses "Archive without syncing" +- **AND** stop without archiving if the user cancels +- **AND** do not start any sync while a capability is blocked, even if other capabilities could sync +- **AND** a failed sync or post-sync verification still stops without archiving; do not silently fall back to skipping sync #### Scenario: No delta specs diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index 182ccb04c5..62ca3fbfac 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -95,21 +95,22 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. - - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`. + - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version. + - Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`. - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does. - Determine what changes would be applied (adds, modifications, removals, renames) - - Show a combined summary before prompting + - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting. **Prompt options:** - - If changes needed: "Sync now (recommended)", "Archive without syncing" - - If already synced: "Archive now", "Sync anyway", "Cancel" + - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel" + - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing" + - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel" Route on the answer: - "Cancel" — stop, do not archive - "Archive without syncing" or "Archive now" — proceed to archive - - "Sync now" or "Sync anyway" — sync, then verify (below) + - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices. - Anything else — ask again rather than archiving Before a selected sync writes any main spec, run diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index a825e95bef..7c7511fc27 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -97,21 +97,22 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. - - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. + - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version. + - Otherwise, if the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - - Show a combined summary before prompting + - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting. **Prompt options:** - - If changes needed: "Sync now (recommended)", "Archive without syncing" - - If already synced: "Archive now", "Sync anyway", "Cancel" + - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel" + - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing" + - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel" Route on the answer: - "Cancel" — stop, do not archive - "Archive without syncing" or "Archive now" — proceed to archive - - "Sync now" or "Sync anyway" — sync, then verify (below) + - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices. - Anything else — ask again rather than archiving Before a selected sync writes any main spec, run @@ -284,21 +285,22 @@ ${STORE_SELECTION_GUIDANCE} **If delta specs exist:** - Compare each delta spec with its corresponding main spec at \`/openspec/specs//spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path) - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: - - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and stop instead of prompting to sync. Never invent a requirement that has no current version. - - If the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. - - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and stop instead of prompting to sync. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. + - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version. + - Otherwise, if the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`. - Otherwise, count the capability as needing sync and name it in the summary (\`: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does. - Determine what changes would be applied (adds, modifications, removals, renames) - - Show a combined summary before prompting + - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting. **Prompt options:** - - If changes needed: "Sync now (recommended)", "Archive without syncing" - - If already synced: "Archive now", "Sync anyway", "Cancel" + - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel" + - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing" + - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel" Route on the answer: - "Cancel" — stop, do not archive - "Archive without syncing" or "Archive now" — proceed to archive - - "Sync now" or "Sync anyway" — sync, then verify (below) + - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices. - Anything else — ask again rather than archiving Before a selected sync writes any main spec, run diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index d90deb213f..333ebea851 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -2383,7 +2383,7 @@ The system will log all events. } }); - it('should proceed with archive when user declines spec updates', async () => { + it.each(['legacy', 'MODIFIED', 'RENAMED', 'REMOVED'])('archives when the user declines %s sync', async (operation) => { const { confirmPrompt: confirm } = await import('../../src/utils/interactive.js'); const mockConfirm = confirm as unknown as ReturnType; @@ -2392,8 +2392,21 @@ The system will log all events. const changeSpecDir = path.join(changeDir, 'specs', 'test-capability'); await fs.mkdir(changeSpecDir, { recursive: true }); - // Create valid spec in change - const specContent = `# Test Capability Spec + // These deltas cannot build a new main spec. Declining sync must still + // archive them without creating one, including the legacy no-operations case. + const specContent = operation === 'RENAMED' + ? '## RENAMED Requirements\n- FROM: `### Requirement: Old name`\n- TO: `### Requirement: New name`\n' + : operation !== 'legacy' + ? `## ${operation} Requirements + +### Requirement: Test capability +The system SHALL provide test capability. + +#### Scenario: Basic test +- **WHEN** an action occurs +- **THEN** the expected result happens +` + : `# Test Capability Spec ## Purpose This is a test capability specification. @@ -2434,6 +2447,10 @@ Then expected result happens`; const archives = await fs.readdir(archiveDir); expect(archives.length).toBe(1); expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`)); + expect(process.exitCode).not.toBe(1); + await expect( + fs.readFile(path.join(archiveDir, archives[0], 'specs', 'test-capability', 'spec.md'), 'utf-8') + ).resolves.toBe(specContent); }); it('warns about absorbed content before asking to apply the destructive spec update', async () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index a053da04bc..35a9772783 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -50,11 +50,11 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: '6a41b0512281a2eb0166b3df2b471f1471347d9331124afd2c56428e77bf0162', + getArchiveChangeSkillTemplate: '80576b4b51a5cdd5ab2e7fb4e8616619e8a100b2aff17eef5a7c004865e07f6f', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', getOpsxSyncCommandTemplate: 'b4b2bbcfa7e3709bc7cfdfefed7d4c5921878d03ba483e6c97bd33eb94cb583c', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: 'f4ca46ebde3e6185429537f98201ed01b28bc78cc85f26226a73dbdcac2ec02a', + getOpsxArchiveCommandTemplate: '320ab6015bc310b9d1974fd6dcb06cfae1b93bf7716b8a86a07beaab956db723', getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f', getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b', getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', @@ -72,7 +72,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49', 'openspec-ff-change': '217c78da2b6e8358f609ac57dcd02266aaec3354ce26dc6ec2fc9c2174673ab4', 'openspec-sync-specs': '0690c2290e74b3f7ce8f19d3204fb2d5630eb06290d1c6a79370026099758c98', - 'openspec-archive-change': '36f3a5a06d5073583e97cc24cf0f4a2ce627243bdb6fc44420ec3ae21bf96b88', + 'openspec-archive-change': '7a0a33ded7b47f941b12ff9e1847476b06548b580be2336c53599fa90f35b222', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', @@ -512,7 +512,7 @@ describe('skill templates split parity', () => { expect(assessStep, variant).toContain('report that no sync is possible'); expect(assessStep, variant).toContain('For a REMOVED-only delta'); expect(assessStep, variant).toContain('leave the main-spec tree unchanged'); - expect(assessStep, variant).toContain('stop instead of prompting to sync'); + expect(assessStep, variant).toContain('mark that capability as sync-blocked'); expect(assessStep, variant).toContain('Spec must have at least one requirement'); expect(assessStep, variant).toContain('Otherwise, count the capability as needing sync'); expect(assessStep, variant).toContain('If the delta also has REMOVED requirements'); @@ -561,6 +561,29 @@ describe('skill templates split parity', () => { } }); + it('preserves explicit archive-without-sync when a missing target blocks sync', () => { + for (const content of [ + getArchiveChangeSkillTemplate().instructions, + getOpsxArchiveCommandTemplate().content, + ]) { + const assessment = content.slice( + content.indexOf('**If delta specs exist:**'), + content.indexOf('Before a selected sync writes any main spec') + ); + expect(assessment).not.toContain('stop instead of prompting to sync'); + expect(assessment).toContain('mark that capability as sync-blocked'); + expect(assessment).toContain('Continue assessing the remaining capabilities'); + expect(assessment).toContain( + 'If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"' + ); + expect(assessment).toContain('Do not start any sync while a capability is sync-blocked'); + expect(assessment).toContain('"Archive without syncing" or "Archive now" — proceed to archive'); + expect(assessment).toContain('"Cancel" — stop, do not archive'); + expect(content).toContain('If the sync failed, or any capability does not match'); + expect(content).toContain('stop — do not archive'); + } + }); + it('recognizes explicitly retired missing specs without blocking archive verification', () => { for (const content of [ getArchiveChangeSkillTemplate().instructions, @@ -570,7 +593,7 @@ describe('skill templates split parity', () => { content.indexOf('**If delta specs exist:**'), content.indexOf('**Prompt options:**') ); - const retirement = assessment.indexOf('If the delta has only REMOVED requirements'); + const retirement = assessment.indexOf('Otherwise, if the delta has only REMOVED requirements'); expect(retirement).toBeGreaterThan(-1); expect(retirement).toBeLessThan(assessment.indexOf('Otherwise, if the delta has no ADDED requirements')); expect(assessment).toContain('`retire_capabilities: true`');