Skip to content

Apply and archive ​

cospec apply is the gate you clear before writing code. cospec archive is the verified ship step. Both are deterministic, and both are safe to script against: obey the exit code, and you never have to re-derive what "clear" means by reading files yourself.

Exit codes ​

codemeaning
0success (including "nothing to do")
1failure — validation errors, verification failure, unknown item, parse error, drift/usage error
2blocked (apply only) — missing required artifacts or unchecked hard blockers
3soft-blocked (apply only) — unconfirmed soft blockers; re-run with --allow-soft

There is no code that means "probably fine." If apply doesn't exit 0, stop and resolve what it reported before writing code.

cospec apply <change> [--allow-soft] [--json] ​

In order:

  1. Resolve the change. An unknown slug exits 1 with a fuzzy suggestion.
  2. Validate. Full validation runs in fast mode; errors exit 1 with the report.
  3. Required artifacts. Missing artifacts for the change's type exit 2 with gate.reason: "missing-artifacts". See Types and artifacts for which artifacts each type requires.
  4. The blocker gate. blocking-changes.md is parsed and checked against the archive index (dirs under openspec/changes/archive/ matching YYYY-MM-DD-<slug>):
    • Self-heal first. Any unchecked entry whose slug is already archived gets rewritten to [x] with an *(archived <date>)* note, recorded under gate.synced in the JSON output. You never have to hand-edit a stale checkbox.
    • Remaining unchecked Blocked by entries are hard blockers — exit 2 with gate.reason: "hard-blockers".
    • Remaining unchecked Soft-blocked by entries exit 3 unless you pass --allow-soft, in which case they're recorded under gate.softAcknowledged and apply proceeds.
  5. Delegate. OpenSpec's own instructions apply --json is fetched for context.
  6. Emit and exit 0.
json
{
  "change": "add-widget",
  "type": "feat",
  "gate": {
    "state": "clear",
    "hardBlockers": [],
    "softAcknowledged": [],
    "synced": []
  },
  "apply": {
    "state": "...",
    "contextFiles": [],
    "progress": {},
    "tasks": [],
    "instruction": "...",
    "warnings": [],
    "missingPrerequisites": []
  }
}

apply.contextFiles in the --json output is the file list an agent should load before implementing — proposal, design, specs deltas, whatever the type requires — so you don't have to guess what's relevant.

apply.warnings and apply.missingPrerequisites come from OpenSpec's own instructions apply --json (present since 1.13.0; a second warnings entry for an unread delta file joins them at 1.13.1). missingPrerequisites is relayed verbatim. warnings and instruction are not: OpenSpec writes its remedies as bare openspec … commands, and cospec rewrites each of OpenSpec's own remedy sentences in them to name the cospec equivalent before printing it — every agent-facing OpenSpec access routes through cospec, so a remedy you read is one you can run. Only OpenSpec's exact sentences are rewritten, never a pattern: a change name, a path, or a schema's own instruction text that happens to name an openspec command is relayed as written. Both fields are advisory — neither ever moves cospec apply's exit code, which is fully decided by steps 1–4 above. In practice a cospec-typed change rarely reaches a non-empty apply.warnings: every state OpenSpec warns about there (an unread delta file, a change with no delta specs, a tasks file with zero checkboxes) is already a cospec validate ERROR that stops the run before step 5. The relay is live mainly on the legacy/v1-schema and forked-schema lanes, where cospec's own gate is narrower than OpenSpec's.

When openspec/changes/archive/ can't be read, apply gates as if nothing were archived — a blocker naming an archived change stays open, never the reverse — and its document gains a top-level "warnings": [{ "code": "archive_unreadable", "message": "…" }] (a Warning: line on stderr in text). archive itself still refuses.

What apply prints from the project's config ​

Two project inputs reach an agent that runs apply, both set in openspec/config.yaml and both absent by default:

  • context — the project's free-text context. A required prompt-level input: the apply workflow reads it and follows the facts, conventions and constraints that apply.
  • operations.apply.guidance — a list of strings. Optional, additive advice for the apply workflow.

On a clear gate (exit 0) the --json document carries them under apply as apply.context and apply.operationGuidance, byte for byte. The human transcript prints them after the instruction, under ### Project Context (required instruction input) and ### Operation Guidance (advisory). A project that configures neither sees no new line, so its output is the same as before. If config.yaml declares references: stores, the transcript also prints a ### Referenced Stores section before the instruction, and the --json document carries apply.references; only the command fields in it (a store's fetch recipe and its status fix) are spelled cospec, while the project's own text passes through unchanged.

Neither input changes the exit code or the gate. The apply workflow treats them as prompt-level: they are not evidence that a task is done, they never lift a blocked state, and they are not copied into implementation files. A conflict with the built-in instruction, an explicit user choice or a CLI-controlled value keeps the controlling value and is reported.

--allow-soft only waives soft blockers. Hard blockers have no

override — the change they name has to actually land first. :::

--skip-specs on apply A spec-bearing type can legitimately have no

deltas on a given run — pass --skip-specs on cospec apply as a one-shot equivalent of persisting skip_specs: true in .openspec.yaml (see Configuration). Precedence is the CLI flag first, then the persisted marker, then the structural default that a spec-bearing type must show deltas. :::

cospec archive <change> [--skip-specs] [--force-incomplete] [--no-validate] [--json] ​

Archive is the step that moves a change out of openspec/changes/ and merges its spec deltas into the living specs. Its steps run in a fixed order, and two of them are hard gates with no --force flag:

Pre-flight

  1. Resolve the change and its schema. A namespace folder — a directory wrapping nested changes rather than a change of its own — is refused here with OpenSpec's message (Cannot archive '<name>': …), before anything reads it.
  2. Run full validation — errors exit 1. --no-validate skips this step and is passed on to OpenSpec, which then skips its own validation as well (see below).
  3. Tasks gate. Any unchecked task in tasks.md exits 1 unless you pass --force-incomplete. Note that -y alone does not waive this — an automated caller can't skip real work just by auto-confirming prompts.
  4. Self-blocker sanity check: unchecked hard blockers pointing at this change's own file print a warning, not a failure (aborted or superseded work still needs to be archivable).
  5. Collision pre-check against an existing archive/YYYY-MM-DD-<slug> dir dated today.
  6. Decide whether to pass --skip-specs to OpenSpec — forced by the flag, by the type having no specs artifact, or by the change having no specs/**/spec.md files. The summary's Specs: line names which one: skipped (--skip-specs), none (the <type> schema has no specs artifact) or none (no delta specs, so no spec sync).

The two hard gates, both run before delegation, both exit 1 with no override:

  • archive/verification-incomplete — fires whenever verification is required for this change's type and schema version, regardless of whether the change carries specs. Every row in verification.md must resolve to [x] with a recorded result, or [~] defer: <reason>. There is no --force for this gate — deferring on the record is the escape hatch. See Verification for the row grammar.

  • archive/scenario-preservation — fires only for specs-bearing changes, right before delegating. It re-parses each ## MODIFIED Requirements delta against the current living spec and refuses when the delta no longer covers a living scenario — either because a scenario name is gone (names are compared case-sensitively and counted with multiplicity, so renaming a scenario is a drop plus an add even at an unchanged count) or because the count shrank. A #### header with no body under it is not a scenario on either side of that comparison, so deleting a scenario's steps and leaving its header behind is a drop, and a living spec that carries a bare header does not read as one scenario richer than the delta faithfully reproducing it. The refusal names every dropped scenario. A requirement retired through ## REMOVED Requirements carries no MODIFIED op at all, so this gate never applies to it.

    The Scenario removed: <reason> escape hatch is retired As of

    openspec 1.8.0, any MODIFIED block that omits a living scenario is a validate ERROR and its archive aborts on one — current spec contains scenario(s) not present in the modified block … Aborted. No files were changed., exit 1 — with no special handling for cospec's note, so the note can no longer excuse a scenario drop; it only used to delay the refusal, and below 1.8.0 honoring it silently dropped scenarios, the exact regression this gate exists to prevent. Two remedies actually work: copy the missing scenario back into the MODIFIED block, or — if the requirement really is being retired — REMOVED it in this change and ADDED its replacement in a later one. A REMOVED and an ADDED of one requirement name in the same delta is not a remedy: openspec refuses it with Requirement present in both ADDED and REMOVED. cospec's own gate still fires first, under its own rule id, and stays the sole defence on openspec 1.0.0–1.7.x inside the accepted >=1.0.0 <2.0.0 range — 1.8.0+ runs its own overlapping check, making cospec's gate defence-in-depth from there on. :::

--no-validate skips revalidation only

cospec archive <change> --no-validate skips step 2 and passes --no-validate to OpenSpec's archive, so neither tool revalidates the change — what an openspec archive --no-validate user asked for. Every other step still runs: the namespace-folder refusal, the tasks gate, both hard gates below, the slot check, the on-disk verification and the spot-check. A banner on stderr says so before the first of them, in text and --json mode alike. Under the flag OpenSpec also skips its rebuilt-spec validation and retires no capability, so a REMOVED that empties a spec writes it empty instead of deleting it. cospec never prompts, so there is no confirmation to answer.

Early-synced operations are not blockers

A delta is sometimes written after its spec change already landed in the living baseline — the spec was synced early, and the archive is catching up. OpenSpec treats three such shapes as no-ops and archives them at exit 0, so cospec's archive preconditions do too, rather than blocking an archive the wrapped binary performs cleanly:

ShapeRule that stays silent
ADDED whose block matches the living requirement (CRLF and outer trim folded)archive/added-exists
REMOVED naming a requirement the living spec no longer hasarchive/target-missing
RENAMED whose FROM is gone and whose TO is already presentarchive/target-missing and the living-collision arm of archive/added-exists

Each exemption is withheld when a name that folds equal to the named one — same letters, differing only in case or interior whitespace — but is not it still survives to that operation. That is a mistyped header rather than an early sync, OpenSpec aborts on it, and cospec keeps refusing it with a hint naming the exact header.

Every one of these checks reads the spec as the merge has it when that operation runs — the living spec with the delta's earlier operations already applied, in OpenSpec's own order (RENAMED, REMOVED, MODIFIED, ADDED) — because that is what OpenSpec itself compares against. So a delta collides with itself: two ADDED names that fold onto each other, an ADDED folding onto the delta's own RENAMED target, a second RENAMED target folding onto the first are each an archive/added-exists ERROR on the later operation, for a brand-new capability as much as for a living one. And in the other direction, a name an earlier operation vacated is free — a swap that renames A to B and then C to A archives cleanly.

The exact-name checks read that same spec, not only the fold ones. A MODIFIED, a REMOVED or a RENAMED source naming a header this delta's own RENAMED just created resolves; chained renames (A → B, then B → C) apply; and an ADDED may re-use the exact header a RENAMED vacated, for a genuinely new requirement. Each is a delta OpenSpec archives at exit 0. The matching refusals stay: a target an earlier operation carried away is an archive/target-missing ERROR that says so, and archive/scenario-preservation follows the rename — a MODIFIED block on a renamed header is measured against the scenarios of the rename's source, which is the block OpenSpec compares it to.

One pairing is refused before any of that replay matters: a delta file that ADDs a requirement name it also REMOVEs, or also MODIFYs, is an archive/added-exists ERROR on the ADDED (ADDED "<name>" is also REMOVED in this delta). OpenSpec's validator checks each delta file's own section names before any merge runs, and openspec archive validates first, so it refuses both — re-using the exact header a REMOVED in the same delta vacates included, and an ADDED block identical to the living requirement included when a MODIFIED of that name sits beside it. Retiring a requirement and re-adding it takes two changes. Names compare exactly here: a fold variant (REMOVED Widget rendering beside ADDED WIDGET RENDERING) is a different name to that check, and OpenSpec archives it.

A MODIFIED whose block is identical to the living requirement, and a REMOVED-only delta under retire_capabilities: true on a capability whose spec is already gone, are no-ops too. A change whose every operation is already reflected in the living specs — synced early with cospec sync-specs, or by hand — archives as a no-op merge: OpenSpec reports the specs already in sync, the Specs: line reads already in sync, and both hard gates still run.

On a capability with no living spec at all, ADDED is applied and REMOVED is a no-op OpenSpec warns about (… REMOVED requirement(s) ignored for new spec (nothing to remove)); only MODIFIED and RENAMED are refused there (archive/new-spec-non-added). A delta that only REMOVEs on such a capability, without retire_capabilities: true, is still refused — by archive/rebuilt-spec-invalid, because the spec it would write has no requirement.

Everything else stays an ERROR: an ADDED collision whose body differs, a RENAMED with FROM and TO both absent, a RENAMED applied while both are present, a RENAMED whose TO collides with an ADDED in the same delta — a delta-internal conflict OpenSpec refuses whether or not the rename itself is an early sync — and a MODIFIED whose target is absent.

Execute and verify

  1. Delegate to openspec archive <name> -y [--skip-specs] [--no-validate] and capture its stdout, stderr, and exit code.
  2. Verify on the filesystem — never trust the exit code alone. OpenSpec can print Aborted (or thin a spec's scenarios during merge) and still exit 0. cospec's verifier checks directly: the source change directory is gone, and a dated target directory with its .openspec.yaml exists. Any mismatch — a clean abort, or a half-moved state — exits 1 with an honest message instead of a false success.
  3. For specs-bearing changes, a post-merge spot-check confirms each delta actually landed as expected in the living spec (ADDED present, REMOVED absent, RENAMED correctly, MODIFIED applied). Each operation is judged against what the capability's other operations do to that name, so a name another operation legitimately puts back — the swap above — is not read as a miss. Any real miss exits 1.

Post

  1. Blocker fan-out: sync-blockers runs in fix mode across every remaining active change, checking off any entries that reference the change just archived, and reporting which changes became fully unblocked as a result.
  2. Print the flywheel summary and exit 0:
Archived: add-widget (feat) → openspec/changes/archive/2026-07-03-add-widget/
Specs:    +2 ~1 -0 →0 applied and verified
Warning:  Retiring openspec/specs/widgets/spec.md: all requirements removed.
Retired:  widgets (spec files deleted)
Blockers: checked off in 1 change(s): add-dashboard
Now unblocked: add-dashboard → next: cospec apply add-dashboard

On the success path, cospec archive no longer swallows the wrapped binary's own non-blocking warnings — a Warning: line per relayed warning, and a Retired: line naming any capability whose living spec the merge deleted. Both also appear in --json, as warnings: string[] and retired: string[] — always present, [] when nothing to report. Relayed text is spelled cospec. The JSON document also carries OpenSpec's own archive and root keys, and every refusal answers one document; both shapes are on Command reference.

The project's archive inputs ​

archive reads the same two project inputs as apply, but for the archive step: context, and operations.archive.guidance (see Configuration). Once the change resolves, the text summary prints them after its last line, and a text-mode refusal prints them after the refusal. A successful --json document carries them as top-level context and operationGuidance keys, only when configured; refusal documents carry neither. An unknown operations id, or a guidance value that is not a list of strings, is reported as a warning and ignored.

The archive workflow looks these up before its own checks, with cospec instructions archive --change <slug> --json. The lookup is advisory: a non-zero exit or invalid JSON means the workflow continues with no inputs and reports nothing. Like apply's inputs, they never override a built-in step, a resolved path, a CLI check or a command contract, and they never move an exit code. archive's two hard gates are unchanged.

Bulk archive: one lookup and collision resolution ​

The bulk-archive workflow runs the same lookup once for the whole batch. Its collision handling is a workflow procedure rather than a command, and it runs before anything is archived:

  1. Detect. Two or more selected changes with a delta for the exact same capability path collide, whatever their operations. archive refuses one case of this on its own, an ADDED requirement that the living spec already has (archive/added-exists); the workflow catches every case first.
  2. Resolve. For each collision the workflow reads both deltas, searches the codebase for implementation evidence, and records an include or exclude decision with its reason. Only one implemented: keep that delta. Both: archive in order (dependency first, then creation date), so the change that archives later, the newer one, overwrites. Neither: exclude both and warn.
  3. Edit, and only the delta files. An included newer ADDED that the older change also adds moves to MODIFIED, carrying the whole updated requirement with every scenario. When both changes MODIFIED the same requirement, the newer block carries the older block's scenarios along with its own. An excluded change's colliding requirement blocks come out of its delta, and the file is deleted when nothing is left in it. Main specs are never written, and no --force flag is passed.
  4. Confirm once. One table shows each change, its status and each collision's decision, and the batch is confirmed once. If the user declines, nothing is edited and nothing is archived.
  5. Validate, then archive, in order. Each edited change runs cospec validate <slug> --strict after the changes ahead of it have archived, then cospec archive <slug>. A failure stops only that change.

Capability retirement ​

A REMOVED operation that takes a capability's last requirement can delete its living spec.md outright, rather than leaving an empty ## Requirements section behind — but only when the change's .openspec.yaml declares retire_capabilities: true and openspec can honour it (see Configuration for when it can't). Without an honoured marker, openspec refuses the merge — on a cospec-typed change cospec says so first, at validate time, as archive/rebuilt-spec-invalid; on a legacy change it relays openspec's refusal untouched — change and spec both left exactly as they were. With it, openspec still refuses a spec holding content the merge cannot name, and cospec says so first too, quoting the lines (see Configuration). Otherwise, expect the Retired: line above; if a spec disappears with no marker present, that's an invariant breach, not a legitimate retirement, and cospec reports it as such rather than accepting it quietly.

Why filesystem checks, not exit codes The wrapped openspec binary

is trusted for its output, never for its exit code alone — it can abort or silently thin a spec while still reporting success. Every gate above that touches the filesystem re-checks the actual state on disk before reporting 0.

Blocker sync outside archive ​

cospec sync-blockers [--check] [--change <id>] [--json] runs the same fix logic as archive's final step, standalone. Use --check in CI to fail on drift without mutating anything.

  • Types and artifacts — which artifacts each type requires, and the required-vs-triggered artifact matrix.
  • Verification — the row grammar and layer/owner vocabulary archive/verification-incomplete enforces.
  • The workflow loop — where apply and archive sit in the end-to-end flow.
  • How it relates to OpenSpec — the pinned OpenSpec version cospec wraps and why exit codes from it aren't trusted alone.