Skip to content
Draft
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
13 changes: 13 additions & 0 deletions .changeset/sync-lifecycle-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@fission-ai/openspec": minor
---

Add `openspec sync`, which folds a change's delta specs into the main specs without archiving it, and an optional `status: proposed | shipped` field in a change's `.openspec.yaml`.

`openspec sync --check` gates on one property: a change that claims to be shipped has its deltas in `specs/`. A proposed change passes for free, so the check is green as its resting state and red only on a real mistake — unlike a check for "is everything archived?", which is red for the whole life of every open pull request. It reads only files on disk, so a pre-commit hook, a pre-push hook and CI run the same command and agree.

`openspec list --status <state>` filters changes by that field.

Everything here is opt-in and inert by default. The `status` field is absent unless a project writes it, nothing generates it, and `archive` is unchanged.

Designed by [@ixxie](https://github.com/ixxie) in [#1683](https://github.com/Fission-AI/OpenSpec/issues/1683) — the diagnosis that `archive` welds a state transition to a text merge, `shipped ⇒ folded` as a predicate over the working tree, and the standalone `sync` that makes it checkable. This ships a smaller, additive subset of that proposal.
96 changes: 96 additions & 0 deletions docs-lab/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| [`openspec show`](#openspec-show) | Print a change or spec, as markdown or JSON. |
| [`openspec view`](#openspec-view) | One-screen dashboard of specs and changes. |
| [`openspec validate`](#openspec-validate) | Check changes and specs for structural issues. |
| [`openspec sync`](#openspec-sync) | Fold a change's delta specs into the main specs, without archiving it. |
| [`openspec archive`](#openspec-archive) | Move a completed change to the archive and update the main specs. |

**Workflows and schemas**
Expand Down Expand Up @@ -410,6 +411,7 @@ Rows come from `openspec/changes/` and `openspec/specs/` under the resolved root
| `--specs` | List specs instead of changes. |
| `--changes` | List changes. This is the default. |
| `--sort <order>` | `recent` (last modified first) or `name`. Default: `recent`. Specs always sort by name. |
| `--status <state>` | Only list changes in this lifecycle state: `proposed` or `shipped`. A change with no `status` in its `.openspec.yaml` counts as `proposed`. |
| `--json` | Print JSON instead of the table. |
| `--store <id>` | Use a registered store as the OpenSpec root instead of the current project. |

Expand Down Expand Up @@ -867,6 +869,100 @@ exit $validationExit

These custom views keep the full report's keys but omit clean items. They are neither complete full-v1 reports nor the versioned `--report findings` shape.

## openspec sync

Folds a change's delta specs into the main specs, without archiving the change.

```bash
openspec sync add-rate-limit # fold one change now; nothing moves
openspec sync add-rate-limit --ship # mark it shipped and fold it, in one set of changes
openspec sync # fold every change declaring status: shipped
openspec sync --check # exit 1 if a shipped change has unfolded deltas
```

`archive` folds and moves in one step, so the fold can only happen at the moment the
change is finished. `sync` separates them: the specs can be brought up to date while
the change is still open, and CI can check that they are.

**Arguments**

| Argument | What it is |
|---|---|
| `change-name` | The change to sync. Omitted, every change declaring `status: shipped` |

**Options**

| Flag | Effect |
|---|---|
| `--check` | Report shipped changes with unfolded deltas and exit 1. Writes nothing. |
| `--ship` | Fold the named change, then set `status: shipped` on it. If the fold fails, the field is not set. |
| `-y, --yes` | Sync even when the change has incomplete tasks. |
| `--no-validate` | Skip validation. |
| `--json` | Print a structured result instead of text. |
| `--store <id>` | Use a registered store as the OpenSpec root. |

**The lifecycle field**

A change may declare where it sits, in its `.openspec.yaml`:

```yaml
schema: spec-driven
status: shipped
```

Optional and absent by default. No `status` means `proposed`, which is what a change
under `changes/` has always meant. Nothing writes the field on its own.

**The gate**

`openspec sync --check` asserts that a change claiming to be shipped has its deltas in
`specs/`. A proposed change passes for free, so green is the resting state:

```
✓ 1 shipped change(s) are folded into the main specs.
```

and red names both the gap and the fix:

```
Sync check failed:

add-rate-limit
api: +1 not applied

Run openspec sync to fold them, then commit the result.
```

It reads only files on disk — no VCS history, no timing — so a pre-commit hook, a
pre-push hook and CI run the same command and agree.

**Output**

```
Applying changes to openspec/specs/api/spec.md:
+ 1 added
Totals: + 1, ~ 0, - 0, → 0
Specs updated successfully.
```

Running it again reports `Specs already in sync; no files changed.` — and so does
`openspec archive` afterwards, because re-applying a folded delta is a no-op.

**What it will not do**

Sync never deletes a spec. When a change's `REMOVED` entries take a capability's last
requirement, retiring it deletes the file, which stays with `openspec archive` behind
the `retire_capabilities` marker. Sync reports the case and names archive instead.

Sync also never examines archived changes: their deltas are history, superseded by
whatever came after.

**Exit codes**

- `0`: the specs were folded, or `--check` found nothing wrong.
- `1`: `--check` found an unfolded shipped change, validation failed, tasks were
incomplete, or the change was not found.

## openspec archive

Moves a completed change to the archive and updates the main specs.
Expand Down
104 changes: 103 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali
| **Personal worksets** | `workset create`, `workset list`, `workset open`, `workset remove` | Keep and open personal, local working views in your tool |
| **Browsing** | `list`, `view`, `show` | Explore changes and specs |
| **Validation** | `validate` | Check changes and specs for issues |
| **Lifecycle** | `archive` | Finalize completed changes |
| **Lifecycle** | `sync`, `archive` | Fold delta specs into the main specs, and finalize completed changes |
| **Workflow** | `new change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support |
| **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows |
| **Config** | `config` | View and modify settings |
Expand Down Expand Up @@ -443,6 +443,7 @@ openspec list [options]
| `--specs` | List specs instead of changes |
| `--changes` | List changes (default) |
| `--sort <order>` | Sort by `recent` (default) or `name` |
| `--status <state>` | Only list changes in this lifecycle state: `proposed` or `shipped`. A change whose `.openspec.yaml` has no `status` counts as `proposed` |
| `--json` | Output as JSON |

**Examples:**
Expand Down Expand Up @@ -626,6 +627,107 @@ Validating add-dark-mode...

## Lifecycle Commands

### `openspec sync`

Fold a change's delta specs into the main specs, without archiving the change.

```
openspec sync [change-name] [options]
```

`archive` does two things at once: it folds a change's deltas into `openspec/specs/`
and it moves the change folder. `sync` does only the first, so the specs can be
brought up to date while the change is still open for review — and so CI can check
that they are.

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| `change-name` | No | Change to sync. Omitted, `sync` acts on every change that declares `status: shipped` |

**Options:**

| Option | Description |
|--------|-------------|
| `--check` | Report shipped changes whose deltas are not in the main specs and exit 1. Writes nothing |
| `--ship` | Fold the named change, then set `status: shipped` on it — both land in one set of file changes for you to commit. If the fold fails, the field is not set |
| `-y, --yes` | Sync even when the change still has incomplete tasks |
| `--no-validate` | Skip validation (not recommended) |
| `--json` | Structured output for hooks and CI |
| `--store <id>` | Use a registered store as the OpenSpec root |

**The lifecycle field.** A change's `.openspec.yaml` may declare where it sits:

```yaml
schema: spec-driven
status: shipped # or: proposed
```

The field is optional and absent by default. A change with no `status` is
`proposed`, which is what every change under `changes/` has always meant, so a
project that never opts in is unaffected. Nothing writes the field on its own —
not `openspec new change`, not `archive`.

If the fold fails — validation, incomplete tasks, a retirement, a write error —
the field is not set. `--ship` writes `status: shipped` only after the specs are
correct, so a failed run never leaves a change claiming to be shipped with its
deltas absent.

**The CI gate.** `openspec sync --check` asserts one property: *a change that
claims to be shipped has its deltas in `specs/`*. A proposed change passes for
free, so the check is green as its resting state and red only on a real mistake —
unlike "is everything archived?", which is red for the entire life of every open
PR. It reads only files on disk, so a pre-commit hook, a pre-push hook and CI run
the same command and reach the same verdict.

```bash
# CI, pre-commit, pre-push — same command
openspec sync --check
```

**Examples:**

```bash
# Fold one change's deltas now; the change stays where it is
openspec sync add-rate-limit

# Mark it shipped and fold it, so both land in one commit when you make it
openspec sync add-rate-limit --ship

# Fold every change that declares status: shipped
openspec sync

# Gate: exits 1 if any shipped change has unfolded deltas
openspec sync --check

# Which changes have claimed to be shipped but aren't archived yet
openspec list --status shipped
```

**What it does:**

1. Validates the change's delta specs (unless `--no-validate`)
2. Refuses a change with incomplete tasks, unless `--yes` — folding a change
nothing implements yet writes requirements into `specs/` that aren't true
3. Validates every rebuilt spec before writing any of them, so a late failure
leaves the whole tree unchanged
4. Writes the updated main specs. Nothing moves; nothing is deleted

**What it deliberately does not do:**

- **It never deletes a spec.** When a change's `REMOVED` entries take a
capability's last requirement, retiring that capability deletes its `spec.md`.
That stays with `openspec archive`, behind the `retire_capabilities` marker.
`sync` reports the case and points you there.
- **It never checks archived changes.** Archived deltas are history, and later
changes supersede them. `--check` looks only at active changes that declare
`status: shipped` — a set that drains itself as those changes archive.

**Syncing early does not change archiving.** Re-applying a delta that is already
in the main specs is a no-op, so `openspec archive` afterwards reports
`Specs already in sync` and moves the folder exactly as it always did.

### `openspec archive`

Archive a completed change and merge delta specs into main specs.
Expand Down
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ AI: Verifying add-dark-mode...

**Optional command.** Merge delta specs from a change into main specs. Archive will prompt to sync if needed, so you typically don't need to run this manually.

> Not the same as the CLI's `openspec sync`. This one is the agent doing the merge in your session. `openspec sync` is a deterministic terminal command that does the same fold without a model, and carries the `--check` gate for CI — see [CLI](cli.md#openspec-sync).

**Syntax:**
```
/opsx:sync [change-name]
Expand Down
4 changes: 3 additions & 1 deletion docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ 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-<name>/`. 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. Usually automatic (archive offers to do it). Available on its own two ways: `/opsx:sync`, where the agent does the merge ([Commands](commands.md#opsxsync)), and `openspec sync`, the deterministic CLI command ([CLI](cli.md#openspec-sync)).

**Shipped / proposed.** A change may declare its lifecycle state as `status: proposed | shipped` in its `.openspec.yaml`. The field is optional and absent by default; no `status` means `proposed`. `openspec sync --check` gates on it — a change that claims to be shipped must have its deltas in the main specs — which makes the specs enforceable in CI without a check that is red for the whole life of every PR. See [OpenSpec on a Team](team-workflow.md#enforcing-it-in-ci).

## Workflow and commands

Expand Down
32 changes: 32 additions & 0 deletions docs/team-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ Archiving folds a change's deltas into your main `openspec/specs/` and moves the

Pick one and be consistent. Either way, `/opsx:archive` checks that tasks are complete and offers to sync first, so nothing merges half-finished by accident.

## Enforcing it in CI

The obvious CI check — "nothing is left unarchived" — doesn't work, because it's red for the whole life of every PR. An open change sits in `changes/`, unarchived, precisely because it isn't finished. A gate that is red as its resting state is one everyone learns to ignore.

`openspec sync --check` is the check that works. It asks a different question: **does anything that claims to be shipped still have deltas missing from `specs/`?** A change that hasn't made that claim passes for free, so green is the resting state and red means a real mistake.

```yaml
# .github/workflows/specs.yml
- run: npx openspec sync --check
```

The claim is one line in the change's `.openspec.yaml`:

```yaml
schema: spec-driven
status: shipped
```

The everyday shape of it:

1. Open the PR. The change is `proposed` (the default — nothing to write). The gate is green.
2. When the work is done and reviewed, mark it shipped and fold its deltas in one step:
```bash
openspec sync add-rate-limit --ship
```
That sets `status: shipped` and writes the deltas into `specs/` in one command, so both land in the same set of file changes for you to commit together. OpenSpec never runs git itself — commit the result as usual.
3. Merge. Archive whenever you like afterwards — re-applying a delta that's already folded is a no-op, so `openspec archive` behaves exactly as it always did.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The check is a pure function of the files on disk, so the same command works as a pre-commit hook, a pre-push hook, and the CI gate, and all three agree.

`openspec list --status shipped` shows which changes have made the claim but aren't archived yet.

## Two people, parallel changes

Because changes are separate folders, they don't collide:
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/add-standalone-spec-sync/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-07
74 changes: 74 additions & 0 deletions openspec/changes/add-standalone-spec-sync/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Let a change's specs be folded before it is archived

## Why

`archive` does two separable jobs in one command. It folds a change's deltas into
`openspec/specs/`, and it declares the change finished by moving its directory.
Welding them means the fold can only happen at the moment the move happens, which
on a team that reviews before merging is after the pull request closes.

So a team that wants CI to assert "the specs describe what shipped" has nothing to
assert during review. The only property expressible today is "nothing is left
unarchived", and that is violated by design for the entire life of every open PR:
the change sits in `changes/`, unarchived, precisely because it is not finished.
A gate that is red as its resting state is one everyone learns to ignore, and it
masks the real failures underneath (#1683).

The fix is to make the check conditional on the change's own claim — not "is
everything archived?" but "does anything claiming to be shipped still have deltas
missing from the specs?" A proposed change passes for free, so green is the
resting state and red means a real mistake.

## What Changes

- **`openspec sync [change]`** folds delta specs into the main specs without
archiving. The merge engine already supports this: re-applying a folded delta
is a no-op it names the "early-sync pattern", so `archive` afterwards behaves
exactly as it always did.
- **`openspec sync --check`** asserts `shipped ⇒ folded` over the working tree and
exits 1 with the offending changes named. A pure function of files on disk, so
a pre-commit hook, a pre-push hook and CI run one command and agree.
- **`status: proposed | shipped`** becomes an optional field in a change's
`.openspec.yaml`. Absent means `proposed`, which is what a change under
`changes/` has always meant. Nothing writes it: not `new change`, not `archive`.
- **`openspec sync <change> --ship`** sets the field and folds in one working-tree
diff, so no intermediate commit claims a change is shipped while the specs say
otherwise.
- **`openspec list --status <state>`** filters by the field, and renders a
lifecycle column only when some change in the root declares one.

Two deliberate limits, both to keep this additive rather than a second lifecycle:

- **Sync never deletes a spec.** Retiring a capability is the one irreversible
operation in the system; it stays with `archive`, behind the
`retire_capabilities` marker and its rollback-safe deletion. Sync reports the
case and names archive.
- **Sync never examines archived changes.** Their deltas are history and later
changes supersede them; re-applying a months-old delta over everything that
came after is a merge conflict, not a drift check. The checked set is the
active changes declaring `shipped`, which drains itself as they archive.

"Folded" is decided by running the merge builder and seeing that it applied zero
operations — the same predicate `archive` uses to decide it has nothing to write.
Not a byte-comparison of the rebuilt output: the rebuild normalizes blank lines,
so a hand-formatted main spec would compare unequal while being perfectly in
sync. Sharing archive's own predicate is also what stops the checker and the doer
from drifting apart (#1112).

## Impact

- Affected specs: `cli-sync` (ADDED), `cli-list` (MODIFIED: filtering)
- Affected code: `src/core/sync.ts` (new), `src/core/list.ts`,
`src/utils/change-metadata.ts`, `src/core/change-metadata/schema.ts`,
`src/cli/index.ts`, `src/core/completions/command-registry.ts`,
`src/core/archive.ts` (two helpers exported, no behavior change)
- Affected docs: `docs/cli.md`, `docs/team-workflow.md`,
`docs-lab/reference/cli.md`

Credit: the design is Matan Bendix Shenhav's, from #1683 and his implementation
#1684. His: the diagnosis, `shipped ⇒ folded` as a tree predicate (V), the
checker-versus-doer argument (IV), the standalone idempotent `sync` (III), status
as data (I and II), and shipping in one working-tree diff (VI). This change takes
a smaller, additive subset — no mode, no layout change, no migration — and
decides folded-ness by archive's zero-operations predicate rather than his
byte-identical regeneration.
Loading
Loading