Skip to content
Open
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/add-lifecycle-status-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Add an opt-in experimental `lifecycle: status` mode, in which a change's lifecycle state is a field in its metadata rather than its position in the filesystem. Under `lifecycle: status` a change carries `status: proposed | shipped` in `.openspec.yaml` and never moves: `openspec sync` folds every shipped change's deltas into `openspec/specs/` idempotently, `openspec sync --check` gates the `shipped ⇒ folded` predicate deterministically for pre-commit, pre-push and CI, and `openspec ship <change>` declares and folds in one diff. Changes are stored sharded by their immutable creation date (`changes/YYYY/MM/DD-<name>/`), discovered by one shared implementation that reads both layouts, and `openspec migrate` converts a project between the two modes in either direction without touching spec text. `openspec list` gains a lifecycle column and `--status` filter, and `openspec archive` refuses under status mode so the two models stay disjoint. Projects that do not set `lifecycle` resolve to `archive` and are entirely unaffected.
2 changes: 2 additions & 0 deletions openspec/changes/add-lifecycle-status-mode/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-17
83 changes: 83 additions & 0 deletions openspec/changes/add-lifecycle-status-mode/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
## Context

`archive` conflates a state transition with a text merge. The merge itself is fine; welding it to a directory move is what makes it hard to schedule. On a solo repo the two are indistinguishable. On a team with review, every possible moment to run `archive` is wrong somewhere:

| Moment | Why it breaks |
|---|---|
| During the PR | Review feedback invalidates the fold; un-archive does not exist and re-archive is not a no-op |
| After merge | A bot commit to a protected branch, racing concurrent merges |
| At approval | GitLab has no approval event (`CI_MERGE_REQUEST_APPROVED` is pre-pipeline), and pushes reset approvals |

## Goals / Non-Goals

**Goals**

- Make lifecycle state a first-class fact that merges trivially and can be edited to correct a mistake.
- Make the fold a standalone, idempotent operation that is safe to run late, twice, or never-yet.
- Make "is this repo consistent?" a pure function of the working tree, so one predicate gates pre-commit, pre-push and CI.
- Change nothing for projects that do not opt in.

**Non-Goals**

- Concurrent modification of the same requirement by two open changes (see #1669 and the parallel-merge plan).
- Replacing the archive workflow. This is an experiment with an exit; if it does not graduate, it is removed.
- Capability maturity tags. Those describe requirements, not changes.

## Decisions

### The state set is closed, and every state has machine consequences

`status: proposed | shipped`. Two states, because a state with no attached consequence is a comment:

- `shipped` means "these deltas belong in `specs/`" — what `sync` folds and what `--check` gates.
- `proposed` means "this change holds a live claim on the requirements it touches" — what overlap and drift tooling can reason over without inferring liveness from a directory path.

An `applied` state was prototyped and dropped: "implementation done" is already recorded by `tasks.md` checkboxes, and a duplicate record drifts. Further states are possible later — `abandoned` would release the live claim — but each must earn its place with a consequence.

### "Folded" is decided by regeneration, not bookkeeping

A change is in sync when re-applying its delta to the current spec produces byte-identical output. No lockfile, no hash sidecar, no timestamp comparison — the check rebuilds and compares.

This costs O(shipped history) per run rather than O(active changes), which is negligible for young histories and is the reason a `--changed` scope is named as future work rather than shipped here. In exchange the gate has no state of its own to corrupt, and — importantly — `--check` and the fold share one code path. A checker that reimplements the doer is how #1112 happened: `validate` passed what `archive` then refused. Here the only difference between checking and doing is whether the rebuilt bytes get written.

### The gate is a tree predicate, not a timing condition

`shipped ⇒ folded`. This is what makes the mode enforceable rather than merely conventional. A timing condition ("archive ran at the right moment") cannot be evaluated mid-PR, precisely when the invariant is supposed to be violated. A tree predicate can be evaluated on any tree by anyone:

```sh
openspec sync --check # pre-commit · pre-push · CI — same command, same verdict
```

Hooks are advisory (`--no-verify` skips them), so CI remains the authority for the tree-level property. The one property that inverts this is atomicity: whether declaring and folding happened in the *same commit* is a history-level fact that CI, which sees only the head tree, is structurally blind to. `ship` makes the atomic path the default one, and a pre-push sweep over the pushed range can enforce it where a team cares.

### `archive` refuses rather than coexists

Under `lifecycle: status`, `openspec archive` throws and names the alternative. Two models that can both claim a change is finished would let `specs/` disagree with itself. The refusal is what keeps `specs/` = shipped reality true in both modes, which is also what makes migration between them a pure relayout: neither mode's `specs/` content differs.

### Layout shards by creation date, which is immutable

If nothing ever moves, `changes/` accumulates. The layout shards by a date **assigned at birth**: `changes/2026/03/15-add-oauth/`. Creation date is chosen precisely because it can never change — sharding by *shipped* date would smuggle the move back in, which is the thing this design removes. The day prefix keeps the full date in the path and `ls` chronological, carrying the same information today's `archive/YYYY-MM-DD-<name>/` carries, relocated from the contested end of the lifecycle to the fixed one.

Discovery reads both layouts by rule: `YYYY` and `MM` directories are shards to walk into, anything else is a change. That keeps flat projects working untouched and makes the layout a storage detail rather than a new contract.

This is the decision most likely to be superseded. [#1367](https://github.com/Fission-AI/OpenSpec/pull/1367) proposes user-chosen *domains* under `changes/`, discovered by a leaf marker (`.openspec.yaml`/`proposal.md` present) rather than a naming convention. That is a better mechanism, and domains carry meaning a calendar cannot. If it lands, this sharding should be dropped in favor of it, and discovery here should be replaced by that walk — the mode above does not depend on which one wins, only on nothing moving. Noted here rather than resolved because it is upstream's call, not ours.

### Migration is bidirectional, because an experiment must be leaveable

`openspec migrate` converts in both directions, and neither direction touches spec text: archive-mode `specs/` is folded shipped reality, which is exactly what status-mode maintains. Reversal is therefore a pure relayout, covered by a round-trip test.

The reverse direction refuses while any shipped change has unfolded deltas, because the archive layout asserts a fold that must actually exist. It reuses the gate itself rather than reimplementing its verdict — the same anti-drift reasoning as `--check` sharing the fold's code path.

Two hazards the forward direction has to handle, both consequences of bare change ids: a legacy name reused across archive eras would shard into two directories no bare id can address (refused up front, with the collisions named), and an interrupted run leaves shards that a naive re-run would try to move into themselves (skipped, so the migration resumes).

## Risks / Trade-offs

- **`ls` stops being the answer to "what's active."** Once state is data, the filesystem is no longer the UI for state; `openspec list --status proposed` is. This is the honest cost of the whole design and is why the mode is opt-in.
- **The fold diff relocates, it does not disappear.** It lands wherever `sync` ran instead of in the archive commit. Deterministic output makes it reviewable the way a lockfile is: regenerate and compare.
- **Editing a delta after it was folded** re-merges over an earlier fold, which needs base snapshots to do correctly. This window pre-exists; making fold-anytime first-class means it sees more traffic. `sync --check` detects the state and fails closed rather than corrupting `specs/`. `sync` is a natural recording point for the parallel-merge plan's base snapshots when those arrive.

## Migration

`openspec migrate` converts a legacy project: archived changes become `status: shipped` sharded by the date their archive folder recorded, in-flight changes become `status: proposed` sharded by their `created` date, the now-empty `archive/` directory is removed, and the config line is written last so an interrupted run is resumable. Nothing is deleted, and `sync --check` verifies the result by regeneration — the engine's own folds re-apply byte-identically, so the gate is green immediately after migrating.

`openspec migrate --to archive` converts back: shipped changes return to `changes/archive/<created>-<name>/`, proposed changes return to flat `changes/<name>/`, the `status` key is stripped (under archive mode, location is the state), and empty shard directories are pruned. One caveat worth stating: a change shipped under status mode carries its *creation* date into an archive folder name where convention reads an *archival* date. That is the only information the round trip cannot preserve, because archive mode never recorded the other one.
52 changes: 52 additions & 0 deletions openspec/changes/add-lifecycle-status-mode/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Why

`archive` does two unrelated jobs in one command: a **state transition** (declaring a change shipped) and a **text merge** (folding deltas into `specs/`). Encoding the transition as a directory move welds the merge to a single moment in the PR lifecycle — and on a team with review, that moment does not exist. Review feedback forces un-archive → edit → re-archive; archiving after merge means a bot commit to a protected branch; and GitLab has no approval event to hang it on.

The team-workflow docs offer both conventions and say "pick one and be consistent" — a choice of costs, not an answer.

This change adds an opt-in experimental mode where a change's lifecycle state is a **field in its metadata** rather than its position in the filesystem, so the merge becomes a standalone idempotent command that can run at any time and be checked deterministically in CI.

## What Changes

- `openspec/config.yaml` accepts `lifecycle: archive | status`. `archive` is the default and current behavior; nothing changes for existing projects.
- Under `lifecycle: status`, a change's `.openspec.yaml` carries `status: proposed | shipped`. New changes are born `proposed`.
- `openspec sync` folds every `shipped` change's deltas into `specs/`, idempotently. It is the text-merge half of archive, decoupled from any move.
- `openspec sync --check` exits 1 if any `shipped` change has unfolded deltas — a deterministic, model-free gate that runs identically at pre-commit, pre-push and in CI.
- `openspec ship <change>` sets `status: shipped` and folds in one working-tree diff, restoring archive's declare-and-fold atomicity as a convenience rather than a mandate.
- `openspec list` shows the lifecycle state and accepts `--status <state>` to filter.
- `openspec archive` refuses to run under `lifecycle: status` and points at the status workflow, so the two models can never both claim a change.

- Under `lifecycle: status`, changes are stored sharded by their **creation date** — `changes/YYYY/MM/DD-<name>/` — a fact fixed at birth, so location never encodes lifecycle state and nothing ever has to move. Discovery reads both layouts.
- `openspec migrate` converts a project between the two modes in **either direction**, moving only bookkeeping. Neither direction touches spec text.

## Capabilities

### New Capabilities

- `lifecycle-status-mode`: the experimental `lifecycle: status` mode — the config flag, the `status` metadata field, the `sync`/`sync --check`/`ship` commands, the `list` surface, and the `archive` refusal that keeps the two models disjoint.
- `change-layout-discovery`: enumerating and resolving changes across both the flat layout and the creation-date sharded layout, including the ambiguity and containment rules that bare change ids require.
- `lifecycle-migration`: bidirectional conversion between the two modes, its refusal conditions, and its resumability.

### Modified Capabilities

_None._ The mode is opt-in and inert under the default `lifecycle: archive`: `sync` reports that the project uses archive mode and exits 0, `ship` refuses and points at `openspec archive`, `list` renders no lifecycle column when no change declares a status, `archive` is untouched, and discovery keeps returning exactly what it returned before for a flat tree. Existing capability specs describe archive-mode behavior, which this change does not alter.

## Impact

- `src/core/project-config.ts` — the `lifecycle` config field and its resolver
- `src/core/change-metadata/schema.ts` — the optional `status` field
- `src/core/sync.ts` — new `SyncCommand` and `ShipCommand`
- `src/core/archive.ts` — refusal guard under status mode
- `src/core/list.ts` — lifecycle column and `--status` filter
- `src/utils/change-utils.ts` — new changes are born `proposed` under status mode
- `src/cli/index.ts`, `src/core/completions/command-registry.ts` — command surface and completions
- `src/core/specs-apply.ts` — the generated skeleton's Purpose line no longer says "by archiving", since a fold can now happen without one
- `src/core/change-discovery.ts` — new: layout-agnostic enumeration and id resolution
- `src/core/lifecycle-migrate.ts` — new: bidirectional migration
- `src/commands/change.ts`, `src/commands/validate.ts`, `src/commands/workflow/*`, `src/core/view.ts`, `src/utils/item-discovery.ts`, `src/core/planning-home.ts` — every surface that enumerates or resolves a change now goes through the shared discovery

## Out of scope

- **Concurrent modification of the same requirement** by two open changes. This changes *when* the merge may run, not *how* it merges; it composes with the parallel-merge plan and with #1669.
- **Deriving shipped-ness from git.** Git proves a change folder landed on a branch, not that the change was implemented. Status stays an explicit declaration; git facts can cross-check it, not replace it.
- **Capability maturity tags** (`experimental`, `beta`, `deprecated`). Those describe requirements in `specs/`, not changes, and belong there as user-defined semantic labels. Different axis, different proposal.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## ADDED Requirements

### Requirement: Changes are discovered across both layouts

Change enumeration SHALL find changes in the flat layout (`changes/<name>/`) and in the creation-date sharded layout (`changes/YYYY/MM/DD-<name>/`) from one shared implementation. A four-digit directory SHALL be treated as a year shard and a two-digit directory beneath it as a month shard; any other directory SHALL be treated as a change. A change discovered under a shard SHALL be identified by its directory name with the `DD-` prefix removed. The `archive/` directory and hidden directories SHALL be excluded, as they are today.

#### Scenario: Mixed tree
- **WHEN** `openspec/changes/` contains `flat-change/`, `2026/03/15-old-change/`, and `archive/2026-01-01-buried/`
- **THEN** enumeration returns exactly `flat-change` and `old-change`

#### Scenario: Every surface agrees
- **WHEN** a project uses the sharded layout
- **THEN** `openspec list`, `openspec show`, `openspec validate`, `openspec status`, `openspec instructions`, shell completions, and the dashboard view all resolve its changes, and none of them reports a shard directory as a change

### Requirement: A bare change id resolves unambiguously or not at all

Resolving a change id SHALL find its directory in either layout. When two shard dates carry the same id, resolution SHALL fail with an error naming both locations rather than choosing one. An id that enumeration could never produce — one containing a path separator or null byte, a dot segment, a hidden name, the reserved `archive` name, or a bare year — SHALL resolve to nothing, so that no id can address a directory outside the change namespace.

#### Scenario: Ambiguous id is refused
- **WHEN** a user names a change id that exists under two different shard dates
- **THEN** the command fails with an error naming both directories

#### Scenario: Shard and reserved directories are not changes
- **WHEN** a user names `2026` or `archive` as a change id
- **THEN** resolution finds no change, rather than returning the shard or archive directory

#### Scenario: Traversing ids are refused
- **WHEN** a user names a change id containing `..` or a path separator
- **THEN** resolution finds no change, and no path outside `openspec/changes/` is read

### Requirement: New changes are created in the layout their mode implies

Under `lifecycle: status`, `openspec new change` SHALL create the change at `changes/YYYY/MM/DD-<name>/` using the creation date, and SHALL report the path it actually created. It SHALL NOT create the `changes/archive/` directory, which the mode does not use. Under `lifecycle: archive`, creation SHALL remain flat and unchanged.

#### Scenario: Sharded creation under status mode
- **WHEN** a user runs `openspec new change add-oauth` in a status-mode project on 2026-08-17
- **THEN** the change is created at `openspec/changes/2026/08/17-add-oauth/` and the reported path matches

#### Scenario: The abolished directory is not recreated
- **WHEN** a user runs `openspec new change add-oauth` in a status-mode project
- **THEN** no `openspec/changes/archive/` directory is created

#### Scenario: A name that could never be resolved is refused at creation
- **WHEN** a user runs `openspec new change archive` or `openspec new change 2026`
- **THEN** the command fails naming the id as reserved, because creation and resolution share one notion of which ids can address a change
Loading