From 52a0762181931368fdd9e8b3b5ac5e83d8cb42db Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 10:14:20 -0500 Subject: [PATCH] docs(openspec): propose add-spec-tooling-suite (follow-up to #1279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up @alfred-openspec asked for: split unarchive, format, diff, and the unified check gate out of the deterministic-sync spine (#1279) into their own change, to land once the merge engine + applied-delta baseline is proven. Adds five capabilities, all built on #1279's baseline (pre-image + digest + provenance) and shared canonicalizer — no new merge semantics: - cli-unarchive + opsx-unarchive-skill: deterministic inverse of archive (byte-exact reverse from the baseline, drift-guarded, atomic). - cli-format: behavior-preserving formatter sharing #1279's canonicalizer. - cli-diff: spec-aware diff splicing provenance + rationale inline. - cli-check: unified format/sync/validate gate, same binary for pre-commit and CI, with an opt-in hook installer + CI template. Design Decisions 6-10 (unarchive) and 14-16 (format/diff/check); the model- shaping rejections (keep archive; keep deltas) stay in #1279 Decisions 5/11/13. Prerequisite: #1279. Validates clean under `openspec validate add-spec-tooling-suite --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../add-spec-tooling-suite/.openspec.yaml | 2 + .../changes/add-spec-tooling-suite/design.md | 97 +++++++++ .../add-spec-tooling-suite/proposal.md | 74 +++++++ .../specs/cli-check/spec.md | 109 ++++++++++ .../specs/cli-diff/spec.md | 86 ++++++++ .../specs/cli-format/spec.md | 144 +++++++++++++ .../specs/cli-unarchive/spec.md | 190 ++++++++++++++++++ .../specs/opsx-unarchive-skill/spec.md | 56 ++++++ .../changes/add-spec-tooling-suite/tasks.md | 64 ++++++ 9 files changed, 822 insertions(+) create mode 100644 openspec/changes/add-spec-tooling-suite/.openspec.yaml create mode 100644 openspec/changes/add-spec-tooling-suite/design.md create mode 100644 openspec/changes/add-spec-tooling-suite/proposal.md create mode 100644 openspec/changes/add-spec-tooling-suite/specs/cli-check/spec.md create mode 100644 openspec/changes/add-spec-tooling-suite/specs/cli-diff/spec.md create mode 100644 openspec/changes/add-spec-tooling-suite/specs/cli-format/spec.md create mode 100644 openspec/changes/add-spec-tooling-suite/specs/cli-unarchive/spec.md create mode 100644 openspec/changes/add-spec-tooling-suite/specs/opsx-unarchive-skill/spec.md create mode 100644 openspec/changes/add-spec-tooling-suite/tasks.md diff --git a/openspec/changes/add-spec-tooling-suite/.openspec.yaml b/openspec/changes/add-spec-tooling-suite/.openspec.yaml new file mode 100644 index 0000000000..e7cc357ca0 --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-01 diff --git a/openspec/changes/add-spec-tooling-suite/design.md b/openspec/changes/add-spec-tooling-suite/design.md new file mode 100644 index 0000000000..527fee02dd --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/design.md @@ -0,0 +1,97 @@ +# Design: spec tooling on the merge baseline — `unarchive`, `format`, `diff`, `check` + +## Context + +The first-slice PR [add-deterministic-spec-sync (#1279)](https://github.com/Fission-AI/OpenSpec/pull/1279) builds the primitive: a deterministic, idempotent merge engine, a per-change **applied-delta baseline** (pre-image + applied-result digest + per-edit provenance), a shared canonicalizer (`src/core/spec-canonical.ts`), and `openspec sync --check`. This change builds the four commands that consume that primitive. It adds **no new merge semantics** — every decision here spends a capability #1279 already established. + +Decision numbers continue #1279's (which kept 1–5, 11–13); this document holds **6–10** (unarchive) and **14–16** (format, diff, check). The rejected-alternatives that shape the model (keep `archive`; keep deltas; don't invert specs-as-source) live in #1279's Decisions 5, 11, 13 and are not repeated. + +## Goals / Non-Goals + +**Goals** +- Reversible: `unarchive` restores `specs/` exactly and moves the folder back, atomically, under a drift guard (Decisions 6–9). +- A deterministic, behavior-preserving spec formatter (`openspec format` + `--check`) sharing one canonicalizer with the merge engine (Decision 14). +- A deterministic spec-aware diff (`openspec diff`, opt-in git diff driver) that splices provenance + rationale inline, reusing existing artifacts (Decision 15). +- A unified deterministic linter `openspec check` (+ `--fix`, `--all`, incremental) wired to run identically as a pre-commit hook and a CI gate, with an opt-in hook installer and a CI template (Decision 16). +- Skills (`/opsx:unarchive`) delegate to the CLI; the agent never performs deterministic work. +- Backward compatible with changes archived before the baseline existed (Decision 9). + +**Non-Goals** +- Re-deriving the merge engine, baseline, or canonicalizer — those are #1279. +- Inference-based "align spec to implementation" / code-vs-spec checking — needs a model; the `verify` direction (#880), out of scope (Decisions 14, 16). +- Wiring a *specific* hook runner or enabling the gate by default — owner policy (Decision 16); the command + opt-in installer + CI template are built. +- Bulk `unarchive`, an archive-folder prefix scheme, lifecycle timestamps — accommodated, not built (Decisions 6, 10). +- Semantic/AI summarization in the diff — `diff` mechanically splices recorded reasoning (Decision 15). + +## Decision 6 — `unarchive`: resolution, prefix-tolerance, never auto-pick + +**Decision.** `unarchive [change-name]` accepts a bare `` or a full archived directory id, treating the leading prefix as **opaque up to ``** — strips `YYYY-MM-DD-` today, tolerates `NNN-`/ISO/configurable ([#409](https://github.com/Fission-AI/OpenSpec/issues/409)/[#787](https://github.com/Fission-AI/OpenSpec/pull/787)/[#1192](https://github.com/Fission-AI/OpenSpec/issues/1192)). Multiple matches for a bare name → interactive prompt (most-recent first, never auto-select); `--json`/non-interactive → error listing candidates, require the full id. Reuse `getArchivedChangesData()` ([#399](https://github.com/Fission-AI/OpenSpec/pull/399)). + +**Why.** Multiple archives of one name already happen (different dates), and a sequence scheme makes it routine. Silent selection is a guess on the rare, costly path. + +## Decision 7 — Reverse under a drift guard, atomically + +**Decision.** Before reversing any spec, compare its current content to the baseline's applied-result digest. On drift (a later change touched the same requirement — [#1246](https://github.com/Fission-AI/OpenSpec/issues/1246), [add-change-stacking-awareness](../add-change-stacking-awareness/proposal.md)), **refuse** and point to `--keep-specs`. The reversal runs as a defined **validate → stage → commit** sequence: (1) validate destination-absent + all affected specs present and drift-free; (2) compute reversed specs into a temporary staging area under `.openspec/` (no change to `specs/` yet); (3) swap staged specs into `specs/`; (4) move the folder. A failure in steps 1–2 leaves everything untouched; a failure of step 4 after the swap rolls `specs/` back from the still-present baseline. On any failure: *"Abort. No files were changed."*; if rollback itself cannot complete, report the partial state and exact recovery steps rather than leave a silent inconsistency. + +**Why.** Restoring a pre-image over a requirement a later change modified would silently delete that change's contribution — the loss OpenSpec guards against. Refusing is strictly safer, and `--keep-specs` always lets the user proceed. Atomicity gives archive the rollback [#682](https://github.com/Fission-AI/OpenSpec/issues/682) noted it lacks, and matches the abort contract [#1112](https://github.com/Fission-AI/OpenSpec/issues/1112) describes. + +## Decision 8 — `--keep-specs` and naming vs. `--skip-specs` + +**Decision.** `--keep-specs` moves the folder back and leaves `specs/` untouched — always deterministic, always safe. Primary spelling `--keep-specs` (reads naturally for the inverse). Accept `--skip-specs` as a hidden alias for muscle-memory symmetry with archive ([#28](https://github.com/Fission-AI/OpenSpec/pull/28)). When `unarchive` cannot safely reverse specs (drift, or pre-baseline REMOVED/MODIFIED) and `--keep-specs` was not given, it refuses and *recommends* `--keep-specs` — it does not silently fall back. **Open for owner confirmation:** if strict symmetry is preferred, swap which spelling is primary. + +## Decision 9 — Backward compatibility: pre-baseline archives + +**Decision.** Changes archived before baselines existed have none. Pre-baseline reversal is **all-or-nothing**: if every delta operation across all affected specs is ADDED/RENAMED (self-invertible), `unarchive` reverses the whole change by delta inversion; if any MODIFIED/REMOVED is present anywhere (pre-image unrecoverable), it **refuses the entire spec reversal** — naming the requirements it cannot restore and directing to `--keep-specs` (optionally offering opt-in git recovery of the pre-image). It does **not** partially reverse the self-invertible specs and leave the rest, because a partial `specs/` is exactly the corruption the atomic guarantee (Decision 7) exists to prevent. A later `openspec sync` (from #1279) establishes a baseline on its next run. Never silently emit a wrong spec. + +**Why.** The feature must be useful on day one, including for already-archived changes, without ever degrading into corruption. Reverse what is provably reversible; stop honestly on the rest. + +## Decision 10 — Move strategy and lifecycle metadata + +- **Move**: reuse `moveDirectory()`/`copyDirRecursive()` ([src/core/archive.ts:96-128](../../../src/core/archive.ts)) and their Windows fallbacks ([#605](https://github.com/Fission-AI/OpenSpec/pull/605)). A future `git mv` ([#709](https://github.com/Fission-AI/OpenSpec/issues/709)) covers both directions through this one helper. +- **Metadata**: archive persists no `archived` timestamp today (only the folder-name prefix). If [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) lands one, `unarchive` should null it on restore — noted, not built. + +## Decision 14 — The deterministic spec formatter (`openspec format`) + +**Context.** The discussion wanted the framework's job to include "how the spec looks and reads and is organized/formatted" — a linter/formatter, hash-optimized. The merge engine (#1279) *already* computes a canonical form for every spec it writes. A standalone formatter is that same canonicalizer pointed at authoring input. + +**Decision.** Add `openspec format [target]` — a deterministic, pure-code formatter for spec and delta files — with `--check` (read-only gate) and `--fix`/default (write). It shares the one canonicalizer with `sync`/`archive` (`src/core/spec-canonical.ts` from #1279), so **the merge engine's output is, by construction, exactly what the formatter produces**: synced/archived specs always pass `format --check`. It is incremental (the baseline's digest skip) and `--json`-capable, and it joins `sync --check` as a model-free gate. + +**Behavior-preserving — the hard line.** The formatter changes only *presentation*: whitespace, blank-line policy, list markers/indentation, heading spacing, canonical delta-section headers. It MUST NOT reorder requirements or scenarios (order can carry meaning), rewrite prose, or add/remove/merge/split requirements. The invariant is testable: parse-before == parse-after (same requirements, scenarios, and delta operations); only surrounding whitespace may differ. This is what separates a *formatter* (safe, deterministic, automatic) from a *rewrite* (semantic, requires review). + +**What it is NOT.** Not `validate` (semantic validity) — they compose (`format` then `validate`). Not the inference-based "align spec to implementation" — that needs a model and is the `verify` direction ([#880](https://github.com/Fission-AI/OpenSpec/issues/880)), out of scope. `format` is pure text canonicalization, so it needs no agent and no `/opsx:format` skill. + +**Why share one canonicalizer.** One canonicalizer specified and tested once, reused by `sync`, `archive`, and `format`, guarantees they cannot diverge. A separate formatter implementation would risk disagreeing with the merge engine's output — the exact drift this toolchain exists to kill. This is why `format` belongs adjacent to the engine, not as an independent tool. + +## Decision 15 — The spec-aware diff driver (`openspec diff`) + +**Context.** The discussion's review workflow: "review primarily becomes reading the spec deltas (in git)" with "a spec-aware diff driver that splices the reasoning log inline so reviewers see what changed and why together," and a related idea of a sidecar database of "semantic deltas as log entries." + +**Decision.** Add `openspec diff [target] [--base ]` — a deterministic, pure-code renderer that shows requirement-level spec changes with each change's provenance and rationale spliced inline. Usable standalone and, opt-in, as a **git diff driver** (registered in `.gitattributes`) so `git diff` over spec/delta paths renders the annotated view. + +**Key insight — OpenSpec already has the "log"; no new store.** The discussion reached for a separate database of semantic deltas plus a reasoning log. But OpenSpec already keeps both halves: the *why* lives in the change's `proposal.md`, and the *what/where* lives in the applied-delta provenance #1279 records. The diff driver simply joins them. So we explicitly **reject building a sidecar reasoning database** and instead render from artifacts that already exist — less to maintain, nothing to keep in sync. + +**Deterministic and honest.** Rendering is a pure function of the git diff + recorded provenance + proposal text — byte-identical across runs and platforms, no inference. When a change cannot be attributed (a pre-baseline edit with no provenance), the diff says so rather than inventing a rationale. It does **not** judge review quality or summarize semantically (that would need a model); it mechanically splices recorded reasoning. Git config is never modified without explicit opt-in. + +## Decision 16 — `openspec check`: the unified deterministic linter for pre-commit *and* CI + +**Context.** The discussion's recurring ask: the sync/format gate should run "like a linter," both as a pre-commit hook (eslint-`--fix` style) and as a CI drift gate, with no model in CI. Pre-commit *or* CI? **Both — it is the same binary invoked in two places.** A drift gate is a pure function of the committed files; *where* it runs changes nothing about the verdict. + +**Decision.** Add `openspec check` — one command that runs the deterministic gates (`format --check`, `sync --check`, `validate`) and exits non-zero on any failure, with `--fix` for auto-remediation, `--all` for cross-change checks, incremental digest skipping, and `--json`. Ship the two integration points as well: a **runner-agnostic, opt-in hook installer** and a **copy-paste CI step**. This is the single entrypoint pre-commit and CI both call (so neither has to know the individual sub-checks). + +**Why one command, not three.** A hook or CI job shouldn't hard-code `format --check && sync --check && validate` and drift out of step with the toolchain. `openspec check` is the stable contract; the sub-gates can evolve behind it. It also gives one exit-code/`--json` shape for both contexts. + +**Pre-commit vs CI — same gate, complementary placement.** Pre-commit catches drift at authoring time and can `--fix` in place before the commit (fast feedback, fewer red CI runs). CI is the backstop that enforces the gate regardless of local setup (someone without the hook, or `--no-verify`). Because the verdict is identical and deterministic, the two never disagree. Recommended posture: hook runs `--fix` (convenience), CI runs `--check` (enforcement). + +**Honesty under `--fix`.** `--fix` only applies *mechanical* remediation (format + sync regeneration). Non-mechanical failures — a delta that doesn't cleanly apply, a cross-change conflict — are reported and still fail; `--fix` never invents a resolution or masks a real conflict. + +**Scope note.** The one thing left to the owner is policy: which runner to standardize and whether the gate is on by default. The inference-based "align spec to implementation" remains out (it needs a model — the `verify` direction, #880). + +## Risks / trade-offs + +- **Forward round-trip is not guaranteed identity.** `unarchive` restores the exact pre-archive `specs/`; a *subsequent* re-archive re-runs the forward merge, which inherits archive's existing cross-change caveats ([#1246](https://github.com/Fission-AI/OpenSpec/issues/1246)). Documented, not solved here. +- **Depends on #1279's baseline shape.** If the baseline format changes during #1279 review, `unarchive`/`format`/`diff` follow it. The scheme tag makes evolution safe (unknown scheme → treated as no baseline, degrade per Decision 9). +- **Sequencing.** This PR should merge after #1279. Until then it is reviewable but not independently buildable (its commands call #1279's engine/baseline). + +## Migration / rollout + +Additive. `unarchive`, `format`, `diff`, and `check` are new commands in the expanded profile. `/opsx:unarchive` ships in the expanded profile (not core). The `openspec check` gate, opt-in hook installer, and CI template ship here; enabling the gate by default is the owner's policy call. Pre-baseline archives degrade per Decision 9. diff --git a/openspec/changes/add-spec-tooling-suite/proposal.md b/openspec/changes/add-spec-tooling-suite/proposal.md new file mode 100644 index 0000000000..2fe8f858b9 --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/proposal.md @@ -0,0 +1,74 @@ +## Why + +The deterministic spec-merge **engine** and per-change **applied-delta baseline** land in the first-slice PR ([add-deterministic-spec-sync, #1279](https://github.com/Fission-AI/OpenSpec/pull/1279)): a byte-deterministic `openspec sync` that records, for every merged change, the pre-image, an applied-result digest, and per-edit provenance. That primitive was deliberately shipped alone so it could be reviewed and proven on its own. + +This change spends that primitive. Four commands the design discussion asked for all fall out of the same baseline + canonicalizer, and none of them needs new merge machinery: + +- **Reverse.** The baseline's pre-image makes archiving deterministically invertible — `openspec unarchive` restores `specs/` byte-exact and moves the folder back, atomically. Without it there is no undo for archive ([#682](https://github.com/Fission-AI/OpenSpec/issues/682)), and the manual `git` fix fails when the archive is buried in a multi-file commit. +- **Canonical form.** The engine already computes a canonical layout for every spec it writes. Pointed at authoring input, that same canonicalizer is `openspec format` — so synced/archived specs pass `format --check` *by construction*. +- **Review.** The provenance the baseline records is exactly what a spec-aware diff needs: `openspec diff` splices *what changed* (the delta) and *why* (the change's `proposal.md`) inline, with no new reasoning store. +- **One gate.** `openspec check` composes `format --check` + `sync --check` + `validate` behind a single verdict, with an opt-in pre-commit hook installer and a CI step — the same binary in both places. + +## Prerequisite + +Depends on **[#1279](https://github.com/Fission-AI/OpenSpec/pull/1279)** (`add-deterministic-spec-sync`): the merge engine, the applied-delta baseline (pre-image + digest + provenance), the shared `src/core/spec-canonical.ts` canonicalizer, and `openspec sync --check`. This change adds no new merge semantics; it builds commands on top. Merge #1279 first. + +## What Changes + +Ordered by leverage; every item reuses a primitive #1279 establishes. + +1. **REVERSE — `openspec unarchive [change-name]` (`cli-unarchive` NEW; `opsx-unarchive-skill` NEW).** The deterministic inverse of archive: resolve the archived folder (prefix-tolerant, never auto-picking among ambiguous matches), reverse the spec merge from the baseline under a **drift guard** (refuse rather than clobber a requirement a later change has since touched), move the folder back to `changes//`, **atomically** ("Abort. No files were changed."). `--keep-specs` restores the folder without touching `specs/` (the always-safe escape hatch, mirror of archive's `--skip-specs`). Changes archived before the baseline existed degrade gracefully — reverse the self-invertible half (ADDED/RENAMED), refuse to guess the rest, all-or-nothing. `/opsx:unarchive` delegates entirely to the CLI. (design Decisions 6–10.) + +2. **CANONICAL — `openspec format [target]` (`cli-format` NEW).** A deterministic, **behavior-preserving** formatter for spec and delta files, with a `--check` gate, sharing **one canonicalizer** with the merge engine (`src/core/spec-canonical.ts` from #1279). The engine's output is, by construction, exactly what the formatter produces: synced/archived specs always pass `format --check`. Behavior-preserving means it normalizes only presentation (whitespace, blank-line policy, list markers, heading spacing, canonical delta-section headers); it never reorders requirements/scenarios or rewrites prose — `parse-before == parse-after`. Incremental via the baseline digest; `--json`-capable; pure text, so no `/opsx:format` skill. It is *not* `validate` (semantic) and *not* code-vs-spec `verify` (inference). (design Decision 14.) + +3. **REVIEW — `openspec diff [target] [--base ]` (`cli-diff` NEW).** A deterministic, pure-code renderer that shows requirement-level spec changes with each change's **provenance and rationale spliced inline** (what changed and why, together), usable standalone or, opt-in, as a **git diff driver** via `.gitattributes`. The key move: OpenSpec **already has the "log"** — the *why* is in `proposal.md`, the *what/where* is the applied-delta provenance #1279 records — so `diff` joins existing artifacts rather than building a sidecar reasoning database. No inference; honest about unattributable (pre-baseline) changes; never modifies git config without opt-in. (design Decision 15.) + +4. **ONE GATE — `openspec check [--fix] [--all]` (`cli-check` NEW).** The unified deterministic linter: runs `format --check`, `sync --check`, and `validate` in one invocation, exiting non-zero on any failure. **The same binary for pre-commit and CI** — a drift gate is a pure function of the committed files, so *where* it runs never changes the verdict. Ships a runner-agnostic, opt-in **hook installer** and a copy-paste **CI step** — no model, no API keys. `--fix` does mechanical remediation only (`format --fix` + `sync --fix`); non-mechanical failures (an un-appliable delta, a cross-change conflict) are reported and still fail. (design Decision 16.) + +## Capabilities + +### New Capabilities + +- `cli-unarchive`: `openspec unarchive [change-name]` — the deterministic inverse of archive. Resolves an archived change (prefix-tolerant, never auto-picking ambiguous matches), reverses the spec merge from the baseline under a drift guard, moves the folder back, atomically. `--keep-specs` restores the folder without touching `specs/`; pre-baseline archives degrade gracefully (all-or-nothing). +- `opsx-unarchive-skill`: a `/opsx:unarchive` workflow skill that delegates to `openspec unarchive` for all deterministic work (selection, confirmation, rendering only). Expanded profile; no cross-skill dependency. +- `cli-format`: `openspec format [target]` — a deterministic, behavior-preserving spec/delta formatter sharing one canonicalizer with the merge engine, so synced/archived specs are canonical by construction. `--check` is a read-only, model-free gate that names unformatted files; `--fix`/default writes canonical form; incremental via digests; `--json`-capable. Pure text — no agent, no `/opsx:format` skill. +- `cli-diff`: `openspec diff [target] [--base ]` — a deterministic, spec-aware diff that splices each changed requirement's provenance and rationale inline (what changed and why, together), usable standalone or as an opt-in git diff driver. Renders from the change's `proposal.md` + the recorded applied-delta provenance — no new reasoning store; no inference; `--json`-capable. +- `cli-check`: `openspec check [--fix] [--all]` — the unified deterministic linter that runs `format --check`, `sync --check`, and `validate` in one invocation, exiting non-zero on any failure. The **same binary for pre-commit and CI** (identical verdict in both); `--fix` applies mechanical auto-remediation (never invents resolutions); incremental via digests; `--json`. Ships with a runner-agnostic opt-in hook installer and a copy-paste CI step — no model, no API keys. + +## Impact + +Builds on #1279's `src/core/specs-apply.ts` (baseline + inverse), `src/core/spec-canonical.ts` (shared canonicalizer), and the recorded provenance. + +- `src/core/unarchive.ts` (**new**) — `UnarchiveCommand`: resolve archived dir, drift-check, restore pre-images (or delta-invert / refuse for pre-baseline), move folder back, atomic abort. Reuse `moveDirectory()`/`copyDirRecursive()` ([src/core/archive.ts:96-128](../../../src/core/archive.ts)); add the inverse merge (delta-inversion for ADDED/RENAMED; pre-image restore for all ops) to `specs-apply.ts`. +- `src/core/format.ts` (**new**) — `FormatCommand` (default/`--fix`/`--check`, `--json`, incremental): runs the shared canonicalizer over main specs and active-change delta files; `--check` lists non-canonical files and exits non-zero without writing. +- `src/core/diff.ts` (**new**) — `DiffCommand` (`--base`, `--json`): renders requirement-level spec/delta changes annotated with provenance (from the baseline) and rationale (from the originating change's `proposal.md`); pure code; also invocable as a git diff driver. Plus a documented `.gitattributes` snippet for opt-in registration. +- `src/core/check.ts` (**new**) — `CheckCommand` (`--fix`, `--all`, `--json`, incremental): composes `format --check`, `sync --check`, and `validate` into one gate with a single exit-code/JSON contract; `--fix` runs `format --fix` + `sync --fix`. Plus an opt-in hook installer (composes with existing hooks, never auto-installs) and a committed CI workflow template that runs `openspec check`. +- `src/core/list.ts` / `src/core/view.ts` — reuse `getArchivedChangesData()` ([#399](https://github.com/Fission-AI/OpenSpec/pull/399)) to resolve/disambiguate archived candidates. +- `src/cli/index.ts` — register `unarchive [change-name]` (`--keep-specs`/`--skip-specs` alias, `-y/--yes`, `--no-validate`, `--json`, `--store`), `format [target]` (`--check`, `--fix`, `--json`), `diff [target]` (`--base`, `--json`), and `check` (`--fix`, `--all`, `--install-hook`, `--json`), mirroring archive. +- `src/core/templates/workflows/unarchive-change.ts` (**new**) — `/opsx:unarchive` delegating to the CLI. Registration: export from `skill-templates.ts`; add `unarchive` to `ALL_WORKFLOWS` ([src/core/profiles.ts:19](../../../src/core/profiles.ts)) and `WORKFLOW_TO_SKILL_DIR` ([src/core/init.ts:65](../../../src/core/init.ts)); **not** to `CORE_WORKFLOWS`. `docs/opsx.md` gains a `/opsx:unarchive` row. +- Tests — archive→unarchive byte-exact round-trip, drift refusal, `--keep-specs`, destination-collision abort, pre-baseline all-or-nothing degradation, atomic "no files changed"; formatter determinism + idempotency + `parse-before==parse-after` + `sync`/`archive` output passes `format --check`; diff determinism + provenance/rationale annotation + honest "unattributable" handling + opt-in-only git config; `check` aggregates the sub-gates, `--fix` remediates mechanical drift but still fails on conflicts, hook-vs-CI verdict parity. 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 remaining Discord conclusions (beyond #1279's `sync`): + +- **`openspec unarchive` / `/opsx:unarchive`** that "moves the folder back and reverses the spec merge," including the hard case where the archive is buried in a multi-file commit. +- **Deterministic spec formatter/linter** *("the framework's goal is how the spec looks and reads and is organized")* → `openspec format` + `--check`, sharing the engine's canonicalizer. +- **Spec-aware diff with reasoning inline** *("review primarily becomes reading the spec deltas" + "a spec-aware diff driver that splices the reasoning log inline")* → `openspec diff`, rendering from existing proposal + provenance. +- **The pre-commit / CI gate** *("sync is a lint step… run within CI/tooling much like linters" / eslint-`--fix`)* → `openspec check`, the same binary for both, with an opt-in hook installer and a CI template. + +Directly fixes / strengthens: + +- [#682](https://github.com/Fission-AI/OpenSpec/issues/682) — archive "is not transactional… there's no rollback." `unarchive` is that rollback, itself atomic. +- [#863](https://github.com/Fission-AI/OpenSpec/issues/863) — the archive/sync skill re-implements deterministic work instead of calling the CLI. `/opsx:unarchive` is CLI-first (as `/opsx:sync` becomes in #1279). + +Delineated from adjacent work (coordinate, don't collide): + +- [#409](https://github.com/Fission-AI/OpenSpec/issues/409) / [#787](https://github.com/Fission-AI/OpenSpec/pull/787) / [#1192](https://github.com/Fission-AI/OpenSpec/issues/1192) (archive-folder prefix scheme) — unarchive's resolver is prefix-tolerant; it does not pick a scheme. +- [add-change-stacking-awareness](../add-change-stacking-awareness/proposal.md) — planning-time archive ordering. Unarchive's drift guard is the runtime spec-layer counterpart; the two compose. +- [#709](https://github.com/Fission-AI/OpenSpec/issues/709) (`git mv`) — a shared `moveDirectory()` makes a future switch cover both archive and unarchive directions. +- [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. If/when `archived` is persisted, `unarchive` should clear it; noted, not built. + +Out of scope: **inference-based review/verification** — semantic review or code-vs-spec checking needs a model; that is the `verify` direction ([#880](https://github.com/Fission-AI/OpenSpec/issues/880)), distinct from the deterministic `diff`/`format`/`check` here. And **owner policy** — *which* hook runner to standardize and whether the `check` gate is enabled by default are the owner's call; the capability (command + opt-in installer + CI template) is built here. diff --git a/openspec/changes/add-spec-tooling-suite/specs/cli-check/spec.md b/openspec/changes/add-spec-tooling-suite/specs/cli-check/spec.md new file mode 100644 index 0000000000..bb25077bde --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/specs/cli-check/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Unified Check Command + +The system SHALL provide an `openspec check` command — the deterministic spec linter — that runs the repository's deterministic gates (format canonical-form, sync delta↔spec consistency and conflicts, and spec validation) in one invocation and exits non-zero if any gate fails. It SHALL run in pure code without AI inference. + +#### Scenario: Run all deterministic gates + +- **WHEN** the user runs `openspec check` +- **THEN** the command runs `format --check`, `sync --check`, and `validate` across the repository's specs and active changes +- **AND** it exits zero only if every gate passes, and non-zero (naming the failing gate and files) otherwise + +#### Scenario: Check all active changes + +- **WHEN** the user runs `openspec check --all` +- **THEN** it includes cross-change checks (e.g. two active changes targeting the same requirement) in addition to per-change checks + +#### Scenario: No inference + +- **WHEN** `openspec check` runs +- **THEN** it computes every result in code +- **AND** it does not call a language model — it is safe to run with no API keys + +### Requirement: Same Gate For Pre-Commit And CI + +The check command SHALL be invocable identically as a local pre-commit hook and as a post-commit CI job, with the only difference being where it is invoked — the same binary, flags, and exit-code contract in both. + +#### Scenario: Pre-commit usage + +- **WHEN** `openspec check` runs from a pre-commit hook +- **THEN** a passing check allows the commit to proceed +- **AND** a failing check blocks the commit with a non-zero exit and a report of what to fix + +#### Scenario: CI usage + +- **WHEN** `openspec check` runs as a CI step on a pull request +- **THEN** a passing check allows the job to succeed +- **AND** a failing check fails the job with the same non-zero exit and report as the pre-commit run + +#### Scenario: Identical verdict in both contexts + +- **WHEN** the same repository state is checked locally and in CI +- **THEN** both reach the same pass or fail verdict (the gate is deterministic and environment-independent) + +### Requirement: Auto-Fix Mode + +The check command SHALL support a `--fix` mode that applies the deterministic auto-fixes (format and sync regeneration) so a pre-commit hook can remediate drift in place before the commit proceeds, leaving specs canonical and in sync. + +#### Scenario: Fix remediates drift + +- **WHEN** the user runs `openspec check --fix` +- **THEN** the command runs `format --fix` and `sync --fix` to regenerate canonical, in-sync specs +- **AND** a subsequent `openspec check` passes + +#### Scenario: Fix does not mask non-mechanical failures + +- **WHEN** `--fix` runs and a failure cannot be resolved deterministically (for example, a delta that does not cleanly apply, or a cross-change conflict) +- **THEN** the command applies what it safely can, leaves the unfixable issue reported, and still exits non-zero +- **AND** it does not invent a resolution + +### Requirement: Incremental Checking + +The check command MAY use recorded content digests to skip specs and files whose content is unchanged since they were last checked, re-checking only what changed. A skip SHALL be permitted only when it cannot change the verdict versus a full check. + +#### Scenario: Unchanged inputs skipped + +- **WHEN** `openspec check` runs and an input's digest matches its recorded digest +- **THEN** the command may skip re-checking it +- **AND** the overall verdict is identical to a full check + +#### Scenario: Changed or unknown inputs fully checked + +- **WHEN** an input's digest does not match, is missing, or uses an unrecognized scheme +- **THEN** the command performs the full check for it + +### Requirement: Hook Installation + +The system SHALL provide a runner-agnostic way to install `openspec check` as a git pre-commit hook, and SHALL NOT modify the user's git configuration or hooks without explicit action. + +#### Scenario: Install on request + +- **WHEN** the user opts in to installing the hook (for example, `openspec check --install-hook`) +- **THEN** the command installs a pre-commit hook that runs `openspec check` +- **AND** it composes with an existing hook rather than silently overwriting it + +#### Scenario: Never automatic + +- **WHEN** the user has not opted in +- **THEN** OpenSpec installs no hook and changes no git configuration + +### Requirement: CI Template + +The system SHALL document and provide a copy-paste CI configuration that runs `openspec check` as a drift gate, requiring no model and no API keys. + +#### Scenario: Documented CI gate + +- **WHEN** a maintainer wants a CI drift gate +- **THEN** the project provides a ready CI step that runs `openspec check` +- **AND** the step fails the build when committed specs are not canonical, not in sync with deltas, or otherwise invalid + +### Requirement: JSON Output + +The check command SHALL support `--json`, emitting a machine-readable summary of which gates ran, which passed or failed, and the offending files. + +#### Scenario: JSON summary + +- **WHEN** the user runs `openspec check --json` +- **THEN** it emits a structured result per gate (format, sync, validate) with pass/fail and the files involved +- **AND** the process exit code reflects the overall verdict diff --git a/openspec/changes/add-spec-tooling-suite/specs/cli-diff/spec.md b/openspec/changes/add-spec-tooling-suite/specs/cli-diff/spec.md new file mode 100644 index 0000000000..67e1586a4c --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/specs/cli-diff/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: Spec Diff Command + +The system SHALL provide an `openspec diff [target] [--base ]` command that renders a deterministic, spec-aware diff of spec and delta files, splicing each changed requirement's provenance and rationale inline so a reviewer sees what changed and why together. It SHALL compute the rendering in pure code, without AI inference. + +#### Scenario: Diff a change's deltas + +- **WHEN** the user runs `openspec diff ` +- **THEN** the command shows the change's delta operations grouped by capability and requirement +- **AND** annotates each with the rationale drawn from the change's `proposal.md` + +#### Scenario: Diff main specs against a base revision + +- **WHEN** the user runs `openspec diff --base ` over `openspec/specs/` +- **THEN** the command shows the requirement-level differences since `` +- **AND** annotates each changed requirement with the change and delta operation that produced it, drawn from the recorded applied-delta provenance + +#### Scenario: No inference + +- **WHEN** the command renders a diff +- **THEN** it composes the result from the git diff and the recorded provenance/rationale in code +- **AND** it does not call a language model + +### Requirement: Inline Reasoning Annotation + +The diff SHALL annotate each changed requirement with the originating change and its rationale, sourced from existing OpenSpec artifacts, and SHALL NOT invent rationale that is not recorded. + +#### Scenario: Annotated with originating change and rationale + +- **WHEN** a changed requirement can be attributed to a change via provenance +- **THEN** the diff shows the originating change and a reference to or excerpt of its recorded rationale + +#### Scenario: Unattributable change shown honestly + +- **WHEN** a changed requirement cannot be attributed (no provenance recorded, e.g. a pre-baseline edit) +- **THEN** the diff shows the change without inventing a rationale +- **AND** it indicates that provenance is unavailable + +### Requirement: Reuses Existing Artifacts, No New Sidecar Store + +The rationale and provenance the diff splices SHALL come from artifacts OpenSpec already maintains — the change's `proposal.md` (the why) and the recorded applied-delta provenance (the what/where) — rather than a separate reasoning database. + +#### Scenario: Reasoning resolved from existing artifacts + +- **WHEN** the diff needs the reasoning for a changed requirement +- **THEN** it resolves the rationale from the originating change's `proposal.md` and the recorded provenance +- **AND** it requires no separate reasoning-log store + +### Requirement: Deterministic Rendering + +The diff rendering SHALL be a pure function of its inputs, producing byte-identical output for the same inputs on every platform. + +#### Scenario: Repeated runs are identical + +- **WHEN** `openspec diff` runs more than once on the same inputs +- **THEN** the output bytes are identical every time + +#### Scenario: Platform independence + +- **WHEN** the diff runs on different operating systems with the same inputs +- **THEN** the output is identical regardless of line-ending or path-separator differences + +### Requirement: Git Diff Driver Integration + +The command SHALL be usable as a git diff driver for spec files, documented as an opt-in `.gitattributes` registration, so that `git diff` over spec and delta files renders the spec-aware view. OpenSpec SHALL NOT modify the user's git configuration without explicit consent. + +#### Scenario: Registered as a diff driver + +- **WHEN** the user opts in by registering the driver for spec paths in `.gitattributes` +- **THEN** `git diff` over those paths renders the spec-aware, annotated diff + +#### Scenario: Opt-in only + +- **WHEN** the user has not registered the driver +- **THEN** OpenSpec does not alter git behavior +- **AND** `openspec diff` remains available as a standalone command + +### Requirement: JSON Output + +The diff command SHALL support `--json`, emitting a machine-readable, per-requirement structure (change operation, provenance, rationale reference) for review tooling. + +#### Scenario: JSON annotated diff + +- **WHEN** the user runs `openspec diff --json` +- **THEN** it emits, per changed requirement, the operation, the originating change, and a reference to the rationale diff --git a/openspec/changes/add-spec-tooling-suite/specs/cli-format/spec.md b/openspec/changes/add-spec-tooling-suite/specs/cli-format/spec.md new file mode 100644 index 0000000000..436b4997cf --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/specs/cli-format/spec.md @@ -0,0 +1,144 @@ +## ADDED Requirements + +### Requirement: Spec Format Command + +The system SHALL provide an `openspec format [target]` command that rewrites OpenSpec spec files and delta spec files to a single deterministic canonical form, in pure code without AI inference. By default it writes; `--check` runs read-only. + +#### Scenario: Format main and delta specs + +- **WHEN** the user runs `openspec format` with no target +- **THEN** the command formats the main specs under `openspec/specs/` and the delta specs under active changes' `specs/` directories +- **AND** it reports which files were reformatted + +#### Scenario: Format a specific target + +- **WHEN** the user runs `openspec format ` for a spec or delta file or directory +- **THEN** the command formats only that target + +#### Scenario: No inference + +- **WHEN** the command formats a file +- **THEN** it computes the canonical form in code +- **AND** it does not call a language model or otherwise depend on non-deterministic input + +### Requirement: Deterministic Canonical Form + +The formatter SHALL produce byte-identical output for the same input on every platform, and SHALL be idempotent: formatting already-canonical content changes nothing. + +#### Scenario: Same input yields identical output + +- **WHEN** the formatter runs more than once on the same input +- **THEN** the output bytes are identical every time + +#### Scenario: Idempotent + +- **WHEN** the formatter is applied to content it has already formatted +- **THEN** the content is unchanged + +#### Scenario: Line endings normalized + +- **WHEN** the input contains CRLF or mixed line endings +- **THEN** the canonical output uses normalized line endings regardless of the platform + +### Requirement: Behavior-Preserving Normalization + +The formatter SHALL change only presentation — whitespace, blank-line policy, list markers and indentation, and heading spacing — and SHALL NOT change the meaning of a spec. It SHALL NOT reorder requirements or scenarios, rewrite prose, or add, remove, merge, or split requirements or scenarios. + +#### Scenario: Requirement and scenario order preserved + +- **WHEN** the formatter runs on a spec +- **THEN** the order of requirements and of scenarios within each requirement is unchanged + +#### Scenario: Prose is not rewritten + +- **WHEN** the formatter normalizes a requirement +- **THEN** the requirement's wording and scenario text are byte-for-byte unchanged except for surrounding whitespace normalization + +#### Scenario: Parsed content is identical before and after + +- **WHEN** a spec is parsed before formatting and after formatting +- **THEN** the parsed requirements, scenarios, and delta operations are identical + +### Requirement: Canonical Section Organization + +The formatter SHALL normalize the structural presentation of a spec deterministically — heading levels and nesting, the spacing between sections, and the canonical headers for delta sections — without changing which sections are present or their order. + +#### Scenario: Canonical headings and spacing + +- **WHEN** a spec uses inconsistent heading spacing or blank-line separation between requirements and scenarios +- **THEN** the formatter rewrites them to the canonical spacing defined by the conventions + +#### Scenario: Canonical delta section headers + +- **WHEN** a delta file contains `## ADDED/MODIFIED/REMOVED/RENAMED Requirements` sections +- **THEN** the formatter normalizes those headers to their canonical form +- **AND** it does not move requirements between sections + +### Requirement: Shared Canonicalization With The Merge Engine + +The canonical form produced by `openspec format` SHALL be the same canonical form emitted by the deterministic merge engine used by `openspec sync` and `openspec archive`, so that synced or archived specs are already canonical. + +#### Scenario: Merge output is already formatted + +- **WHEN** `openspec sync` or `openspec archive` writes a spec +- **THEN** running `openspec format --check` on that spec passes without changes + +#### Scenario: One canonicalizer + +- **WHEN** the same spec content is produced by the formatter and by the merge engine +- **THEN** the two results are byte-identical + +### Requirement: Check Mode + +The format command SHALL support a read-only `--check` mode that exits non-zero when any target is not in canonical form, naming the offending files and modifying nothing, so it can gate commits and CI as a plain binary. + +#### Scenario: Unformatted file detected + +- **WHEN** `openspec format --check` finds a file that is not in canonical form +- **THEN** the command reports the file +- **AND** it exits with a non-zero status code and modifies no files + +#### Scenario: All formatted + +- **WHEN** every target is already in canonical form +- **THEN** the command exits zero and modifies no files + +### Requirement: Fix Mode + +The format command SHALL, by default (or with `--fix`), rewrite targets to canonical form, suitable for use as an auto-fixer in a pre-commit hook. + +#### Scenario: Fix writes canonical form + +- **WHEN** the user runs `openspec format` (or `openspec format --fix`) +- **THEN** the command writes each target's canonical form +- **AND** a subsequent `openspec format --check` passes + +### Requirement: Incremental Checking + +The format check MAY use recorded content digests to skip files whose content is unchanged since they were last checked, re-checking only what changed. A skip SHALL be permitted only when it cannot change the result versus a full check. + +#### Scenario: Unchanged file skipped + +- **WHEN** `--check` runs and a file's current content digest matches the recorded digest +- **THEN** the command may skip re-checking that file +- **AND** the overall result is identical to checking it fully + +#### Scenario: Changed or unknown file fully checked + +- **WHEN** a file's digest does not match, no digest is recorded, or the recorded digest uses an unrecognized scheme +- **THEN** the command performs the full check for that file + +### Requirement: JSON Output + +The format command SHALL support `--json` for non-interactive use, emitting machine-readable results and diagnostics. + +#### Scenario: JSON reports unformatted files + +- **WHEN** `openspec format --check --json` finds files not in canonical form +- **THEN** it emits the list of offending files as JSON +- **AND** exits with a non-zero status code + +#### Scenario: JSON reports written files + +- **WHEN** `openspec format --json` rewrites files +- **THEN** it emits the list of changed files as JSON diff --git a/openspec/changes/add-spec-tooling-suite/specs/cli-unarchive/spec.md b/openspec/changes/add-spec-tooling-suite/specs/cli-unarchive/spec.md new file mode 100644 index 0000000000..60a892daae --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/specs/cli-unarchive/spec.md @@ -0,0 +1,190 @@ +## ADDED Requirements + +### Requirement: Unarchive Command + +The system SHALL provide an `openspec unarchive [change-name]` command that is the inverse of `openspec archive`: it moves a change folder out of `openspec/changes/archive/-/` back to `openspec/changes//` and reverses the spec merge that archiving applied to `openspec/specs/`. + +#### Scenario: Restore an archived change + +- **WHEN** the user runs `openspec unarchive ` for a change that was archived by this version or later +- **THEN** the command restores `openspec/specs/` to its pre-archive state +- **AND** moves the change folder back to `openspec/changes//` +- **AND** reports the restored change name and location + +#### Scenario: Interactive selection + +- **WHEN** no change-name is provided in an interactive session +- **THEN** the command lists archived changes (most-recently-archived first) and prompts the user to select one +- **AND** it does not auto-select + +#### Scenario: Non-interactive requires a name + +- **WHEN** the command is run with `--json` (or otherwise non-interactively) and no change-name is provided +- **THEN** it fails with a machine-readable diagnostic stating a change name is required +- **AND** exits with a non-zero status code + +### Requirement: Archived Change Resolution + +The command SHALL resolve the target archived directory from either a bare change `` or a full archived directory id, treating the leading prefix as opaque up to the name, and SHALL never silently choose among multiple matches. + +#### Scenario: Resolve a bare name with a single match + +- **WHEN** exactly one archived directory matches `` after stripping its leading prefix +- **THEN** the command resolves to that directory + +#### Scenario: Tolerate differing prefix schemes + +- **WHEN** an archived directory uses a date prefix (`YYYY-MM-DD-`) or a sequence/other prefix (e.g. `NNN-`) +- **THEN** the command resolves `` regardless of which prefix scheme produced the directory + +#### Scenario: Ambiguous bare name in interactive mode + +- **WHEN** more than one archived directory matches a bare `` +- **AND** the session is interactive +- **THEN** the command lists the matching directories (most-recent first) and prompts the user to choose +- **AND** it does not auto-select + +#### Scenario: Ambiguous bare name in non-interactive mode + +- **WHEN** more than one archived directory matches a bare `` +- **AND** the command is run with `--json` or otherwise non-interactively +- **THEN** it fails with a diagnostic listing the candidate directory ids +- **AND** directs the user to re-run with the full archived directory id + +#### Scenario: Full directory id resolves unambiguously + +- **WHEN** the user passes the full archived directory id (including its prefix) +- **THEN** the command resolves to exactly that directory without prompting + +#### Scenario: No matching archive + +- **WHEN** no archived directory matches the provided name or id +- **THEN** the command fails with a clear not-found error and makes no changes + +### Requirement: Deterministic Spec Reversal + +The command SHALL reverse the spec merge deterministically by restoring, for each affected spec, the pre-merge content recorded in the change's applied-delta baseline — recreating specs that archiving deleted and deleting specs that archiving created — without re-parsing or inferring requirement content. + +#### Scenario: Restore modified and removed requirements exactly + +- **WHEN** the archived change's baseline records pre-merge content for an affected spec +- **THEN** the command restores that spec to the recorded pre-merge bytes +- **AND** requirements that were MODIFIED or REMOVED during archiving are restored to their exact prior content + +#### Scenario: Reverse added requirements + +- **WHEN** archiving added requirements to a spec +- **THEN** restoring the recorded pre-merge content removes exactly those added requirements + +#### Scenario: Recreate a spec that archiving created + +- **WHEN** archiving created a new spec (the baseline marks the pre-image as absent) +- **THEN** the command deletes that spec on reversal, returning `openspec/specs/` to its pre-archive shape + +#### Scenario: Round-trip is byte-exact + +- **WHEN** a change is archived and then unarchived with no intervening edits to the affected specs +- **THEN** `openspec/specs/` is byte-for-byte identical to its state before the archive + +### Requirement: Drift Guard + +The command SHALL verify, before reversing any spec, that each affected spec still matches the applied-result state recorded in the applied-delta baseline, and SHALL refuse to reverse a spec that has drifted rather than overwrite later changes. + +#### Scenario: Refuse on drift + +- **WHEN** an affected spec's current content no longer matches the applied-result digest recorded in the baseline (for example, a later change modified the same requirement) +- **THEN** the command refuses to reverse the spec merge +- **AND** it reports which specs drifted +- **AND** it directs the user to re-run with `--keep-specs` to restore the folder without touching specs + +#### Scenario: Proceed when no drift + +- **WHEN** every affected spec still matches its recorded applied-result state +- **THEN** the command proceeds with the deterministic spec reversal + +### Requirement: Keep Specs Option + +The command SHALL support a `--keep-specs` flag that restores the change folder to active without modifying `openspec/specs/`, and SHALL accept `--skip-specs` as an equivalent alias. + +#### Scenario: Restore folder without touching specs + +- **WHEN** the user runs `openspec unarchive --keep-specs` +- **THEN** the command moves the change folder back to `openspec/changes//` +- **AND** it makes no changes to `openspec/specs/` +- **AND** it does not perform drift or reversal checks on specs + +#### Scenario: Skip-specs alias + +- **WHEN** the user runs `openspec unarchive --skip-specs` +- **THEN** the command behaves identically to `--keep-specs` + +### Requirement: Atomic Operation + +The command SHALL apply the reversal atomically using a defined sequence: validate, then stage, then commit, so that any failure before the final commit step leaves the filesystem unchanged. + +#### Scenario: Defined sequence + +- **WHEN** the command performs a reversal that touches specs +- **THEN** it executes in this order: + 1. validate that the destination `openspec/changes//` does not exist and that every affected spec is present and drift-free against the baseline; + 2. compute the reversed spec content and write it to a temporary staging area inside `.openspec/` (no change yet to `openspec/specs/`); + 3. swap the staged specs into `openspec/specs/`; + 4. move the change folder from archive back to active. + +#### Scenario: Failure before the folder move leaves specs untouched + +- **WHEN** validation or staging (steps 1–2) fails +- **THEN** the command reports `Abort. No files were changed.` +- **AND** `openspec/specs/` and the archive folder are exactly as they were + +#### Scenario: Failure of the final move rolls specs back + +- **WHEN** the spec swap (step 3) has occurred but the folder move (step 4) fails +- **THEN** the command restores `openspec/specs/` from the still-present baseline +- **AND** reports `Abort. No files were changed.` +- **AND** if rollback itself cannot complete, it reports the partial state and the exact recovery steps rather than leaving a silent inconsistency + +#### Scenario: Destination already exists + +- **WHEN** an active change directory `openspec/changes//` already exists +- **THEN** the command fails without overwriting it +- **AND** it makes no changes to specs + +### Requirement: Backward Compatibility For Pre-Baseline Archives + +For changes archived before applied-delta baselines existed, the command SHALL reverse the spec merge only when the whole change is invertible from the archived delta alone, and SHALL otherwise refuse the spec reversal entirely (all-or-nothing) rather than leaving a partially reversed `openspec/specs/`. This preserves the same atomic, never-corrupt guarantee in the degraded path. + +#### Scenario: Fully self-invertible change is reversed + +- **WHEN** an archived change has no applied-delta baseline +- **AND** every delta operation across all of its affected specs is ADDED and/or RENAMED +- **THEN** the command reverses them by delta inversion (removing added requirements, renaming renamed requirements back) and restores `openspec/specs/` accordingly + +#### Scenario: Mixed invertibility refuses all spec reversal + +- **WHEN** an archived change has no applied-delta baseline +- **AND** its delta contains at least one MODIFIED or REMOVED requirement (whose pre-image is not recoverable from the delta), possibly alongside ADDED/RENAMED in the same or other specs +- **THEN** the command modifies no specs at all (it does not partially reverse the self-invertible specs) +- **AND** it reports which requirements cannot be safely reversed and why +- **AND** it directs the user to `--keep-specs` (and optionally to git-based recovery of the pre-image) + +#### Scenario: Keep-specs always available + +- **WHEN** an archived change has no applied-delta baseline +- **AND** the user runs `openspec unarchive --keep-specs` +- **THEN** the command restores the folder without touching specs, regardless of which delta operations the change contains + +### Requirement: Error Conditions + +The command SHALL handle error conditions gracefully and consistently with `openspec archive`. + +#### Scenario: Missing archive directory + +- **WHEN** no `openspec/changes/archive/` directory exists +- **THEN** the command fails with a clear message and makes no changes + +#### Scenario: JSON diagnostics + +- **WHEN** the command is run with `--json` and a blocked condition occurs (not found, ambiguous, drift, destination exists, or pre-baseline irreversibility) +- **THEN** it emits a machine-readable diagnostic with a stable code +- **AND** exits with a non-zero status code diff --git a/openspec/changes/add-spec-tooling-suite/specs/opsx-unarchive-skill/spec.md b/openspec/changes/add-spec-tooling-suite/specs/opsx-unarchive-skill/spec.md new file mode 100644 index 0000000000..742592f506 --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/specs/opsx-unarchive-skill/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: OPSX Unarchive Skill + +The system SHALL provide an `/opsx:unarchive` skill that restores an archived change to active, delegating the deterministic work to the `openspec unarchive` CLI rather than moving folders or editing specs itself. + +#### Scenario: Unarchive a selected change + +- **WHEN** the agent executes `/opsx:unarchive` with a change name +- **THEN** the skill invokes `openspec unarchive ` to perform the restore +- **AND** it displays the CLI's result (restored location and spec-reversal outcome) + +#### Scenario: Change selection prompt + +- **WHEN** the agent executes `/opsx:unarchive` without specifying a change +- **AND** the change cannot be inferred from conversation context +- **THEN** the skill lists archived changes (via `openspec list --archived --json`) and asks the user to choose +- **AND** it never auto-selects, including when several archives share a name + +### Requirement: CLI Delegation + +The skill SHALL perform all deterministic operations — resolution, spec reversal, drift checking, folder move, and atomicity — by calling the `openspec unarchive` CLI, and SHALL NOT re-implement that logic in prose. + +#### Scenario: No manual file operations + +- **WHEN** the skill restores a change +- **THEN** it does not manually move the change directory +- **AND** it does not manually edit files under `openspec/specs/` +- **AND** it relies on the CLI for the reversal + +#### Scenario: Surface CLI guardrails verbatim + +- **WHEN** the CLI refuses to reverse specs because they have drifted, or because a pre-snapshot change contains irreversible operations +- **THEN** the skill reports that refusal to the user +- **AND** it offers the `--keep-specs` option the CLI recommends rather than attempting its own workaround + +### Requirement: Confirmation And Output + +The skill SHALL confirm the action before running it and present a clear summary of the outcome. + +#### Scenario: Confirm before restoring + +- **WHEN** the skill has resolved which archived change to restore +- **THEN** it shows the user what it will do (target change, whether specs will be reversed or kept) before invoking the CLI +- **AND** it proceeds only after the user confirms + +#### Scenario: Summarize the result + +- **WHEN** the CLI completes +- **THEN** the skill displays the restored change name, its new active location, and whether specs were reversed or kept + +#### Scenario: Report a clean failure + +- **WHEN** the CLI aborts (for example, the destination already exists or the change name is ambiguous) +- **THEN** the skill reports the CLI's diagnostic and the suggested next step +- **AND** it does not attempt a manual fallback diff --git a/openspec/changes/add-spec-tooling-suite/tasks.md b/openspec/changes/add-spec-tooling-suite/tasks.md new file mode 100644 index 0000000000..36368569fa --- /dev/null +++ b/openspec/changes/add-spec-tooling-suite/tasks.md @@ -0,0 +1,64 @@ +# Tasks: spec tooling on the merge baseline — `unarchive` + `format` + `diff` + `check` + +> Builds on [add-deterministic-spec-sync (#1279)](https://github.com/Fission-AI/OpenSpec/pull/1279): the merge engine, applied-delta baseline (pre-image + digest + provenance), shared `src/core/spec-canonical.ts`, and `sync --check`. **Merge #1279 first.** Phases are independently testable: Phase 1 (`unarchive`) needs only the baseline + inverse; Phase 2 (`format`) reuses the canonicalizer; Phase 3 (`diff`) reuses provenance; Phase 4 (`check`) composes `sync --check` + `format --check` + `validate`. Cross-platform concerns (`path.join`, Windows `moveDirectory`, newline-normalized digests) are called out per [openspec/config.yaml](../../config.yaml). + +## 0. Prerequisite wiring (from #1279) + +- [ ] 0.1 Add the inverse merge in `specs-apply.ts`: delta-inversion for ADDED (remove by name) and RENAMED (rename TO→FROM, rewriting the header line); pre-image restore for all ops when a baseline exists. No change to forward behavior. (This is the reverse half of the engine #1279 records the baseline for.) +- [ ] 0.2 Confirm the baseline read API from #1279 exposes pre-image, applied-result digest, and provenance per affected spec; rebase on its final shape. + +## 1. `openspec unarchive` (reverse, built on the baseline) + +- [ ] 1.1 Create `src/core/unarchive.ts` `UnarchiveCommand` (human + `--json`, blocked-error → diagnostic). +- [ ] 1.2 Resolver: bare `` or full archived id; treat prefix as opaque up to `` (strip `YYYY-MM-DD-`; tolerate `NNN-`/ISO/configurable, #409/#787/#1192); reuse `getArchivedChangesData()` (#399). Ambiguity → interactive prompt (most-recent first, never auto-pick) / `--json` error with candidates. +- [ ] 1.3 Reverse from baseline: restore each affected spec's pre-image (rewrite modified, recreate deleted, delete archive-created), under the drift guard (current content vs baseline applied-result digest); on drift, refuse and direct to `--keep-specs`. +- [ ] 1.4 Destination-collision guard (`changes//` exists → abort, no overwrite). Atomicity: stage + validate first; commit order restore specs → move folder (`moveDirectory`, Windows fallback); on failure *"Abort. No files were changed."*, rolling specs back from the baseline if the move fails. (design Decision 7) +- [ ] 1.5 `--keep-specs` (with hidden `--skip-specs` alias): pure folder move, no spec work, no drift/reversal checks. (design Decision 8) +- [ ] 1.6 Pre-baseline degradation, all-or-nothing (design Decision 9): reverse by delta-inversion iff every op is ADDED/RENAMED; if any MODIFIED/REMOVED, refuse the whole spec reversal naming the un-restorable requirements and directing to `--keep-specs`. +- [ ] 1.7 Register `unarchive [change-name]` in `src/cli/index.ts` (`--keep-specs`/`--skip-specs`, `-y/--yes`, `--no-validate`, `--json`, `--store`). +- [ ] 1.8 Tests: byte-exact archive→unarchive round-trip for ADD/MODIFY/REMOVE/RENAME/create; drift refusal (no writes); destination collision abort; `--keep-specs` leaves `specs/` untouched; pre-baseline all-or-nothing; atomic abort leaves zero partial state; Windows move path; `--json` blocked-path diagnostics. + +## 2. `openspec format` — the formatter (reuses the #1279 canonicalizer) + +- [ ] 2.1 Create `src/core/format.ts` `FormatCommand` (human + `--json`); default/`--fix` rewrites targets via `spec-canonical.ts`; `--check` is read-only. +- [ ] 2.2 Target resolution: no target → all main specs + active-change delta files; explicit path → just that file/dir. Handle both spec and delta formats. +- [ ] 2.3 `--check`: list non-canonical files, exit non-zero, write nothing; default/`--fix`: write canonical form. +- [ ] 2.4 Register `format [target]` in `src/cli/index.ts` (`--check`, `--fix`, `--json`), mirroring archive's shape. No skill (pure code). +- [ ] 2.5 Incremental: reuse the baseline digest skip (design Decision 13, #1279) so `--check` only re-reads changed files; skip never changes the verdict. +- [ ] 2.6 Tests: determinism (same input → same bytes; CRLF/LF; Windows); idempotency (`format(format(x))==format(x)`); **behavior-preserving** (`parse-before==parse-after`; requirement/scenario order and prose unchanged); **shared-canonicalizer invariant** — `sync`/`archive` output passes `format --check`; `--check` exit codes; `--json` shape. + +## 3. `openspec diff` — spec-aware diff driver (consumes #1279 provenance) + +- [ ] 3.1 Create `src/core/diff.ts` `DiffCommand` (human + `--json`, `--base `): render requirement-level spec/delta changes; resolve provenance from the applied-delta baseline and rationale from the originating change's `proposal.md`. +- [ ] 3.2 Annotate each changed requirement with originating change + rationale reference; when a change is unattributable (no provenance, e.g. pre-baseline), show it honestly without inventing rationale. +- [ ] 3.3 Deterministic rendering: pure function of git diff + provenance + proposal text; byte-identical across runs/platforms; no inference. +- [ ] 3.4 Git diff driver integration: provide the renderer in a form usable as a git `diff`/`textconv` driver, plus a documented opt-in `.gitattributes` snippet for spec paths. Never modify the user's git config automatically. +- [ ] 3.5 Register `diff [target]` in `src/cli/index.ts` (`--base`, `--json`). No skill (pure code). +- [ ] 3.6 Tests: deterministic output (repeat/CRLF/Windows); annotation resolves from proposal + provenance; unattributable change shown without invented rationale; no git-config mutation without opt-in; `--json` shape. + +## 4. `openspec check` — the unified gate (pre-commit + CI) + +- [ ] 4.1 Create `src/core/check.ts` `CheckCommand` (`--fix`, `--all`, `--json`, incremental): compose `format --check`, `sync --check`, and `validate` into one run with a single exit-code/JSON contract; `--fix` runs `format --fix` + `sync --fix` and never invents non-mechanical resolutions (cross-change conflicts / un-appliable deltas still fail). +- [ ] 4.2 Register `check` in `src/cli/index.ts` (`--fix`, `--all`, `--install-hook`, `--json`). +- [ ] 4.3 Hook installer: `openspec check --install-hook` installs a pre-commit hook that runs `openspec check`; runner-agnostic; composes with an existing hook; never installs automatically or alters git config without the explicit flag. +- [ ] 4.4 CI template: add a committed, copy-paste CI step (e.g. a GitHub Actions job) that runs `openspec check` as a drift gate — no model, no API keys. +- [ ] 4.5 Assert the pre-commit/CI symmetry: the same repo state yields the same `openspec check` verdict whether invoked from the hook or the CI step (one binary, one contract). +- [ ] 4.6 Document the **opt-in git diff driver** registration for `openspec diff` (the `.gitattributes` snippet for spec/delta paths), making clear OpenSpec never alters git config without consent (design Decision 15). +- [ ] 4.7 Tests: `check` aggregates the sub-gates and exit codes; `--fix` remediates mechanical drift but still fails on conflicts; `--all` runs cross-change checks; incremental skip never changes the verdict; hook installer composes with an existing hook and is never automatic; `--json` shape. + +## 5. Skill + docs + +- [ ] 5.1 Create `src/core/templates/workflows/unarchive-change.ts` (`getUnarchiveChangeSkillTemplate()` + `getOpsxUnarchiveCommandTemplate()`) delegating to `openspec unarchive`; surface the CLI's drift refusal and `--keep-specs` option verbatim; never hand-move folders or hand-edit specs. +- [ ] 5.2 Export from `skill-templates.ts`; add `unarchive` to `ALL_WORKFLOWS` ([src/core/profiles.ts:19](../../../src/core/profiles.ts)) and `WORKFLOW_TO_SKILL_DIR` ([src/core/init.ts:65](../../../src/core/init.ts)); **not** to `CORE_WORKFLOWS` (#913/#762). +- [ ] 5.3 Tests: template snapshot asserts `/opsx:unarchive` calls the CLI and contains no manual merge/move instructions (anti-#863 guard). +- [ ] 5.4 `docs/opsx.md` + CLI docs: add `/opsx:unarchive`; document `openspec unarchive`, `openspec format`, `openspec diff`, and `openspec check` with their flags (and that `format`/`diff`/`check` need no agent/skill); document running `check` as a pre-commit hook and in CI. + +## 6. End-to-end verification + +- [ ] 6.1 E2E round-trip: `archive` (with #1279's baseline) then `unarchive` → `specs/` byte-identical to pre-archive, folder back under `changes//`. +- [ ] 6.2 E2E stacked drift: change A MODIFIES req X and is archived; a second change re-MODIFIES X and is archived; `unarchive A` refuses spec reversal; `unarchive A --keep-specs` succeeds untouched. +- [ ] 6.3 E2E formatter: mangle a spec's whitespace → `format --check` fails → `format` fixes it → `--check` passes; and `sync`/`archive` output passes `format --check` unchanged (shared-canonicalizer invariant). +- [ ] 6.4 E2E diff: edit a change's delta and proposal → `openspec diff ` shows the requirement change annotated with the proposal's rationale; a synced spec change is annotated with the originating change via provenance; a pre-baseline edit is shown without invented rationale. +- [ ] 6.5 E2E check gate: introduce drift (un-synced delta + non-canonical whitespace) → `openspec check` fails naming both gates → `openspec check --fix` remediates → `check` passes; then introduce a cross-change conflict → `check --all` still fails (not auto-fixable); confirm identical verdict whether run via the installed hook or the CI step. +- [ ] 6.6 Validation: `openspec validate add-spec-tooling-suite --strict` passes; `openspec status` shows artifacts complete. +- [ ] 6.7 Run the suite on macOS, Linux, and Windows CI.