Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
21ad9b6
fix(archive): retire a capability when a change removes its last requ…
clay-good Jul 29, 2026
ca8ab19
fix(archive): decide retirement from the validator and contain the de…
clay-good Jul 29, 2026
4503f6b
fix(archive): close the retirement gaps a second review round found
clay-good Jul 29, 2026
72e770b
fix(archive): make the write-failure test platform-neutral and the pa…
clay-good Jul 29, 2026
bdd8d18
fix(archive): make the residual-heading veto position-independent
clay-good Jul 29, 2026
f26b05b
test(archive): canonicalize both negative path assertions
clay-good Jul 29, 2026
5fbdddc
fix(archive): move a retired capability's spec into the archive inste…
clay-good Jul 29, 2026
111a02f
fix(archive): clean up staging directories when a retirement move fails
clay-good Jul 29, 2026
4ff2129
docs(archive): say "moved" where the retirement path still said "dele…
clay-good Jul 29, 2026
c04cab6
fix(archive): roll back a staged copy when the live spec cannot be re…
clay-good Jul 29, 2026
4126e21
test(archive): run the rename-fallback rollback case on Windows too
clay-good Jul 29, 2026
ea6aa3c
fix(archive): claim the retirement destination atomically
clay-good Jul 29, 2026
e3bd2cd
Merge branch 'main' into fix/retire-capability-removed-only-delta
clay-good Jul 29, 2026
3028990
fix(archive): take retirement ownership from an exclusive create, not…
clay-good Jul 29, 2026
a48d03b
fix(archive): keep the staged copy when the source is already gone
clay-good Jul 29, 2026
a184fae
refactor(archive): gate retirement on a declared marker, drop retired…
clay-good Jul 29, 2026
71eebc3
Merge remote-tracking branch 'origin/main' into HEAD
clay-good Jul 29, 2026
19ee599
chore: regenerate parity hashes over the merged sync-specs template
clay-good Jul 29, 2026
90bf9a7
docs(archive): correct claims the redesign left false, and bump to minor
clay-good Jul 29, 2026
49c31f4
fix(archive): refuse to retire a spec with a second Requirements section
clay-good Jul 29, 2026
4b4c42e
docs(archive): note that retiring a capability strands an in-flight M…
clay-good Jul 30, 2026
889d59e
fix(archive): veto retirement on any heading past the merged section
clay-good Jul 30, 2026
990d691
docs(archive): say the marker needs the schema key beside it
clay-good Jul 30, 2026
7c2b6c3
Merge remote-tracking branch 'origin/main' into HEAD
clay-good Jul 30, 2026
f9401ac
chore: merge main (#1483) and keep both archive test suites
clay-good Jul 30, 2026
e0b540e
fix(archive): only print a recovery command that would actually run
clay-good Jul 30, 2026
1fead66
fix(archive): retire only what the merge can account for
clay-good Jul 30, 2026
7ef2c15
fix(archive): account for the whole spec, not two slices of it
clay-good Jul 30, 2026
1b3606c
fix(specs): keep content absorbed into a removed requirement
clay-good Jul 30, 2026
c25bc79
fix(specs): keep notes absorbed into a modified or removed requirement
clay-good Jul 30, 2026
2e231ec
fix(specs): decide salvage by identity, not by matching text
clay-good Jul 30, 2026
459a6fd
fix(specs): warn when a note absorbed into a requirement will be deleted
clay-good Jul 30, 2026
870b322
fix(archive): scope a scenario's bullets, and stop refusing ordinary …
clay-good Jul 30, 2026
01ebf74
fix(archive): a section is not only an ATX heading
clay-good Jul 30, 2026
a1a44ec
Merge branch 'main' into fix/retire-capability-removed-only-delta
clay-good Jul 31, 2026
29f039c
Merge branch 'main' into fix/indented-requirement-headers
clay-good Jul 31, 2026
1c2f97e
Merge remote-tracking branch 'origin/pr-1490' into codex/pr-1484-fixes
clay-good Jul 31, 2026
c1e7778
fix(archive): close remaining capability retirement gaps
clay-good Jul 31, 2026
ddecf0b
fix(archive): preserve retirement safety after pr 1490
clay-good Jul 31, 2026
71b307e
fix(archive): close final transaction safety gaps
clay-good Jul 31, 2026
443a99e
fix(archive): close retirement race windows
clay-good Aug 1, 2026
2d3761f
fix(archive): preserve retirement authorization
clay-good Aug 1, 2026
20f0a25
fix(archive): verify complete fallback copies
clay-good Aug 3, 2026
f5b1bee
fix(archive): preserve transactional safety
clay-good Aug 3, 2026
7b4f84e
fix(archive): preserve replaced claims on Windows
clay-good Aug 3, 2026
2d22d8d
test(archive): respect Windows deferred deletion
clay-good Aug 3, 2026
bc24c97
Merge branch 'main' into fix/retire-capability-removed-only-delta
clay-good Aug 4, 2026
0deaab2
test(archive): align symlink fixtures with path boundaries
clay-good Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retire-capability-on-removed-only-delta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Retire a capability when a change removes its last requirement. A change that declares `retire_capabilities: true` in its `.openspec.yaml` (alongside the `schema:` that file requires) may now be archived even when its REMOVED entries take a capability's last requirement: `openspec archive` deletes that capability's main spec instead of aborting with "Spec must have at least one requirement". Without the marker nothing changes — the archive aborts exactly as before, except the message now names the marker as the way out. Retirement happens only when the emptied spec could not have been written at all, every one is named in the archive output, a pasteable `git checkout` is included when the spec lived in the caller's checkout, and `--no-validate` never retires. Archive now also rejects a main spec with duplicate canonical requirement names instead of letting delta reconciliation collapse one of the duplicate blocks. One thing to know before retiring: a capability's spec is the base another change's MODIFIED block is checked against, so an in-flight change that modifies the capability you just retired will keep validating clean and then refuse to archive ("target spec does not exist; only ADDED requirements are allowed for new specs") — close or rework that change alongside the retirement.
4 changes: 2 additions & 2 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1.

### 4.9 `archive <name> --json`
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written; an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.
Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"?, "warnings"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. `specsUpdated` is true only when at least one spec file was written or retired (a capability whose last requirement the change removed has its spec deleted, which requires `retire_capabilities: true` in the change's `.openspec.yaml`; every retirement is named in `warnings`, with a pasteable Git recovery command only when the spec lived in the caller's checkout); an already-synced change archives with all-zero totals and the skips listed in `warnings`. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code.

### 4.10 `doctor --json`
`{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "drift"?: {ahead,behind}, "status": [] } | null, "references": [...], "status": [] }`. `drift` (present only for a git-backed store checkout that has an upstream tracking ref) is ahead/behind counts against the last-fetched upstream, not the live remote. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1.
Expand Down Expand Up @@ -119,7 +119,7 @@ setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, regis
`relationship_registry_unreadable`, `root_pointer_ignored`, `root_pointer_invalid`, `pointer_declarations_inert`.

### Archive (JSON mode)
`archive_change_name_required`, `archive_change_not_found`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`.
`archive_change_name_required`, `archive_change_not_found`, `archive_change_symlink`, `archive_validation_failed`, `archive_confirmation_required`, `archive_tasks_incomplete`, `archive_spec_update_failed`, `archive_spec_validation_failed`, `archive_target_exists`, `archive_error`.

### Context writes
`context_file_exists`, `context_output_dir_missing`.
Expand Down
9 changes: 6 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ openspec archive [change-name] [options]
|--------|-------------|
| `-y, --yes` | Skip confirmation prompts. Required when nothing can answer them — an AI agent, a CI job, or any run with stdin closed |
| `--skip-specs` | Skip spec updates for one archive run. A change that permanently has no spec deltas should declare `skip_specs: true` in its `.openspec.yaml` instead — it archives with no flag |
| `--no-validate` | Skip validation (requires confirmation) |
| `--no-validate` | Skip validation (requires confirmation). Also disables capability retirement — with no validator verdict, nothing is retired |

**Examples:**

Expand All @@ -652,8 +652,11 @@ openspec archive update-ci-config --skip-specs

1. Validates the change (unless `--no-validate`)
2. Prompts for confirmation (unless `--yes`)
3. Merges delta specs into `openspec/specs/`
4. Moves change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`
3. Claims the archive destination before changing any main spec
4. Validates and merges the active delta specs into `openspec/specs/` — a capability whose last requirement the change removes is retired, and its spec file deleted, but only when the change's `.openspec.yaml` declares `retire_capabilities: true` next to its `schema:`
5. Moves the change folder to `openspec/changes/archive/YYYY-MM-DD-<name>/`
6. If a spec mutation or final move fails before a complete archive is secured, restores the specs and leaves or returns the change at its active path
7. If a verified fallback copy completes but staged-source cleanup fails, retains the complete archive and committed spec state for recovery

**Without a terminal:** an AI agent, a CI job, or any run with stdin closed cannot
answer step 2, so archive stops before touching anything, exits 1, and names the
Expand Down
4 changes: 2 additions & 2 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ openspec/changes/add-dark-mode/
├── proposal.md # Why and what
├── design.md # How (technical approach)
├── tasks.md # Implementation checklist
├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs
├── .openspec.yaml # Change metadata (optional): schema, created, skip_specs, retire_capabilities
└── specs/ # Delta specs
└── ui/
└── spec.md # What's changing in ui/spec.md
Expand Down Expand Up @@ -392,7 +392,7 @@ The system MUST expire sessions after 15 minutes of inactivity.
|---------|---------|------------------------|
| `## ADDED Requirements` | New behavior | Appended to main spec |
| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement |
| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec |
| `## 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 |

### Why Deltas Instead of Full Specs
Expand Down
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ A spec that describes only what's changing, using `ADDED`, `MODIFIED`, and `REMO

### Where do archived changes go?

To `openspec/changes/archive/YYYY-MM-DD-<name>/`, with all artifacts preserved. Nothing is deleted; the change just moves out of your active list.
To `openspec/changes/archive/YYYY-MM-DD-<name>/`, with all change artifacts preserved. The change moves out of your active list. A change that explicitly declares `retire_capabilities: true` can also delete a main capability spec when it removes that capability's final requirement.

## Configuration and customization

Expand Down
2 changes: 1 addition & 1 deletion docs/writing-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ A change describes its edits to the specs with three section types. Using the ri
- **`## MODIFIED Requirements`** — behavior that already existed and is changing. Include the full new version; a short note on what changed helps a reviewer.
- **`## REMOVED Requirements`** — behavior going away, with a line on why.

On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.
On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is dropped from it. Remove the last requirement a capability has and you retire it: rather than leave a spec with nothing in it, archive deletes `openspec/specs/<capability>/spec.md`. Because that is the one archive step that removes a file, it has to be asked for — add `retire_capabilities: true` to the change's `.openspec.yaml`, alongside the `schema:` that file already needs. Without it the archive aborts and tells you so. For a spec in the caller's checkout, the archive output also names the `git checkout` that restores a committed file; selected stores receive checkout-scoped recovery guidance instead. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.

One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs/<capability>/spec.md` directly to change one.

Expand Down
85 changes: 79 additions & 6 deletions openspec/specs/cli-archive/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ The archive operation SHALL follow a structured process to safely move changes t
- **THEN** execute these steps:
1. Create archive/ directory if it doesn't exist
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date, keeping the name as-is when it already starts with a `YYYY-MM-DD-` prefix
3. Check if target directory already exists
4. Update main specs from the change's future state specs (see Spec Update Process below)
5. Move the entire change directory to the archive location
3. Claim the target and verify that it does not already exist
4. Prepare and validate spec updates from the active change's delta specs
5. Apply the spec updates as a rollback-capable transaction
6. Move the entire change directory to the archive location
7. If a spec mutation or final move fails before a complete archive is secured, restore the spec transaction and leave or return the change at its active path
8. If a verified fallback copy completes but staged-source cleanup fails, retain the complete archive and committed spec state for recovery instead of risking the only complete copy

#### Scenario: Archive already exists

Expand All @@ -78,7 +81,7 @@ The archive operation SHALL follow a structured process to safely move changes t

### Requirement: Spec Update Process

Before moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.
After claiming the archive destination, the command SHALL apply delta changes to main specs to reflect the deployed reality, then move the change to its archive destination. It SHALL restore the spec transaction when a mutation or final move fails before a complete archive is secured. Once a verified fallback archive is complete, a staged-source cleanup failure SHALL retain that archive and committed spec state for recovery.

#### Scenario: Applying delta changes

Expand All @@ -98,6 +101,12 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **THEN** abort with error message showing the conflict
- **AND** suggest manual resolution

#### Scenario: Duplicate requirement already exists in the main spec

- **WHEN** a main spec contains two canonical requirement headers with the same name
- **THEN** reject the structurally ambiguous main spec before applying any delta
- **AND** preserve the main spec and active change unchanged

#### Scenario: New main spec inherits the delta's Purpose

- **WHEN** a delta creates a main spec that does not exist yet
Expand Down Expand Up @@ -130,6 +139,70 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
- **THEN** leave the existing Purpose untouched
- **AND** warn that the delta Purpose was ignored, naming the spec file to edit directly, but only when that spec has a Purpose of its own and it differs from the delta's

### Requirement: Capability Retirement

A delta whose REMOVED entries cover every requirement a capability has SHALL retire that capability instead of writing a main spec with no requirements, which can never pass validation.

#### Scenario: Deciding that a rebuilt spec cannot be written

- **WHEN** applying a delta leaves the rebuilt spec with no requirement blocks, and every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples
- **THEN** put that rebuilt spec to the spec validator
- **AND** treat it as retirable only when its sole validation error is that the spec has no requirements
- **AND** otherwise write or reject it exactly as any other rebuilt spec, so a spec the validator still accepts, one broken in some further way, and one still holding a `###` heading are all left alone

#### Scenario: Validation was skipped

- **WHEN** the archive runs with validation disabled
- **THEN** retire nothing, because no verdict was produced to justify a deletion
- **AND** write the rebuilt spec exactly as an archive without this behavior would

#### Scenario: Retirement is not declared

- **WHEN** a rebuilt spec is retirable but the change does not declare `retire_capabilities: true` in its metadata, or declares it in metadata that cannot be honored
- **THEN** write the spec as any other, so the archive aborts on it exactly as it did before this behavior existed
- **AND** name the marker as the fix in that abort, and say when a marker that is present cannot be honored
- **AND** say nothing about the marker when retiring would not have made the spec writable anyway

#### Scenario: Delta removes the capability's last requirement

- **WHEN** a retirable rebuilt spec belongs to a capability whose main spec exists
- **AND** at least one requirement was actually removed by this run
- **AND** the change declares `retire_capabilities: true`
- **THEN** delete the capability's `spec.md` instead of writing it
- **AND** refuse to delete when the target resolves outside the real specs root
- **AND** delete any in-root directory the deletion leaves empty, and never the specs root itself
- **AND** count every operation the delta applied in the archive totals
- **AND** record the retirement in the archive warnings, naming what the deleted file held and giving a pasteable Git recovery command only when the spec lived in the caller's checkout

#### Scenario: Retirement is deferred until every spec is written

- **WHEN** an archive both retires one capability and updates another
- **THEN** settle the archive destination before touching any spec, so a name collision cannot strand a retirement
- **AND** perform the deletion only after every spec write has succeeded
- **AND** report a destination claimed while the merge ran as the same collision, rather than as a raw filesystem error

#### Scenario: Capability directory holds other files

- **WHEN** retiring a capability whose directory still holds other files after `spec.md` is deleted
- **THEN** leave that directory in place

#### Scenario: Removal was already synced

- **WHEN** a retirable rebuilt spec removed nothing this run and its main spec exists
- **THEN** leave the file untouched
- **AND** abort the archive with the validation error, as for any other unwritable spec, unless validation was skipped

#### Scenario: Content the merge cannot account for

- **WHEN** the spec holds any non-blank line the merge cannot name - anywhere in the file, including above the requirements section and inside a requirement block, where content the parser did not read as a new header rides along
- **THEN** refuse the retirement, because deleting the file would take that content with it
- **AND** say which lines stood in the way when the change declared the marker, rather than aborting on the bare validation error

#### Scenario: Main spec is already gone

- **WHEN** a REMOVED-only delta targets a capability that has no main spec, and the change declares `retire_capabilities: true`
- **THEN** complete the archive without creating or retiring one

### Requirement: Confirmation Behavior

The spec update confirmation SHALL provide clear visibility into changes before they are applied.
Expand Down Expand Up @@ -269,6 +342,6 @@ The archive command SHALL validate changes before applying them to ensure data i
**Task checking**: Prevents accidental archiving of incomplete work
**Date prefixing**: Maintains chronological order and prevents naming conflicts; a name that already carries a date prefix keeps it, so archived names never stack dates
**No overwrite**: Preserves historical archives and prevents data loss
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
**Claim-first transaction**: The destination is claimed before main specs are mutated, spec changes are rollback-protected, and the active change is moved only after the spec transaction succeeds
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
1 change: 1 addition & 0 deletions openspec/specs/opsx-archive-skill/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ The skill SHALL prompt to sync delta specs before archiving if specs exist.
- **AND** if user cancels, stop without archiving
- **AND** if user confirms, execute `/opsx:sync` logic inline and wait for it to complete
- **AND** verify every capability that has a delta spec, not only those the sync reports it touched: ADDED requirements present, MODIFIED requirements carrying the changes named in the delta, REMOVED requirements absent, RENAMED requirements present under the new name and absent under the old one
- **AND** treat a capability whose last requirement the sync removed as verified when its main spec was deleted rather than left empty, and a spec the sync deliberately kept and reported as verified too
- **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

Expand Down
Loading
Loading