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..1c86bb8b74 --- /dev/null +++ b/.changeset/create-main-spec-for-new-capability.md @@ -0,0 +1,5 @@ +--- +"@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. diff --git a/docs-lab/reference/glossary.md b/docs-lab/reference/glossary.md index 429a05fbbe..a6675a87ec 100644 --- a/docs-lab/reference/glossary.md +++ b/docs-lab/reference/glossary.md @@ -19,7 +19,7 @@ OpenSpec reuses words that mean something else in git, CI, and agent tooling. Ea | **Fast-forward** | Create a change proposal with every planning artifact in one pass, ready to implement. Skill: `openspec-ff-change`. Not a git fast-forward. | [Skills](skills.md) | | **Legacy workflow** | The pre-OPSX `/openspec:*` commands. | [Migration](../help/legacy/migration.md) | | **Loop** | The cycle a change proposal moves through: explore, propose, review, apply, archive. | [Quickstart](../start/quickstart.md) | -| **Main specs** | The `openspec/specs/` tree: the current, agreed behavior of your system. Archiving merges deltas into it. | [Concepts](../guides/concepts.md) | +| **Main specs** | The `openspec/specs/` tree: the current behavior of your system. Sync or archive creates a new capability's spec from its `ADDED` requirements. | [Concepts](../guides/concepts.md) | | **OpenSpec root** | The `openspec/` tree a command resolves to and operates on: your repo's, or a store's. | [Stores](configuration/stores.md) | | **OPSX** | The current OpenSpec workflow system, and the command prefix it installs (`/opsx:`). | [Architecture](architecture/index.md) | | **Profile** | Which workflows init installs: `core` or `custom`. | [Profiles](../customize/profiles.md) | diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 2c76461e54..17149960a3 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -82,6 +82,33 @@ 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: Applicable ADDED delta 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 +- **AND** the delta has `## ADDED Requirements` +- **AND** the delta has no `## MODIFIED Requirements` or `## RENAMED Requirements` +- **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 + +- **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 + +#### Scenario: Nothing to put in a missing main spec + +- **WHEN** a delta targets a capability whose main spec does not exist yet +- **AND** the delta has no `## ADDED Requirements` +- **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 - **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..34872ada57 100644 --- a/openspec/specs/specs-sync-skill/spec.md +++ b/openspec/specs/specs-sync-skill/spec.md @@ -75,6 +75,20 @@ 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: 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-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index 5f34ed53a7..91072e6de3 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -94,6 +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: + - 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`. + - 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/skills/openspec-sync-specs/SKILL.md b/skills/openspec-sync-specs/SKILL.md index d12d56b857..9a254f463c 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:** @@ -142,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/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 2dae74d436..b4b2e79951 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -96,6 +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: + - 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\`. + - 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 @@ -278,6 +282,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: + - 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\`. + - 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/sync-specs.ts b/src/core/templates/workflows/sync-specs.ts index bedbaa7164..0ac2fd497e 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:** @@ -144,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 @@ -359,6 +371,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:** @@ -406,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 3860a62eda..0d8fd8a21c 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: '5b6da3170398f6f8c2d6ab58e985d2771023e644e7d1bde74d2c29ea07f2b1bf', getOnboardSkillTemplate: '3a836faae463d88c289a1c129cb7ee556a563b7e53e1a52a4711ff152a3b51f7', getOpsxExploreCommandTemplate: 'd2f70d11588f902c15c1e5ce9908cc4124c6b82fe78dc766ac5c3599c9e2a6f1', getOpsxNewCommandTemplate: 'f2d30e569798a4c92ba932859d6ba4e0ad10e18feccbade1cfee0957597b3463', getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559', getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0', getOpsxFfCommandTemplate: 'e603bc0996604e6c17a3140943ea642a32d0fc65565e25424bf956e124c55772', - getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23', + getArchiveChangeSkillTemplate: '5eac76fa2e9ae19136b8d4246bb5cfe4e8ac7925f2e253a93c86856b15ce9f16', getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d', - getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202', + getOpsxSyncCommandTemplate: '382c724044c0532b5174b111cba220d20d4ff6519caad5b1849c1831530d3eb5', getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff', - getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d', + getOpsxArchiveCommandTemplate: '5e754505e7baf80f8c6959a6fa5cacfabc6b50f8bf5ec94428e82095abf5d361', 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': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8', - 'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a', + 'openspec-sync-specs': '5fcd207922b55a78b19fbc2872690c104ef83be09f1369de8b4b48778a234d75', + 'openspec-archive-change': '238c6e04cca29ab8b8635bafd39d836b40f8290ccec110a2e6ed1bf8618773bf', 'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c', 'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f', 'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a', @@ -481,6 +481,85 @@ 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('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('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('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: + // 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'); + + // ...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'); + } + }); + 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;