Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/tidy-moons-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

archive: tell the author how to retire a capability when the emptied spec also holds content the merge cannot account for. That combination printed only "Spec must have at least one requirement" and no guidance at all; the abort now names the blocking lines and reports a `retire_capabilities` marker that is present but cannot be honored. Authored content quoted in those messages - the blocking lines, and the marker's own reason, which `openspec validate` prints too - is stripped of control characters and bounded in length before it reaches the terminal.
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 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.
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. Retirement deletes the whole file, so it is also refused while the spec holds anything outside its title, `## Purpose`, and its requirement blocks — a `## Notes` section, a comment under a requirement. The abort names those lines; move them into `## Purpose` or a requirement, or delete the spec by hand. 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-path>/spec.md` directly to change one. Here, `<capability-path>` is the directory relative to `specs/`, such as `user-auth` in a flat project or `identity/user-auth` in a project organized by domain.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-19
51 changes: 51 additions & 0 deletions openspec/changes/fix-archive-retirement-guidance/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Never dead-end a capability retirement

## Why

A change whose delta removes the last requirement a capability has rebuilds the
main spec empty, and an empty spec can never validate. Retirement is what
archive does instead, and because it deletes a file it has to be asked for: the
change declares `retire_capabilities: true`. The abort names that marker when it
is the single thing missing.

Retirement is also refused while the spec holds any non-blank line the merge
cannot name — a `## Notes` section, a comment under a requirement. Both are
ordinary things to find in a hand-written spec. When the marker was missing *and*
such a line was present, neither hint fired: the marker hint was suppressed
because adding it would not have let the archive through, and the hint that names
those lines only spoke to authors who had already declared the marker.

The archive then aborted on a bare "Spec must have at least one requirement" with
no guidance at all — the dead end the marker exists to close, still reachable
(#1696, worked around there with `--skip-specs` plus a hand-applied sync).

## What Changes

- When this run emptied the capability, the marker is absent, and the spec holds
content the merge cannot account for, the abort names that content and says
what archive would otherwise do with the spec.
- It still does not name the marker in that case. The marker is named only when
adding it would really let the archive through; a spec with a second
`## Requirements` section holding a live requirement must not be pointed toward
a deletion. Once the content is resolved, the rerun names the marker.
- A marker that is present but cannot be honored is reported alongside the
blocking content. An author who wrote `retire_capabilities: yes-please`
believes they authorised the deletion; making them clear the content first,
only to then learn the marker was never read, is two aborts for one mistake.
- The blocking lines are authored file content printed to a terminal, so they are
rendered with control characters replaced and their length bounded — the same
treatment a change directory name already gets. This also hardens the
marker-declared refusal, which echoed them verbatim.
- The marker's own reason gets the same treatment, at its source in
`readBooleanMarker`, because every reason quotes something the author wrote —
a schema name, a parser message carrying one, a filesystem error carrying a
path. Fixing it there covers `openspec validate`, which prints the same reason.

No change to what archive writes, deletes, or refuses. Message paths only.

## Impact

- Affected specs: `cli-archive` (MODIFIED: Capability Retirement)
- Affected code: `src/core/archive.ts`, `src/utils/change-metadata.ts`
- Affected docs: `docs/writing-specs.md` (states the second refusal condition,
which was true before this change but undocumented)
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## MODIFIED Requirements

### 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, with control characters replaced in the reason because it repeats what the author wrote
- **AND** say nothing about adding the marker when retiring would not have made the spec writable anyway, while still reporting a marker that is present but cannot be honored

#### 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 whether or not the change declared the marker, rather than aborting on the bare validation error
- **AND** name the marker only when adding it would let the archive through, so an author whose spec still holds such content is pointed at that content first
- **AND** render those lines with control characters replaced and their length bounded, because a spec that redraws the terminal or fills the screen would take the way out of the abort with it

#### 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
13 changes: 13 additions & 0 deletions openspec/changes/fix-archive-retirement-guidance/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Tasks

## 1. Name the blocking content when the marker is absent
- [x] 1.1 Derive "this run emptied the capability" once, and hint on it in both the marker-missing and content-blocked cases
- [x] 1.2 Keep the marker unnamed while content still blocks the retirement, while still reporting one that cannot be honored

## 2. Render the blocking lines safely
- [x] 2.1 Replace control characters and bound each line, sharing one helper with the marker-declared refusal
- [x] 2.2 Sanitize the marker's own reason at its source, so `validate` is covered too
- [x] 2.3 Cover the human abort, the `--json` detail, and the rendering with tests

## 3. Record the behavior
- [x] 3.1 Update the `cli-archive` spec delta and `docs/writing-specs.md`
Loading
Loading