diff --git a/openspec/changes/add-deterministic-spec-sync/.openspec.yaml b/openspec/changes/add-deterministic-spec-sync/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-deterministic-spec-sync/design.md b/openspec/changes/add-deterministic-spec-sync/design.md new file mode 100644 index 0000000000..a1b44ab48e --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/design.md @@ -0,0 +1,113 @@ +# Design: the deterministic spec-merge engine — `sync` + the applied-delta baseline + +## Context + +OpenSpec's spec merge (delta → `specs/`) is reached two ways today: deterministically but only *inside* `openspec archive` (fused to the folder move), or by an AI agent in `/opsx:sync` that "directly edits main specs." The standalone deterministic apply (`applySpecs()`, [src/core/specs-apply.ts:391](../../../src/core/specs-apply.ts)) has zero callers. The design discussion (2026-06) worked from "add unarchive" to a single root cause: **the merge must be deterministic, idempotent, and reversible pure code, exposed as commands** — then a code-only CI drift gate, a `sync --fix` pre-commit hook, crumb-free re-merge, and (later) reverse all fall out of one primitive. + +**Scope of this PR (the first slice).** Per review on [#1279](https://github.com/Fission-AI/OpenSpec/pull/1279), the proposal was narrowed to the load-bearing spine so it ships in one reviewable pass: the applied-delta **baseline**, the deterministic **merge engine**, `openspec sync` (+ `--check`/`--fix`), `archive` routed through the engine, and `/opsx:sync` delegating to the CLI. The commands that build on the baseline — `unarchive`, `format`, `diff`, and the unified `check` gate — are specified in the **follow-up** change `add-spec-tooling-suite` (design Decisions 6–10, 14–16 live there). This document keeps the decisions that define and constrain the primitive. + +## Goals / Non-Goals + +**Goals** +- A deterministic merge: same (delta + base) → byte-identical `specs/`, in pure code, on every platform. +- Idempotent: re-running `sync` is a no-op; revising a delta regenerates `specs/` with no crumbs. +- Code-only drift detection (`sync --check`) usable by CI and a pre-commit hook — no model, no API keys. +- Early, deterministic conflict detection (delta-vs-base and cross-change) at commit/PR time, not at archive (Decision 11). +- Per-edit provenance and a delta↔spec correspondence check, within the delta model (Decision 12). +- Hash-optimized incremental checking: `--check` re-checks only specs whose digest changed, without changing any verdict (Decision 13). +- `archive` records the baseline so a later change can reverse it deterministically (the follow-up `unarchive`). +- Skills delegate the merge to the CLI; the agent never performs it. +- Backward compatible with changes archived (and specs synced) before this feature. + +**Non-Goals (here)** +- Removing or replacing the `archive` lifecycle boundary, or editing `specs/` in place instead of via deltas (Decisions 5, 11). +- Folding the spec merge into `apply` (Decision 5). +- The reverse command `unarchive` and its resolution / drift-guard / atomicity / pre-baseline handling — **follow-up** (`add-spec-tooling-suite`). +- The `format` canonicalizer-for-authoring, the spec-aware `diff` driver, and the unified `check` gate + hook/CI wiring — **follow-up**. +- Inference-based "align spec to implementation" / code-vs-spec checking — that needs a model and is the `verify` direction (#880). +- Reducing the committed artifact set (commit-only-the-spec) — a separate product question served by schema flexibility (Decision 13). +- A scenario-granular "smart" merge — the conventions mandate whole-requirement deltas; whole-block apply is the correct, deterministic semantics (Decision 2). + +## Decision 1 — One primitive: the applied-delta baseline + +**Decision.** When a change's deltas are merged into `specs/` (by `sync` or `archive`), record a per-change **applied-delta baseline**: for each affected spec, the **pre-merge file content** (the pre-image, or an explicit `absent` marker when the spec is created), a **digest of the applied result** (newline-normalized CRLF→LF, scheme-tagged), and the **provenance** of each edit (originating change + delta operation). The baseline lives with the change; when archived, it travels into the archive folder. + +**Why one primitive.** The asks in the thread are the same computation viewed several ways: +- **Idempotent re-merge** (`sync` after a delta revision) = `specs_new = apply(delta_new, base)` where `base = current specs with delta_old reversed` (via the pre-image). This is the "no leftover crumbs" guarantee — the prior revision's contribution is removed, not layered over. +- **Drift** (`sync --check`, the hook, CI) = current `specs/` digest ≠ baseline digest. This is the discussion's "track the hash of the changes/ state applied to the spec," made precise. +- **Reverse** (the follow-up `unarchive`) = restore the pre-image. Deterministic for *every* op, including the REMOVED/MODIFIED ones the delta alone cannot invert. This PR **records** the baseline that makes reverse possible; the reverse command itself is the follow-up. + +Building separate mechanisms would invite separate drift bugs. Building one — the pre-image + digest + provenance — and deriving the rest is why this is the spine. + +**Form.** Store the whole pre-image (not a reverse-diff), so a future restore is a byte copy with no re-parsing. Cost is a copy of each affected spec's prior bytes per merge — negligible, and only for changes that touch specs. Coordinate the digest convention with [#1278](https://github.com/Fission-AI/OpenSpec/pull/1278)'s artifact-graph digest ledger so the two drift layers (artifact staleness vs. spec-merge drift) speak the same digest dialect. + +## Decision 2 — Deterministic sync is pure parser code; the agent never merges + +**Decision.** Promote `applySpecs()`/`buildUpdatedSpec` into the canonical merge and expose it as `openspec sync`. The merge is a pure function of (delta files + base spec bytes) producing byte-identical output across runs and platforms (stable requirement ordering, newline normalization, deterministic recomposition). `archive` calls the same function. `/opsx:sync` is rewritten to **invoke the CLI**, not edit specs itself. + +**Why.** This is the thread's keystone verbatim: *"Moving it out of the agent prompt and into plain parser code that always produces byte-identical output for the same delta + base."* The agent path isn't just non-deterministic; its stated reason for existing — "intelligent merging (e.g., adding a scenario without copying the entire requirement)" — is precisely how [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246) silently drops sibling scenarios. The conventions spec already requires deltas to carry "the complete modified requirement, not a diff," so whole-block apply is the *correct* semantics and the agent's cleverness is the bug. Deterministic code is therefore more faithful, not less. + +**Alternatives.** Keep agent sync and only diff its output in CI — rejected: needs a model in CI, the exact thing the thread set out to avoid. + +## Decision 3 — Idempotency and "no crumbs" + +**Decision.** `sync` is idempotent: running it on an already-synced, unchanged change writes nothing (`--check` is clean). When a delta is revised, `sync` reverses the prior revision's contribution via the baseline pre-image, then applies the new delta, then refreshes the baseline — so `specs/` equals `apply(current delta, original base)` with zero residue from the old revision. + +**Why.** Directly answers *"a revised delta should let sync regenerate the spec output from scratch, idempotently, no leftover crumbs."* Without the baseline, re-applying a revised delta against already-merged `specs/` either conflicts (ADDED now exists) or layers (renamed-then-renamed) — crumbs. The baseline makes regeneration a clean reverse-then-apply. + +**Edge.** If `specs/` drifted from the baseline since the last sync (a later change touched the same requirement), `sync` does not silently reverse-then-apply over someone else's edit — it reports drift and requires `--check`-style acknowledgement. Consistency over cleverness. (The follow-up `unarchive` applies the same drift guard on reverse.) + +## Decision 4 — `--check` and `--fix`: the gate and the auto-fixer + +**Decision.** `sync --check` is read-only and exits non-zero when the change's deltas are not cleanly appliable to the base, or — when the team commits merged `specs/` during review — when committed `specs/` ≠ the regenerated output. `sync --fix` (and bare `sync`) regenerates `specs/`. + +- **CI drift gate**: a job runs `openspec sync --check` (fan out over active changes) and fails the PR on drift — "the same pattern as a codegen or IaC drift gate," as a plain binary, no model. +- **pre-commit hook**: `openspec sync --check` detects drift before commit; `openspec sync --fix` is the eslint-`--fix`-style auto-remediation. + +**Relationship to the follow-up `check` gate.** The per-tool `--check`/`--fix` modes shipped here are the primitives. The **follow-up** adds `openspec check`, a single command that composes `sync --check` with `format --check` and `validate` behind one exit-code/JSON contract, plus an opt-in hook installer and a CI template. Until that lands, CI and hooks invoke `openspec sync --check` directly — the verdict is identical, just not yet bundled behind one entrypoint. + +## Decision 5 — Keep the `archive` boundary; reject folding the merge into `apply` + +**Decision.** The lifecycle boundary stays: `specs/` is written at `sync`/`archive`, and `archive` remains the "fold finished change into shipped specs, then move the folder" step. Reject the proposal to remove `archive`, keep every change in a dated archive dir for its whole life, and make `apply` do both code and spec application. + +**Why.** The invariant that pays for OpenSpec's value is **`specs/` describes only shipped reality**. Folding the merge into `apply` would (a) pollute the source of truth with proposed-but-unmerged or abandoned changes, and (b) let parallel changes overwrite each other's specs before any lands. The thread's own resolution: the awkwardness of the "final archive step" is not the boundary's fault, it is the *non-determinism* of crossing it. Make the crossing deterministic and reversible (Decisions 1–3, and the follow-up `unarchive`) and the boundary becomes cheap in both directions — which is the actual fix. This is why determinism, not deletion, is the spine. + +**Consequence for `sync` on active changes.** Because of the invariant, the *default* workflow still merges at archive. Standalone `sync` serves: the engine archive uses; `--check` (read-only, never pollutes `specs/`); and the opt-in "generated-artifact" workflow where a team chooses to commit merged `specs/` mid-review and gate it. The tool enables both policies; it does not force early merge. + +## Decision 11 — Keep deltas; don't edit specs in place — and get early conflict resolution anyway + +**Context.** The discussion pushed a deeper question than "remove archive": *are we simulating deltas above git when git already stores deltas?* The proposed alternative: edit `specs/` in place (the git diff **is** the delta), drop the delta folder, and keep the "why" in a sidecar reasoning log. Its sharpest argument is about **timing**: if in-flight changes are deltas applied at archive, conflicts surface late; editing the spec first "forces conflict resolution immediately." + +**Decision.** Keep the delta layer; do **not** make in-flight edits directly to `specs/`. But **adopt the timing argument's goal** by moving conflict detection earlier with `sync --check` (Decision 12). + +**Why keep deltas.** The delta layer is exactly what a raw git diff cannot give you: a clean separation between **proposed** and **shipped**. `specs/` always describes reality; multiple in-flight changes stay isolated and independently reviewable until each lands. Edit-in-place collapses that — proposed-but-unmerged or abandoned edits sit in the source of truth, and N parallel changes mutate the same files, so the only place conflicts can be resolved is one big merge at the end. The delta model makes each change a self-contained, reviewable unit and is what makes reversing one change (the follow-up `unarchive`) and isolation-preserving parallelism *possible at all*. Git stores byte deltas; OpenSpec's deltas are behavioral agreements one level up — they answer *why* and *what-should-be* before code exists, which a diff cannot. + +**The synthesis — adopt the valid kernel.** Rather than discovering at archive that a delta no longer applies (or that two changes touched the same requirement), `sync --check` surfaces those conflicts at commit/PR time, deterministically, as a plain binary (Decision 12, "Cross-Change Conflict Detection"). So we get "resolve conflicts immediately" **without** sacrificing proposed-vs-shipped isolation. Same shape as Decision 5: adopt the goal, reject the mechanism that would break the invariant. + +## Decision 12 — Provenance and delta↔spec correspondence + +**Context.** The discussion wanted the "why" to travel with the "what": a consistency lint where a spec change with no delta — or a delta with no spec change — fails. + +**Decision.** Record **provenance** as part of the applied-delta baseline: when the engine writes a spec change, it records which change and which delta operation produced it. Expose it (e.g. `openspec sync --explain` / a provenance entry). Use it for a deterministic **delta↔spec correspondence** check in `sync --check`: every committed `specs/` edit for a change must trace to one of its delta operations (no orphan edits), and every delta operation must have landed (no unapplied deltas). The prose "why" is not re-authored — provenance links each spec edit to its change, whose `proposal.md` already holds the rationale. + +**Why this is the right slice.** Provenance falls out of the merge for free (the engine already knows exactly what it applied), and correspondence reuses the baseline. Together they answer the discussion's consistency lint *within the delta model* (deltas are the source; specs are generated) rather than inverting it (specs as source, deltas as sidecar log) — which would reintroduce the edit-in-place problems of Decision 11. + +**Consumed by the follow-up.** The presentation side — a `git diff` driver that follows the provenance link and splices the change's rationale inline — is the follow-up's `openspec diff`. It needs no new data store because it consumes exactly the provenance recorded here. + +## Decision 13 — Incremental checking (hash-optimized) + +**Context.** The discussion asked for "hashes to optimize when checks are needed." + +**Decision.** The applied-delta baseline already carries a per-spec digest. So `sync --check` (and any gate built on it) can skip any spec whose content digest is unchanged since it was last reconciled, and re-check only what changed. The correctness invariant: a skip is allowed *only* on an exact digest match against a recorded baseline; a mismatch, a missing baseline, or an unknown digest scheme forces a full check. So incremental mode is an optimization that can **never change a verdict** versus a full check — important for a gate. This makes the gate cheap enough to run on every commit even in a repo with hundreds of specs. + +**Out of scope (recorded for the "just commit the spec" question).** The wish to commit "only the spec" and treat design/tasks as disposable runs against the reason those artifacts exist: in OpenSpec they *are* the reviewable, resumable product. The valid kernel — that not every change needs the full artifact set — is already served *outside this PR* by schema flexibility (a change can run a minimal schema). This PR neither needs nor changes that. The `format` canonicalizer that the same discussion asked for ("how the spec looks and reads") reuses this PR's canonicalizer and ships in the **follow-up** (`add-spec-tooling-suite`). + +## Risks / trade-offs + +- **Behavior shift for `/opsx:sync` users.** Replacing agent merge with deterministic merge changes output for anyone who relied on the agent's scenario-level merges. This is intended (it fixes [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246)), but it is a real change; call it out in the changeset and docs, and keep the conventions' "complete requirement, not a diff" rule prominent so deltas are authored to merge cleanly. +- **Baseline storage in the change folder.** Adds a small artifact; inert for spec-less changes. Format is scheme-tagged so canonicalization can evolve without silent mis-compares. +- **Baseline is forward-only.** Changes synced/archived before this feature have no baseline; `sync` establishes one on its next run, and the follow-up `unarchive` degrades gracefully for pre-baseline archives (specified there). + +## Migration / rollout + +Additive and phased (see tasks). The engine + baseline land first (no user-visible change to `archive` output). `openspec sync` is a new command in the expanded profile. `/opsx:sync` delegation ships with a changeset noting the determinism shift. The follow-up PR (`add-spec-tooling-suite`) adds `unarchive`, `format`, `diff`, and the unified `check` gate on top of the baseline this PR establishes. diff --git a/openspec/changes/add-deterministic-spec-sync/proposal.md b/openspec/changes/add-deterministic-spec-sync/proposal.md new file mode 100644 index 0000000000..30e7bae61e --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/proposal.md @@ -0,0 +1,90 @@ +## Why + +The spec merge (delta → `specs/`) is **the** load-bearing operation in OpenSpec, yet today it is reachable only two ways — buried *inside* `openspec archive` (fused to the folder move), or performed by an **AI agent** in `/opsx:sync` ("This is an agent-driven operation… you will read delta specs and directly edit main specs"). The agent path is non-deterministic: its "intelligent" scenario-merge is the very mechanism that silently drops scenarios in [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246). The deterministic engine `applySpecs()` ([src/core/specs-apply.ts:391](../../../src/core/specs-apply.ts)) has **zero callers** — it cannot be run on its own. + +So you cannot gate spec drift in CI without a model, you cannot cleanly re-merge a revised delta, and you cannot catch merge conflicts before archive. One missing primitive sits under all of it. This change builds it: a deterministic, idempotent spec-merge **engine** plus a per-change **applied-delta baseline**, exposed as a first-class `openspec sync` command and shared by `archive`. + +## Scope: the first slice (this PR) + +This is deliberately the **spine**, not the whole toolchain. Per review ([@alfred-openspec on #1279](https://github.com/Fission-AI/OpenSpec/pull/1279): *"narrow the first slice to deterministic merge/baseline, sync check/fix, archive using that path, and CLI-delegated /opsx:sync, then split unarchive, format, and diff into follow-ups once the primitive is proven"*), this PR ships exactly that first slice: + +1. the deterministic, idempotent merge **engine** + the **applied-delta baseline**; +2. `openspec sync` with its model-free `--check` gate and `--fix`; +3. `openspec archive` routed through the same engine (records the baseline); +4. `/opsx:sync` delegating to the CLI instead of merging in the agent. + +The commands that **build on** this baseline — `unarchive` (reverse), `format` (canonical), `diff` (spec-aware review), and the unified `check` gate — ship as a **follow-up PR** once the primitive is proven. See "Follow-up" below. + +## The primitive: one applied-delta baseline + +When a change's deltas are merged into `specs/` (by `sync` or `archive`), record a per-change **applied-delta baseline**: for each affected spec, the **pre-merge content** (the pre-image, or an explicit `absent` marker when the spec is created) plus a **digest of the applied result** (newline-normalized, scheme-tagged), and the **provenance** of each edit (which change and delta op produced it). The baseline lives with the change; when archived, it travels into the archive folder. + +One primitive, three payoffs (design Decision 1): idempotent crumb-free re-merge = reverse the old delta via the pre-image, then apply the new; deterministic drift detection = current `specs/` digest ≠ baseline digest; and (in the follow-up) byte-exact reverse for `unarchive`. Building one mechanism and deriving the rest is why this is the spine. + +## What Changes + +Design principle the whole thread converges on, and the one this project is already adopting elsewhere ([#1277](https://github.com/Fission-AI/OpenSpec/pull/1277), prevent-silent-spec-drop): **the merge is pure parser code that produces byte-identical output for the same delta + base; the agent never performs it.** Ordered by leverage: + +1. **THE ENGINE — deterministic, idempotent merge core (`cli-sync` NEW, shared by archive).** Promote the existing `applySpecs()`/`buildUpdatedSpec` into a first-class, **byte-deterministic** apply that records the applied-delta baseline, and make it idempotent (re-running is a no-op; a revised delta regenerates `specs/` with no crumbs). `openspec archive` keeps merging-then-moving but routes its merge through this shared core and writes the baseline before moving, so it travels into the archive. + +2. **FORWARD COMMAND + DRIFT GATE — `openspec sync [change]` (`cli-sync` NEW).** Apply a change's deltas to `specs/` without archiving, deterministically and idempotently: + - default / `--fix`: write `specs/` to the regenerated result (idempotent; a revised delta regenerates with no crumbs); + - `--check`: read-only; exit non-zero if the deltas are not cleanly appliable, or (when `specs/` has been synced) if committed `specs/` ≠ the regenerated output. This is the **codegen/IaC-style drift gate CI runs as a plain binary — no model, no API keys.** + + `--check` also surfaces conflicts **early** — a delta that no longer applies to the current base, or two active changes targeting the same requirement — at commit/PR time instead of at archive (design Decision 11). The engine records **provenance**, enabling a deterministic delta↔spec correspondence check (orphan edit / unapplied delta → fail; design Decision 12). Because the baseline carries a per-spec digest, `--check` is **incremental** — it re-checks only what moved, so the gate stays cheap even in a large repo, and the incremental verdict always equals the full verdict (design Decision 13). + +3. **NO MODEL IN THE MERGE — `/opsx:sync` delegates to the CLI (`specs-sync-skill` MODIFIED).** `/opsx:sync` stops doing agent-driven edits and **invokes `openspec sync`**. The deterministic work lives in TypeScript; the skill only selects, confirms, and renders. This removes the [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246) "intelligent merge drops scenarios" failure mode by construction, and is the direction [#863](https://github.com/Fission-AI/OpenSpec/issues/863)/[#799](https://github.com/Fission-AI/OpenSpec/issues/799)/[#656](https://github.com/Fission-AI/OpenSpec/issues/656) ask for. + +### What this deliberately does *not* change + +The discussion proposed removing `archive` and folding the spec merge into `apply`. **Rejected, and it shapes the design:** `specs/` only ever describes *shipped* reality; folding the merge into `apply` would let proposed-but-unmerged (or abandoned) changes pollute the source of truth and let parallel changes step on each other's specs before any land. So the archive boundary stays; what changes is that crossing it becomes deterministic. The determinism is what makes the boundary cheap — the real fix for the "awkward final step" (design Decision 5). + +The discussion also proposed editing `specs/` in place (the git diff *is* the delta) with a sidecar reasoning log. **Also rejected:** the delta layer is precisely what a raw git diff cannot give — clean isolation of *proposed* from *shipped*, so `specs/` stays canonical while several in-flight changes remain independently reviewable. Its sharpest kernel is valid and adopted: "editing the spec first forces conflict resolution now" → `sync --check` moves conflict detection early (design Decisions 11–12). + +## Capabilities + +### New Capabilities + +- `cli-sync`: the deterministic, idempotent spec-merge engine exposed as `openspec sync [change]` — `--fix`/default writes `specs/` from the change's deltas (byte-identical for the same delta + base; re-running is a no-op; a revised delta regenerates with no crumbs); `--check` is a read-only, model-free gate for CI and pre-commit that also surfaces conflicts early (delta-vs-base, and two active changes on one requirement) and verifies delta↔spec correspondence. Records a per-change applied-delta baseline (pre-image + digest + provenance) that powers idempotent re-merge, drift detection, and tracing each spec edit to its delta. The same engine `archive` uses internally. + +### Modified Capabilities + +- `cli-archive`: routes its spec merge through the shared deterministic engine and records the applied-delta baseline inside the change folder before moving it, so archiving becomes deterministically reversible (unlocking the follow-up `unarchive`). Forward-only and backward-compatible — no change to how archive merges, moves, validates, or what it prints. +- `specs-sync-skill`: `/opsx:sync` delegates the merge to the deterministic `openspec sync` CLI instead of performing agent-driven edits to `specs/`, making the result byte-deterministic and removing the scenario-dropping "intelligent merge" failure mode. The skill handles selection, confirmation, and output only. + +## Follow-up (separate PR) + +Once this primitive is proven, a follow-up PR (`add-spec-tooling-suite`) adds the commands that build on the same baseline and canonicalizer: + +- `openspec unarchive` (+ `/opsx:unarchive`) — the deterministic inverse of archive: byte-exact reverse from the baseline pre-image under a drift guard, atomically. +- `openspec format` — a deterministic, behavior-preserving spec formatter sharing **one canonicalizer** with this engine, so synced/archived specs pass `format --check` by construction. +- `openspec diff` — a spec-aware diff that splices the provenance this PR records + the change's rationale inline; opt-in git diff driver. +- `openspec check` — the unified deterministic linter (`format --check` + `sync --check` + `validate`) plus an opt-in pre-commit hook installer and a CI step — the same binary for both. + +## Impact + +- `src/core/specs-apply.ts` — make `buildUpdatedSpec`/`applySpecs` byte-deterministic and idempotent; add baseline read/write. Forward output unchanged for existing callers. +- `src/core/spec-canonical.ts` (**new**) — extract the deterministic spec/delta canonicalizer (the recomposition `buildUpdatedSpec` already performs at [specs-apply.ts:311-348](../../../src/core/specs-apply.ts)) into one shared module, so the merge engine's output is canonical by construction. Behavior-preserving: `parse(canonicalize(x)) == parse(x)`. (Reused by the follow-up `format`.) +- `src/core/sync.ts` (**new**) — `SyncCommand` (default/`--fix`/`--check`, `--json`), mirroring `ArchiveCommand`'s human + `--json` shape; writes/refreshes the applied-delta baseline; records provenance; incremental `--check`. +- `src/core/archive.ts` — route the merge through the shared engine (already deterministic) and persist the baseline before `moveDirectory` (~414-506). No behavior/output change. +- `src/core/change-metadata/` or a sibling baseline store — persist the applied-delta baseline per change (pre-image + digest, newline-normalized, scheme-tagged). Coordinate the digest convention with [#1278](https://github.com/Fission-AI/OpenSpec/pull/1278)'s digest ledger. +- `src/cli/index.ts` — register `sync [change]` (`--check`, `--fix`, `--all`/`--changes` for CI fan-out, `--json`, `--store`), mirroring archive (326-343). +- `src/core/templates/workflows/sync-specs.ts` — rewrite `/opsx:sync` to invoke `openspec sync` (drop agent-driven edits). `docs/opsx.md` gains a determinism note for `/opsx:sync`. +- Tests — byte-identical sync determinism (same delta+base → same bytes; CRLF/LF; Windows), idempotency (re-run no-op; revised delta no crumbs), `--check` exit codes, provenance + correspondence, incremental verdict == full verdict, skill-template delegation snapshots. Per [openspec/config.yaml](../../config.yaml), run on Windows CI. + +## Issues addressed + +All references verified against `Fission-AI/OpenSpec` at `main` (`546224e`, #1248). Delivers the Discord thread's deterministic-sync conclusion — *"the right fix is a deterministic, code-only openspec sync/archive path that CI can run as a plain binary… no model in CI"* — as `openspec sync` + `--check`. + +Directly fixes / strengthens: + +- [#863](https://github.com/Fission-AI/OpenSpec/issues/863), [#799](https://github.com/Fission-AI/OpenSpec/issues/799), [#656](https://github.com/Fission-AI/OpenSpec/issues/656) — "the sync skill re-implements the merge instead of calling the CLI." `/opsx:sync` becomes CLI-first; the merge is one deterministic code path. +- [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246) — the agent "intelligent merge" silently drops scenarios. Deterministic whole-block apply (per the conventions spec's "complete modified requirement, not a diff") removes the failure mode, and the baseline retains the pre-image #1246 wants for drift detection. +- [#1112](https://github.com/Fission-AI/OpenSpec/issues/1112) — MODIFIED/REMOVED/RENAMED-from headers absent from base pass `validate` but abort at archive. `sync --check` surfaces the same appliability check earlier, as a gate. + +Delineated from adjacent work (coordinate, don't collide): + +- [#1278](https://github.com/Fission-AI/OpenSpec/pull/1278) (sibling) — artifact-graph drift (proposal→design→tasks staleness) via a content-digest ledger. This PR is the *spec-merge* drift layer (delta→`specs/`). Same digest philosophy, different layer; share the newline-normalization convention. +- [add-change-stacking-awareness](../add-change-stacking-awareness/proposal.md) — planning-time archive ordering. `sync`'s idempotency and cross-change `--check` are the runtime spec-layer counterpart; the two compose. + +Out of scope here (moved to the follow-up PR, with reasons in design.md): `unarchive` and its reverse/drift-guard machinery (Decisions 6–10); the `format` canonicalizer-for-authoring (Decision 14); the spec-aware `diff` driver (Decision 15); the unified `check` gate + hook/CI wiring (Decision 16). Inference-based code-vs-spec verification (the `verify` direction, [#880](https://github.com/Fission-AI/OpenSpec/issues/880)) is out of scope for both. diff --git a/openspec/changes/add-deterministic-spec-sync/specs/cli-archive/spec.md b/openspec/changes/add-deterministic-spec-sync/specs/cli-archive/spec.md new file mode 100644 index 0000000000..e21d71d118 --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/specs/cli-archive/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Applied-Delta Baseline Capture + +When the archive operation rewrites main specs, it SHALL record a self-contained applied-delta baseline inside the change folder so that the operation can later be reversed deterministically by `openspec unarchive`. The baseline SHALL be forward-only and SHALL NOT alter how archive merges, moves, validates, or what it outputs. + +#### Scenario: Baseline captured when specs are updated + +- **WHEN** archiving a change applies delta specs to `openspec/specs/` +- **THEN** the command records, for each affected spec, its pre-merge content and its applied-result digest in an applied-delta baseline stored inside the change folder +- **AND** the baseline moves into `openspec/changes/archive/-/` together with the rest of the change + +#### Scenario: Defined storage location and versioned schema + +- **WHEN** the baseline is written +- **THEN** it is stored at a defined path inside the change folder — a versioned manifest `/.openspec/merge-baseline.json` plus, for each affected spec, its captured pre-merge content under `/.openspec/pre-merge//spec.md` +- **AND** the manifest carries a schema version and, per affected spec, an entry of the form `{ "capability": "...", "preImage": "pre-merge//spec.md" | null, "appliedDigest": ":" }` (`preImage: null` marks a spec the archive created) +- **AND** readers that encounter an unrecognized schema version treat the baseline as unavailable rather than misreading it + +#### Scenario: Created specs marked absent + +- **WHEN** archiving creates a new spec that did not previously exist +- **THEN** the baseline records the pre-merge state of that spec as absent +- **AND** unarchive can delete it to restore the pre-archive state + +#### Scenario: No baseline when specs are not updated + +- **WHEN** archiving runs with `--skip-specs`, or the change has no delta specs to apply +- **THEN** no spec content is changed +- **AND** no applied-delta baseline is written + +#### Scenario: Archive behavior and output unchanged + +- **WHEN** capturing the applied-delta baseline +- **THEN** the merge, move, validation, and confirmation behavior of archive are unchanged +- **AND** the command's human-readable and `--json` output are unchanged + +### Requirement: Shared Deterministic Merge Engine + +The archive operation SHALL apply delta specs using the same deterministic merge engine as `openspec sync`, so that archiving and syncing produce identical spec output for the same change. + +#### Scenario: Archive merge matches sync + +- **WHEN** archiving applies a change's deltas to `openspec/specs/` +- **THEN** the resulting spec content is identical to what `openspec sync` produces for the same change +- **AND** the merge is performed in code without AI inference diff --git a/openspec/changes/add-deterministic-spec-sync/specs/cli-sync/spec.md b/openspec/changes/add-deterministic-spec-sync/specs/cli-sync/spec.md new file mode 100644 index 0000000000..d472c4a8d7 --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/specs/cli-sync/spec.md @@ -0,0 +1,224 @@ +## ADDED Requirements + +### Requirement: Sync Command + +The system SHALL provide an `openspec sync [change-name]` command that applies a change's delta specs to `openspec/specs/` deterministically and in pure code, without archiving the change. + +#### Scenario: Apply deltas to main specs + +- **WHEN** the user runs `openspec sync ` for a change that has delta specs +- **THEN** the command applies the change's ADDED/MODIFIED/REMOVED/RENAMED operations to the corresponding main specs +- **AND** it does not move or archive the change folder +- **AND** it reports, per capability, the counts of requirements added, modified, removed, and renamed + +#### Scenario: No delta specs + +- **WHEN** a change has no delta specs to apply +- **THEN** the command reports that there is nothing to sync and makes no changes + +#### Scenario: Interactive selection + +- **WHEN** no change-name is provided in an interactive session +- **THEN** the command lists changes that have delta specs and prompts the user to choose +- **AND** it does not auto-select + +### Requirement: Deterministic Merge + +The sync merge SHALL be a pure function of the change's delta files and the base spec content, producing byte-identical output for the same inputs on every platform, performed in code without any AI inference. + +#### Scenario: Same inputs produce identical output + +- **WHEN** sync is run more than once on the same delta specs and the same base specs +- **THEN** the resulting `openspec/specs/` content is byte-for-byte identical every time + +#### Scenario: Platform independence + +- **WHEN** sync runs on different operating systems with the same inputs +- **THEN** the resulting spec content is identical regardless of line-ending or path-separator differences in the environment + +#### Scenario: No inference + +- **WHEN** sync applies deltas +- **THEN** it computes the result in code +- **AND** it does not call a language model or otherwise depend on non-deterministic input + +### Requirement: Idempotent Regeneration + +Sync SHALL be idempotent: re-running it on an unchanged change makes no further changes, and re-running it after a delta is revised regenerates the affected specs from scratch with no residue from the prior revision. + +#### Scenario: Re-running is a no-op + +- **WHEN** the user runs `openspec sync ` again with no change to the change's deltas or to the affected specs +- **THEN** no spec files are modified +- **AND** a subsequent `openspec sync --check` reports no drift + +#### Scenario: Revised delta leaves no crumbs + +- **WHEN** a change's delta is revised (for example, a requirement that was added is removed, or a modified requirement's text changes) and sync is run again +- **THEN** the affected specs reflect exactly the current delta applied to the original base +- **AND** no requirement or content from the prior revision of the delta remains + +### Requirement: Applied-Delta Baseline + +When sync applies deltas to `openspec/specs/`, it SHALL record a per-change applied-delta baseline capturing, for each affected spec, its pre-merge content (or an absent marker when the spec is created) and a digest of the applied result, so that the merge can later be reversed and drift can be detected. + +#### Scenario: Baseline recorded on apply + +- **WHEN** sync writes changes to `openspec/specs/` +- **THEN** it records the pre-merge content and applied-result digest for each affected spec in the change's baseline + +#### Scenario: Baseline refreshed on re-sync + +- **WHEN** sync re-applies a revised delta +- **THEN** it refreshes the baseline to reflect the new pre-merge state and applied-result digest + +#### Scenario: Defined storage location and versioned schema + +- **WHEN** sync writes the baseline +- **THEN** it uses the same defined, versioned storage as archive — a manifest at `/.openspec/merge-baseline.json` plus captured pre-merge content under `/.openspec/pre-merge//spec.md` +- **AND** an unrecognized schema version is treated as no baseline rather than misread + +### Requirement: Drift Check Mode + +The sync command SHALL support a read-only `--check` mode that verifies spec consistency and exits non-zero on a problem, without modifying any files, so it can gate commits and CI as a plain binary. + +#### Scenario: Deltas not cleanly appliable + +- **WHEN** `openspec sync --check` runs and the change's deltas cannot be cleanly applied to the base specs (for example, a MODIFIED, REMOVED, or RENAMED-from header is absent from the base) +- **THEN** the command reports the problem +- **AND** it exits with a non-zero status code +- **AND** it modifies no files + +#### Scenario: Committed specs drifted from regenerated output + +- **WHEN** `openspec sync --check` runs, the change's specs have been synced, and the committed `openspec/specs/` content differs from the regenerated output +- **THEN** the command reports drift +- **AND** it exits with a non-zero status code +- **AND** it modifies no files + +#### Scenario: Check passes + +- **WHEN** `--check` runs and the specs are consistent with the deltas +- **THEN** the command exits zero and modifies no files + +### Requirement: Fix Mode + +The sync command SHALL support a `--fix` mode (the same as the default write behavior) that regenerates `openspec/specs/` from the change's deltas, suitable for use as an auto-fixer in a pre-commit hook. + +#### Scenario: Fix regenerates specs + +- **WHEN** the user runs `openspec sync --fix` +- **THEN** the command writes `openspec/specs/` to the regenerated result +- **AND** a subsequent `--check` reports no drift + +### Requirement: Shared Engine With Archive + +The deterministic merge used by sync SHALL be the same engine used by `openspec archive`, so that syncing and archiving produce identical spec output for the same change. + +#### Scenario: Archive and sync agree + +- **WHEN** a change's deltas are applied by `openspec sync` and, separately, by `openspec archive` +- **THEN** the resulting `openspec/specs/` content is identical + +### Requirement: JSON Output + +The sync command SHALL support `--json` for non-interactive use, emitting machine-readable results and diagnostics. + +#### Scenario: JSON success + +- **WHEN** `openspec sync --json` succeeds +- **THEN** it emits the per-capability counts and the root context as JSON + +#### Scenario: JSON blocked path + +- **WHEN** `openspec sync --json` cannot proceed (no change, deltas not appliable, conflict, or drift under `--check`) +- **THEN** it emits a machine-readable diagnostic with a stable code +- **AND** exits with a non-zero status code + +### Requirement: Change Provenance + +When sync applies a change's deltas to `openspec/specs/`, it SHALL record, for each resulting spec change, which change and which delta operation produced it, so that any spec content can be traced back to its source delta. + +#### Scenario: Provenance recorded on apply + +- **WHEN** sync applies a delta operation (ADDED/MODIFIED/REMOVED/RENAMED) that changes a spec +- **THEN** it records, with the applied-delta baseline, the originating change name and the delta operation that produced that spec change + +#### Scenario: Explainability + +- **WHEN** the user runs sync with an explain option (for example `openspec sync --explain` or `--json`) +- **THEN** the output associates each affected requirement with the change and delta operation that produced it +- **AND** it does not re-author rationale prose (the change's `proposal.md` remains the source of the "why") + +### Requirement: Delta–Spec Correspondence + +In `--check` mode, sync SHALL verify a two-way correspondence between a change's deltas and the committed `openspec/specs/` content: every spec change attributable to the change traces to one of its delta operations, and every delta operation has landed in the specs. + +#### Scenario: Orphan spec edit detected + +- **WHEN** `--check` finds a change to `openspec/specs/` attributable to the change that does not correspond to any of its delta operations +- **THEN** the command reports the unexplained spec change +- **AND** it exits with a non-zero status code and modifies no files + +#### Scenario: Unapplied delta detected + +- **WHEN** `--check` finds a delta operation that has not been reflected in `openspec/specs/` +- **THEN** the command reports the unapplied delta +- **AND** it exits with a non-zero status code and modifies no files + +#### Scenario: Correspondence holds + +- **WHEN** every spec change traces to a delta operation and every delta operation has landed +- **THEN** the correspondence check passes + +### Requirement: Cross-Change Conflict Detection + +Sync SHALL surface conflicts deterministically and early — at commit or PR time rather than only at archive — including when a change's deltas no longer apply to the current base specs and when two active changes target the same requirement. Cross-change detection requires visibility across active changes, so the command SHALL accept an `--all` flag that operates over every active change rather than a single named one. + +#### Scenario: Delta no longer applies to the current base + +- **WHEN** `--check` runs and a change's MODIFIED, REMOVED, or RENAMED-from header is absent from the current base spec (for example, the base moved since the delta was authored) +- **THEN** the command reports the conflict with the specific requirement +- **AND** it exits with a non-zero status code and modifies no files + +#### Scenario: Check all active changes + +- **WHEN** the user runs `openspec sync --check --all` +- **THEN** the command checks every active change for cross-change conflicts (and per-change appliability) +- **AND** it reports any requirement targeted by more than one active change + +#### Scenario: Two active changes target the same requirement + +- **WHEN** `--check --all` runs and more than one active change modifies, removes, or renames the same requirement +- **THEN** the command reports the overlapping changes and requirement as a potential conflict to resolve before archiving +- **AND** it exits with a non-zero status code + +#### Scenario: No conflicts + +- **WHEN** a change's deltas apply cleanly to the current base and no other active change targets the same requirements +- **THEN** the conflict check passes + +### Requirement: Incremental Checking + +The check operation MAY use the recorded baseline digests to skip specs whose content is unchanged since they were last reconciled, so that checking cost scales with what changed rather than with repository size. A skip SHALL be permitted only when it cannot change the result versus a full check. + +#### Scenario: Unchanged spec is skipped + +- **WHEN** `--check` runs and a spec's current content digest matches the digest recorded in the baseline +- **THEN** the command may skip re-checking that spec +- **AND** the overall result is identical to checking it fully + +#### Scenario: Changed spec is re-checked + +- **WHEN** a spec's current content digest does not match the recorded baseline digest +- **THEN** the command performs the full check for that spec + +#### Scenario: Missing or unknown baseline forces a full check + +- **WHEN** no baseline digest is recorded for a spec, or the recorded digest uses an unrecognized scheme +- **THEN** the command performs the full check for that spec rather than skipping it + +#### Scenario: Incremental result equals full result + +- **WHEN** the same change is checked incrementally and with all skips disabled +- **THEN** both runs reach the same pass or fail verdict diff --git a/openspec/changes/add-deterministic-spec-sync/specs/specs-sync-skill/spec.md b/openspec/changes/add-deterministic-spec-sync/specs/specs-sync-skill/spec.md new file mode 100644 index 0000000000..58f2ba4739 --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/specs/specs-sync-skill/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Specs Sync Skill +The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs by invoking the deterministic `openspec sync` CLI command, rather than editing main specs through agent inference. + +#### Scenario: Sync delta specs to main specs +- **WHEN** agent executes `/opsx:sync` with a change name +- **THEN** the skill invokes `openspec sync ` to apply the change's deltas to `openspec/specs/` +- **AND** it reports the per-capability counts the CLI returns + +#### Scenario: Change selection prompt +- **WHEN** agent executes `/opsx:sync` without specifying a change +- **THEN** the agent prompts user to select from available changes +- **AND** shows changes that have delta specs + +#### Scenario: Idempotent operation +- **WHEN** agent executes `/opsx:sync` multiple times on the same change +- **THEN** the result is the same as running it once +- **AND** no duplicate requirements are created + +### Requirement: Delta Reconciliation Logic +The reconciliation of delta operations into main specs SHALL be performed by the deterministic `openspec sync` engine in code; the skill SHALL NOT add, modify, remove, or rename requirements in `openspec/specs/` by agent inference. + +#### Scenario: Reconciliation delegated to the CLI +- **WHEN** delta operations (ADDED/MODIFIED/REMOVED/RENAMED) must be applied to main specs +- **THEN** the skill relies on `openspec sync` to apply them deterministically +- **AND** the skill does not directly edit files under `openspec/specs/` + +#### Scenario: No scenario-level guessing +- **WHEN** a MODIFIED requirement is applied +- **THEN** the deterministic engine replaces the whole requirement block as written in the delta (per the conventions: a complete requirement, not a diff) +- **AND** the skill does not perform partial, scenario-level merges that could silently drop sibling scenarios + +#### Scenario: Surface engine diagnostics +- **WHEN** `openspec sync` reports that deltas are not cleanly appliable (for example, a MODIFIED/REMOVED/RENAMED-from header is absent from the base) +- **THEN** the skill reports that diagnostic to the user +- **AND** it does not attempt a manual workaround diff --git a/openspec/changes/add-deterministic-spec-sync/tasks.md b/openspec/changes/add-deterministic-spec-sync/tasks.md new file mode 100644 index 0000000000..e360a81e91 --- /dev/null +++ b/openspec/changes/add-deterministic-spec-sync/tasks.md @@ -0,0 +1,52 @@ +# Tasks: the deterministic spec-merge engine — `sync` + the applied-delta baseline + +> Phased so the keystone ships first and each phase is independently testable. Phase 1 (engine + baseline + shared canonicalizer) and Phase 2 (`sync` + `--check`) deliver the deterministic, model-free drift path; Phase 3 (idempotency) and Phase 4 (skill delegation) build on the same baseline. Cross-platform concerns (`path.join`, newline-normalized digests) are called out per [openspec/config.yaml](../../config.yaml). The follow-up change `add-spec-tooling-suite` builds `unarchive`, `format`, `diff`, and the unified `check` gate on the baseline established here. + +## 1. The engine: deterministic, byte-stable merge + applied-delta baseline + +- [ ] 1.1 Make the merge byte-deterministic: audit `buildUpdatedSpec` ([src/core/specs-apply.ts:240-307](../../../src/core/specs-apply.ts)) for any nondeterministic ordering/whitespace; guarantee stable requirement ordering and newline normalization so the same (delta + base) yields byte-identical output on macOS/Linux/Windows. +- [ ] 1.1a Extract the canonicalizer: factor the recomposition/normalization `buildUpdatedSpec` performs ([specs-apply.ts:311-348](../../../src/core/specs-apply.ts)) into a shared `src/core/spec-canonical.ts` used by the merge engine (and, in the follow-up, the formatter), so their output cannot diverge. Cover spec files and delta files (ADDED/MODIFIED/REMOVED/RENAMED sections). Behavior-preserving: assert `parse(canonicalize(x)) == parse(x)`. +- [ ] 1.2 Define the **applied-delta baseline** format (per design Decision 1): per affected spec, the pre-merge content (or `absent` marker), a scheme-tagged, newline-normalized digest of the applied result, and provenance (originating change + delta op). Store it with the change (e.g. `.openspec/merge-baseline/`); document the location. +- [ ] 1.3 Add baseline read/write helpers (safe read-modify-write, preserving unrelated fields), coordinating the digest convention with [#1278](https://github.com/Fission-AI/OpenSpec/pull/1278)'s ledger. +- [ ] 1.4 Tests: same delta+base → byte-identical output across repeated runs and simulated CRLF/LF + Windows paths; baseline round-trips through read/write; canonicalizer is behavior-preserving (`parse(canonicalize(x)) == parse(x)`) for spec and delta files. + +## 2. `openspec sync` + the drift gate (the keystone command) + +- [ ] 2.1 Create `src/core/sync.ts` `SyncCommand` mirroring `ArchiveCommand`'s human + `--json` shape (`resolveOpenSpecRoot`, blocked-error → diagnostic, exit codes); default/`--fix` writes `specs/` from the deltas and refreshes the baseline. +- [ ] 2.2 `--check`: read-only; exit non-zero when deltas are not cleanly appliable to the base, or (when `specs/` was synced) when committed `specs/` ≠ regenerated output. Never writes `specs/`. Support `--all`/`--changes` style fan-out for CI (or document the loop). +- [ ] 2.3 Register `sync [change]` in [src/cli/index.ts](../../../src/cli/index.ts) (`--check`, `--fix`, `--all`, `--json`, `--store`, hidden store-path), mirroring archive (326-343). +- [ ] 2.4 Tests: write produces deterministic `specs/`; `--check` clean vs drifted exit codes; `--check` never mutates; unknown/ambiguous change diagnostics; JSON shape on success and each blocked path. +- [ ] 2.5 Provenance: record, with the baseline, the originating change + delta operation for each applied spec change; add an `--explain` (and JSON) output that maps each affected requirement to its source delta. Do not re-author rationale (link to the change's `proposal.md`). (design Decision 12) +- [ ] 2.6 Delta↔spec correspondence in `--check`: fail on an orphan spec edit (attributable to the change but matching no delta op) and on an unapplied delta (delta op not reflected in `specs/`); pass when both directions hold. (design Decision 12) +- [ ] 2.7 Cross-change conflict detection in `--check` (design Decision 11): (a) delta-vs-base — a MODIFIED/REMOVED/RENAMED-from header absent from the current base (surfaces #1112 early); (b) cross-change — two active changes targeting the same requirement; report specifics, non-zero exit, no writes. Coordinate the cross-change check with [add-change-stacking-awareness](../add-change-stacking-awareness/proposal.md). +- [ ] 2.8 Tests: provenance recorded + `--explain` mapping; orphan-edit and unapplied-delta both fail `--check`; delta-vs-base conflict fails with the right requirement; two active changes on one requirement flagged; clean case passes. +- [ ] 2.9 Incremental checking (design Decision 13): `--check` skips a spec whose current digest matches the recorded baseline; re-checks on mismatch; forces a full check when the baseline is missing or its scheme is unrecognized. Add an escape hatch to disable skips (e.g. `--no-incremental`) for the equivalence test. +- [ ] 2.10 Tests: unchanged spec skipped; changed spec re-checked; missing/unknown-scheme baseline → full check; **incremental verdict == full verdict** on the same input (the correctness invariant); a touched spec in a large fixture is the only one re-checked. + +## 3. Idempotency & "no crumbs" + +- [ ] 3.1 `sync` on an already-synced, unchanged change writes nothing and `--check` is clean (idempotent no-op). +- [ ] 3.2 Revised-delta re-merge: reverse the prior revision via the baseline pre-image, apply the new delta, refresh the baseline — assert `specs/` equals `apply(current delta, original base)` with no residue from the prior revision. +- [ ] 3.3 Drift-on-resync: if `specs/` drifted from the baseline since last sync, do not silently reverse-then-apply over the edit — report drift and require acknowledgement. +- [ ] 3.4 Tests: double-sync no-op; add→sync→revise(add+remove a requirement)→sync yields exactly the new delta's result, no crumbs; drift-on-resync refusal. + +## 4. `archive` integration + skill delegation (no model in the merge) + +- [ ] 4.1 Route `archive`'s spec merge through the shared engine and persist the applied-delta baseline before `moveDirectory` ([src/core/archive.ts](../../../src/core/archive.ts) ~414-506), so the baseline travels into the archive. No behavior/output change; existing archive tests stay green. +- [ ] 4.2 Rewrite `src/core/templates/workflows/sync-specs.ts` so `/opsx:sync` invokes `openspec sync` (drop the "agent-driven… directly edit main specs" instructions); skill does selection/confirmation/output only. +- [ ] 4.3 Tests: archive still produces identical merged `specs/` and output, and now writes a baseline; `/opsx:sync` template snapshot asserts it calls the CLI and contains no manual merge instructions (anti-#863/#1246 guard). +- [ ] 4.4 Changeset + docs note: `/opsx:sync` is now deterministic; the agent no longer performs scenario-level merges (fixes #1246); author deltas as complete requirements per the conventions. + +## 5. Docs + +- [ ] 5.1 `docs/opsx.md` + CLI docs: document `openspec sync` and its flags; update `/opsx:sync` to note CLI delegation + determinism. +- [ ] 5.2 Note the `specs/` = shipped-reality invariant and why `archive` is retained (design Decision 5), so the "why not just remove archive" question has a documented answer. Point to the follow-up `add-spec-tooling-suite` for `unarchive`/`format`/`diff`/`check`. + +## 6. End-to-end verification + +- [ ] 6.1 E2E determinism: scaffold a change with ADDED/MODIFIED/REMOVED/RENAMED; `openspec sync` twice → byte-identical `specs/`; `sync --check` clean. +- [ ] 6.2 E2E no-crumbs: sync, revise the delta, re-sync → `specs/` reflects only the current delta. +- [ ] 6.3 E2E drift gate: introduce an un-synced delta → `openspec sync --check` fails naming the drift → `sync --fix` remediates → `--check` passes; then a delta-vs-base conflict still fails (not auto-fixable). +- [ ] 6.4 E2E archive baseline: `archive` a change → merged `specs/` unchanged from today's output, and a baseline is recorded in the archived folder (the artifact the follow-up `unarchive` reverses from). +- [ ] 6.5 Validation: `openspec validate add-deterministic-spec-sync --strict` passes; `openspec status` shows artifacts complete. +- [ ] 6.6 Run the suite on macOS, Linux, and Windows CI.