From c8b72fce3f8195428982161c7c965fd2c5478e15 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 14:32:36 -0500 Subject: [PATCH 01/11] =?UTF-8?q?docs(openspec):=20propose=20add-update-wo?= =?UTF-8?q?rkflow=20=E2=80=94=20graph-driven=20/opsx:update=20+=20cohesive?= =?UTF-8?q?=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooded OpenSpec proposal for the missing first-class "update" action: a /opsx:update workflow that propagates an edit to one artifact across its downstream dependents (targeted mode) or audits a whole change for stale/ incoherent artifacts (audit mode) — driven by the schema's artifact graph, never hardcoded filenames, editing planning artifacts only (never code). - artifact-graph: expose reverse-dependency queries (getDependents/getDownstream) + a requires-edge mtime staleness signal (the engine already builds the dependents map at graph.ts:98 and discards it). - cli-artifact-workflow: surface requires/dependents/stale on `openspec status --json` and add a `--impact ` downstream-revisit-order selector. - opsx-update-skill: the user-facing /opsx:update command (targeted + audit). Supersedes the proposal-only stub add-artifact-regeneration-support. Addresses the cluster #1188/#705/#673/#247 (closes), #694/#684/#618 (answers), and is graph-driven to avoid the #777/#666 hardcoded-artifact-pattern bug class. Validates clean under `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../add-update-workflow/.openspec.yaml | 2 + .../changes/add-update-workflow/design.md | 79 +++++++++++++++ .../changes/add-update-workflow/proposal.md | 98 +++++++++++++++++++ .../specs/artifact-graph/spec.md | 74 ++++++++++++++ .../specs/cli-artifact-workflow/spec.md | 56 +++++++++++ .../specs/opsx-update-skill/spec.md | 76 ++++++++++++++ openspec/changes/add-update-workflow/tasks.md | 45 +++++++++ 7 files changed, 430 insertions(+) create mode 100644 openspec/changes/add-update-workflow/.openspec.yaml create mode 100644 openspec/changes/add-update-workflow/design.md create mode 100644 openspec/changes/add-update-workflow/proposal.md create mode 100644 openspec/changes/add-update-workflow/specs/artifact-graph/spec.md create mode 100644 openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md create mode 100644 openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md create mode 100644 openspec/changes/add-update-workflow/tasks.md diff --git a/openspec/changes/add-update-workflow/.openspec.yaml b/openspec/changes/add-update-workflow/.openspec.yaml new file mode 100644 index 0000000000..34f9314d22 --- /dev/null +++ b/openspec/changes/add-update-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md new file mode 100644 index 0000000000..53d3caac3f --- /dev/null +++ b/openspec/changes/add-update-workflow/design.md @@ -0,0 +1,79 @@ +# Design: `/opsx:update` — graph-driven artifact update + +## Context + +OPSX models a change as a DAG of artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them and reports forward state (`getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete`). State is detected purely by filesystem existence ([src/core/artifact-graph/state.ts](../../../src/core/artifact-graph/state.ts)). + +The graph is only ever traversed **forward** ("what can I build next?"). The update action requires the **backward** traversal ("I changed X — what downstream is now stale?"). Notably, `getBuildOrder()` already constructs the reverse-edge `dependents` map (graph.ts:98) for Kahn's algorithm — it is computed and discarded. This change exposes it and builds two thin layers on top. + +The orchestrating skills currently embed `proposal → specs → design → tasks` as hardcoded prose patterns that "override the schema instruction field" ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)); `continue-change.ts:103-112` even contradicts its own guardrail ("Use the schema's artifact sequence, don't assume specific artifact names"). The new skill must not repeat this — it reads ids and edges from CLI JSON. + +## Goals / Non-Goals + +**Goals** +- A `/opsx:update` action that propagates an edit to one artifact across exactly its downstream dependents, and an audit mode that checks a whole change for incoherence. +- Drive everything from the schema's artifact graph — zero hardcoded artifact names — so custom schemas work. +- Edit planning artifacts only; never touch code. Confirm every edit with the user. +- Reuse the graph the engine already has; add the minimum surface (one reverse query + one staleness signal + status fields). + +**Non-Goals** +- Automatic, unattended regeneration (the user always chooses — same stance as the superseded stub). +- A new top-level `openspec update*` CLI verb (name is taken; see Decision 4). +- Content-level completion validation of artifacts ([#1084](https://github.com/Fission-AI/OpenSpec/issues/1084)) — distinct change. +- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — phase 2; see Open Questions. +- Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. + +## Decisions + +### 1. Expose the reverse edges instead of re-deriving them +Add `getDependents(id)` (direct dependents) and `getDownstream(id)` (transitive closure of dependents, in topological order) to `ArtifactGraph`. The direct map is already built inside `getBuildOrder()`; factor it into a memoized field so both forward and backward traversals share it. Topological order means a downstream walk revisits each artifact only after the upstreams it depends on have themselves been revisited — so a single pass converges (e.g. for spec-driven, editing `proposal` revisits `specs`, `design`, then `tasks`, never `tasks` before `specs`). + +*Why not compute in the skill?* Because that is exactly the hardcoding #777 is about. The graph is the single source of truth for "how artifacts relate"; the skill must ask it. + +### 2. Staleness from `requires`-edge mtimes — advisory, not authoritative +An artifact is *stale* if `mtime(artifact_output) < max(mtime(output) for each transitive upstream it requires)`. For glob outputs (`specs/**/*.md`) use the newest matching file's mtime. This needs no metadata file and no pattern matching — it walks the **explicit** `requires` edges and the **explicit** `generates` outputs, satisfying [openspec/config.yaml](../../config.yaml)'s "explicit list lookup, not pattern matching" and "if we generate it, we track it by name" rules. + +Staleness is **advisory**: it is a cheap filter that points the audit at edges worth a semantic look. mtime is noisy (a `git checkout`, a `touch`, or formatting all perturb it), so the signal never *acts* on its own — the skill confirms semantically and always asks before editing. Design records the limitation; Open Questions tracks a git-aware refinement and the [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) timestamp option. + +[Risk] mtime false positives/negatives → Mitigation: advisory-only; skill re-reads and reasons about content; audit lists candidates, never auto-edits. + +### 3. The skill is a thin, graph-driven wrapper +`/opsx:update` mirrors `continue-change.ts`'s shape (select change → `openspec status --json` → act) but **only reads ids/paths/edges from JSON** — no embedded artifact-name patterns. Flow: + +1. Resolve the change (infer from context or prompt via `openspec list --json`, like `/opsx:continue`). +2. `openspec status --change --json` → artifacts with `requires`, `dependents`, `stale`, `staleAgainst`, resolved paths. +3. Pick mode: + - **Targeted**: user names the artifact they changed / want to change (or the skill infers it). `openspec status --impact --json` → ordered downstream set. For each downstream artifact: read it + its changed upstreams, assess coherence, propose a concrete diff, **ask**, apply, re-check. + - **Audit**: no target → walk every `stale` edge; present the list; offer per-artifact fixes in revisit order. +4. Stop at the planning boundary. Never edit code. If `tasks`/specs change implies code rework, point to `/opsx:apply`. + +**Intent-change guard.** If the user's revision changes the *intent* (not a refinement), apply the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216-300](../../../docs/opsx.md)) and recommend `/opsx:new` rather than mutating the proposal into different work. + +[Risk] Agent edits code while "updating the plan" (the [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) complaint) → Mitigation: explicit, repeated guardrail "planning artifacts only; if implementation must change, hand to `/opsx:apply`"; the skill's write targets are constrained to `resolvedOutputPath`s from the graph. + +### 4. Naming: `/opsx:update` skill, not `openspec update` CLI +`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts:202](../../../src/cli/index.ts)). Reusing it would overload one verb with two unrelated meanings. Decision: the artifact-update action is the **skill** `/opsx:update`; CLI support is **read-only additions to `openspec status`** (`--impact`, extra JSON fields). No new top-level verb. + +Alternatives considered: (a) `openspec regen --from ` as #705 literally asks — rejected: a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. (b) `openspec update artifacts …` subcommand — rejected: still collides conceptually and splits the action across two surfaces. + +### 5. Build on `cli-artifact-workflow`, compose with #1277 +The status-JSON additions live in the same loader (`instruction-loader.ts`) and command path (`src/commands/workflow/instructions.ts`) that [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches. Additions are purely additive fields, so they compose; if #1277 lands first, rebase the JSON shape onto its version of the status output. + +## Risks / Trade-offs + +- **mtime fragility** (Decision 2) → advisory-only; never auto-edits. +- **Convergence** depends on a correct DAG. If `requires` edges are wrong (e.g. [#695](https://github.com/Fission-AI/OpenSpec/issues/695): `design` omits `specs`), propagation misses a real dependency. Trade-off: surface the consequence; the edge fix is #695's, not ours. +- **Scope creep into cross-change audit** ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)) → explicitly phase 2; intra-change first. +- **Skill drift back to hardcoding** → covered by a test asserting the update template contains no literal `proposal`/`specs`/`design`/`tasks` artifact-name branching (it must read ids from JSON). + +## Migration Plan + +Purely additive. New graph methods, new optional status fields/flag, one new skill template behind the expanded-workflow profile. No existing command changes behavior; no data migration. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. + +## Open Questions + +1. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)).** When `openspec/config.yaml` or a project guideline changes, the user wants *every active change* re-audited. Phase 2: `openspec status --impact` generalizes, but "config changed → which changes are stale" needs a dependency from changes to project config. Worth a follow-up proposal. +2. **Staleness signal source.** mtime now; revisit if [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) lands lifecycle timestamps, or add an optional `--since ` git-aware mode. +3. **Targeted-mode entry point.** Should the skill infer the changed artifact from git diff / mtime automatically, or always ask? Lean: offer the stale set as defaults, let the user confirm/override. +4. **Relationship to `/opsx:apply`'s mid-flight edits.** `apply` already re-reads artifacts each run; should `/opsx:update` be invocable *from within* an apply session, or strictly before it? Lean: standalone, with apply pointing to it when it detects upstream drift. +5. **Retire hardcoded patterns in `continue`/`ff` now or later?** ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)) Doing it here widens scope; deferring risks the new skill being the only graph-driven one. Recommend a fast-follow change. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md new file mode 100644 index 0000000000..c09b0dce02 --- /dev/null +++ b/openspec/changes/add-update-workflow/proposal.md @@ -0,0 +1,98 @@ +## Why + +OPSX names **four** first-class actions — "create, implement, **update**, archive — do any of them anytime" ([docs/opsx.md:52](../../../docs/opsx.md)). Three ship as commands. **`update` does not exist.** The only mechanism offered is *"edit the files manually"* — and when you edit one artifact, nothing tells you which others now contradict it. + +It is the most-requested missing capability in the tracker, and it is one gap: *after artifacts exist, a user revises one, and no command propagates that revision to the artifacts depending on it.* The manual workarounds let the agent edit **code** when the user only wanted to revise the **plan**. Yet the fix is latent: every schema is an artifact DAG, but the graph is only read **forwards** ("what next?"); update needs the **backward** read ("I changed X — what's stale downstream?"). This change surfaces that one missing query and puts `/opsx:update` on top — graph-driven, so it asks what depends on what instead of hardcoding file names. + +## Background: the request cluster + +Verified against `Fission-AI/OpenSpec` on 2026-06-29. Six angles, one gap: + +1. **No update command exists.** "I want a command that can modify the proposal, design and task and then I can check the design and apply." The same issue reports the exact failure mode of the manual workaround: *"the agent is not very clever sometimes, it modified the proposal and the code at the same time. So I must say that you only modify the proposal, design and task, not the code every time."* ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)) + +2. **No way to rebuild downstream artifacts from a modified upstream.** "Once a proposal has been applied, there is **no built-in command to regenerate downstream artifacts** after modifying an upstream file… This breaks the promise of a declarative, spec-driven workflow." The issue asks for `openspec regen --from proposal`. ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) + +3. **Users don't know which command updates an artifact.** "If I need to update or change the content of a document, which opsx command should I use to regenerate it? Is it possible to regenerate a document even after the coding phase has started?" Today the honest answer is "none — edit by hand." ([#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618)) + +4. **`/opsx:continue` over-reaches when you only wanted to revise.** "I just ask AI to update the spec/proposal/design… but it sometimes generates another artifact. I'd be happier if it just did the updates I asked for and waited." ([#673](https://github.com/Fission-AI/OpenSpec/issues/673)) + +5. **The cohesive-audit need is explicit.** "Technical decisions in my project keep changing… I need to manually request my coding agent to review and update all features' specs, design, proposal and tasks to comply." The request is a command that "verifies and updates the necessary files." ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)) + +6. **The would-be fix must not be hardcoded.** The existing in-repo stub proposes tracking dependencies by literal filename. But the orchestrating skills already encode `proposal → specs → design → tasks` as **hardcoded artifact patterns** that "override the schema instruction field," breaking custom schemas. Any update mechanism that re-hardcodes those names inherits the same bug. ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)) + +### The root cause, concretely + +`ArtifactGraph` already computes the reverse-edge `dependents` map internally while topologically sorting ([src/core/artifact-graph/graph.ts:98](../../../src/core/artifact-graph/graph.ts)) — then discards it. Every query the engine exposes — `getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete` — answers *"what should I build next?"* (forward). Nothing answers *"I changed X — what downstream of X is now stale?"* (backward). The update action is missing not because it is hard, but because **the one graph query it needs was never surfaced.** The graph already knows how artifacts relate; we just read it the other direction. + +There is a stub already in the repo — `openspec/changes/add-artifact-regeneration-support/proposal.md` (proposal-only, no specs or tasks) — which identifies staleness detection and regeneration but proposes tracking dependencies by hardcoded filename and metadata files. **This change supersedes that stub** with a graph-driven design and the user-facing `/opsx:update` command the cluster is actually asking for. + +## What Changes + +A new **`update`** action, implemented as a thin skill over one new graph query — consistent with the architectural direction of [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (deterministic logic in the CLI, skills as thin wrappers). Ordered by leverage: + +1. **FOUNDATION — expose the reverse edges (`artifact-graph`).** Surface the `dependents` map the engine already builds, plus a transitive, topologically-ordered downstream query. Given any artifact id, the graph answers "what depends on this, directly and transitively, in the order they should be revisited." This is the single primitive the whole feature stands on, and it is schema-agnostic by construction — it reads `requires` edges, never artifact names. + +2. **SIGNAL — staleness along graph edges (`artifact-graph`).** An artifact is *stale* when any upstream it `requires` (transitively) was modified more recently than it. Computed by comparing filesystem modification times along the **explicit `requires` edges** — no metadata file, no pattern matching (per [openspec/config.yaml](../../config.yaml): "specify by explicit list lookup, not pattern matching"). Staleness is **advisory** — a cheap signal that points the audit at likely-incoherent edges; semantic confirmation is the skill's job, and the skill always asks before changing anything. + +3. **SURFACE — graph edges and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains per-artifact `requires`, `dependents`, and `stale`/`staleAgainst` fields, and a focused `openspec status --change --impact --json` returns the downstream set in revisit order. The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. + +4. **COMMAND — the `/opsx:update` skill (`opsx-update-skill`).** The user-facing action, in two modes: + - **Targeted** — "I changed (or want to change) artifact X." The skill reads the graph, walks X's downstream dependents in revisit order, and for each one reads it against its now-changed upstreams, proposes a concrete revision, asks, applies, and re-checks. A single coherent pass over **exactly the files the graph says are related** — the user's "cohesive audit." + - **Audit** — "is this change still internally coherent?" The skill scans every stale edge the CLI reports, presents them, and offers per-artifact fixes. This is the within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). + + Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — it uses schema artifact ids from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. + +The naming boundary is deliberate: `openspec update` is already taken (it regenerates AI tool/skill files — [src/cli/index.ts:202](../../../src/cli/index.ts)). The artifact-update action is therefore the **skill** `/opsx:update` plus **read-only** additions to `openspec status`; this change adds **no** new top-level `openspec update*` CLI verb. (See design for the considered alternatives.) + +## Capabilities + +### New Capabilities + +- `opsx-update-skill`: A new `/opsx:update` workflow skill that propagates a change to one artifact across its downstream dependents (targeted mode) or audits a whole change for incoherent/stale artifacts (audit mode), driven entirely by the schema's artifact graph, editing planning artifacts only and never code, and confirming each edit with the user. + +### Modified Capabilities + +- `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a staleness signal computed along `requires` edges from filesystem modification times — the primitives an update/audit traversal needs, all schema-agnostic. +- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`) and a staleness signal (`stale`, `staleAgainst`); a new `--impact ` selector returns the downstream revisit set in order, so skills consume graph structure as data instead of hardcoding artifact names. + +## Impact + +- `src/core/artifact-graph/graph.ts` — expose `getDependents(id)` (direct, from the map already built at line 98) and `getDownstream(id)` (transitive, topologically ordered); both throw on unknown id. +- `src/core/artifact-graph/state.ts` + `outputs.ts` — staleness computation: resolve each artifact's output mtime (newest match for glob outputs) and compare against the newest mtime among its transitive `requires`; return `{ stale, staleAgainst[] }` per artifact. Missing/empty outputs are "not present," not "stale." +- `src/core/artifact-graph/instruction-loader.ts` + `src/commands/workflow/instructions.ts` (status path) — add `requires`, `dependents`, `stale`, `staleAgainst` to per-artifact status JSON; add the `--impact ` selector returning the ordered downstream set; human-readable output unchanged by default. +- `src/cli/index.ts` — register the `--impact ` option on `status`. +- `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts` but reading graph edges from the CLI rather than embedding artifact-name patterns. +- Skill/command registration + profile wiring (the expanded-workflow profile that already lists `continue`, `ff`, `verify`, …) so `/opsx:update` installs alongside its siblings; docs/opsx.md command table gains a `/opsx:update` row. +- `openspec/changes/add-artifact-regeneration-support/` — superseded by this change (see below); retire or fold its staleness notes into design. +- Cross-platform tests (graph queries, mtime staleness with `path.join`, skill template generation) and Windows CI per [openspec/config.yaml](../../config.yaml). + +## Issues addressed + +All references verified against `Fission-AI/OpenSpec` on 2026-06-29. + +Closes (the missing-update-action family): + +- [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task." Delivered as `/opsx:update`, artifacts-only (never touches code), which is the specific pain in the report. +- [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Support Rebuilding Downstream Artifacts from Modified Intermediate Files." The requested `regen --from ` becomes graph-driven downstream propagation in targeted mode. +- [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — the "clarify" request: update existing artifacts without auto-generating the next one. `/opsx:update` revises in place and never advances the build frontier. +- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "utility to review and update all change proposals." Audit mode delivers the within-a-change form; cross-change audit is the scoped phase-2 extension (see design Open Questions). + +Answers (workflow questions whose honest answer today is "no command exists"): + +- [#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618) — "which command regenerates a document after the flow progressed / after apply?" → `/opsx:update`. + +Supersedes: + +- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by a graph-driven design and the user-facing command. Its staleness-detection idea is preserved (graph-edge mtimes); its hardcoded-filename/metadata-file mechanism is dropped. + +Related, addressed-in-part: + +- [#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666) — hardcoded artifact patterns override the schema. The new skill is graph-driven by construction; retiring the same hardcoding in `continue-change.ts`/`ff-change.ts` is recommended as a fast follow (noted in design), not done here, to keep this change focused. +- [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (prevent-silent-spec-drop) — same architectural principle (deterministic CLI, thin skills) and touches `artifact-graph`/`instruction-loader`; coordinate so the status-JSON additions compose. +- [#695](https://github.com/Fission-AI/OpenSpec/issues/695) — `design` requires only `proposal`, not `specs`. The downstream propagation is only as good as the `requires` edges; this change surfaces the consequence but the edge fix belongs to #695. + +Related, out of scope (referenced, not closed): + +- [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084), [#906](https://github.com/Fission-AI/OpenSpec/issues/906) — completion state is existence-only, so an interrupted/partial artifact reads as "done." Audit mode can *surface* a suspiciously-stale or empty artifact, but content-level completion validation is a distinct change. +- [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. Would give staleness a more robust signal than mtime; complementary, tracked separately. +- [#339](https://github.com/Fission-AI/OpenSpec/issues/339) — proposal-time codebase exploration; orthogonal. diff --git a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md new file mode 100644 index 0000000000..58dd1b9024 --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Direct Dependent Query + +The system SHALL identify which artifacts directly depend on a given artifact (the reverse of the `requires` relationship). + +#### Scenario: Artifact with dependents + +- **WHEN** artifact B requires artifact A and getDependents("A") is called +- **THEN** the result includes B + +#### Scenario: Artifact with no dependents + +- **WHEN** no artifact requires artifact T and getDependents("T") is called +- **THEN** the result is empty + +#### Scenario: Unknown artifact id + +- **WHEN** getDependents() is called with an id not present in the schema +- **THEN** the system throws an error identifying the unknown id + +### Requirement: Transitive Downstream Query + +The system SHALL compute the transitive set of artifacts that depend on a given artifact, returned in topological order so each artifact appears after the upstreams it depends on. + +#### Scenario: Linear chain downstream + +- **WHEN** artifacts form a linear chain (A → B → C) and getDownstream("A") is called +- **THEN** the result is [B, C] in that order + +#### Scenario: Diamond downstream order + +- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D) and getDownstream("A") is called +- **THEN** the result includes B and C before D + +#### Scenario: Leaf artifact has no downstream + +- **WHEN** getDownstream() is called on an artifact with no dependents +- **THEN** the result is empty + +#### Scenario: Downstream excludes the artifact itself + +- **WHEN** getDownstream("A") is called +- **THEN** the result does not include A + +### Requirement: Artifact Staleness Detection + +The system SHALL detect whether an artifact is stale relative to its dependencies by comparing filesystem modification times along the schema's `requires` edges. An artifact is stale when its output was last modified before the most recently modified output among the artifacts it transitively requires. + +#### Scenario: Upstream modified after downstream + +- **WHEN** an upstream dependency's output was modified more recently than an artifact that (transitively) requires it +- **THEN** the artifact is reported as stale +- **AND** the upstream is listed among the artifacts it is stale against + +#### Scenario: Artifact newer than all upstreams + +- **WHEN** an artifact's output was modified more recently than every artifact it requires +- **THEN** the artifact is not reported as stale + +#### Scenario: Glob output uses newest matching file + +- **WHEN** an artifact generates a glob pattern (e.g. `specs/**/*.md`) with multiple files +- **THEN** staleness uses the most recently modified matching file as the artifact's modification time + +#### Scenario: Missing output is not stale + +- **WHEN** an artifact's output does not exist on disk +- **THEN** the artifact is reported as not present rather than stale + +#### Scenario: Root artifact is never stale + +- **WHEN** an artifact has no dependencies +- **THEN** it is never reported as stale diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md new file mode 100644 index 0000000000..f908147bc0 --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Status Includes Dependency Edges + +The system SHALL include each artifact's dependency edges in the `openspec status --json` output, so consumers can determine how artifacts relate without hardcoding artifact names. + +#### Scenario: Status JSON exposes requires and dependents + +- **WHEN** the user runs `openspec status --change --json` +- **THEN** each artifact in the `artifacts` array includes a `requires` array (the artifact ids it depends on) +- **AND** each artifact includes a `dependents` array (the artifact ids that directly depend on it) + +#### Scenario: Edges reflect the active schema + +- **WHEN** the change uses a custom schema with non-default artifact ids +- **THEN** the `requires` and `dependents` edges in the status output use that schema's artifact ids + +### Requirement: Status Includes Staleness Signal + +The system SHALL include a per-artifact staleness signal in the `openspec status --json` output. + +#### Scenario: Stale artifact flagged in JSON + +- **WHEN** an artifact's output is older than an upstream it transitively requires +- **THEN** that artifact's status JSON includes `stale: true` +- **AND** includes `staleAgainst` listing the upstream artifact ids it is stale against + +#### Scenario: Fresh artifact reports not stale + +- **WHEN** an artifact is newer than all of its dependencies +- **THEN** its status JSON includes `stale: false` and an empty `staleAgainst` + +### Requirement: Downstream Impact Query + +The system SHALL provide an impact selector on the status command that returns the downstream artifacts affected by a change to a given artifact, in revisit (topological) order. + +#### Scenario: Impact returns ordered downstream set + +- **WHEN** the user runs `openspec status --change --impact --json` +- **THEN** the output lists the transitive downstream dependents of `` in topological order +- **AND** the listed artifacts include their resolved output paths so a consumer can read and rewrite them + +#### Scenario: Impact on a leaf artifact + +- **WHEN** the impact selector targets an artifact with no dependents +- **THEN** the downstream set is empty + +#### Scenario: Impact on an unknown artifact + +- **WHEN** the impact selector names an artifact id not in the change's schema +- **THEN** the command reports an error identifying the unknown artifact id + +#### Scenario: Default human-readable output is unchanged + +- **WHEN** the user runs `openspec status --change ` without `--json` or `--impact` +- **THEN** the default human-readable status output is unchanged from prior behavior diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md new file mode 100644 index 0000000000..def361eef3 --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Update Workflow Command + +The system SHALL provide a `/opsx:update` workflow skill that revises a change's existing planning artifacts in place. It SHALL NOT advance the build frontier (it does not create a not-yet-started artifact) and SHALL edit planning artifacts only, never implementation code. + +#### Scenario: Select the change to update + +- **WHEN** the user invokes `/opsx:update` without a change name +- **THEN** the skill infers the change from conversation context if possible +- **AND** if it cannot, it lists available changes (most-recently-modified first) and asks the user to choose, never auto-selecting + +#### Scenario: Revise without advancing the frontier + +- **WHEN** the user asks `/opsx:update` to revise an existing artifact +- **THEN** the skill updates that artifact and only its already-existing downstream dependents +- **AND** it does NOT create any artifact that does not yet exist (that remains the job of `/opsx:continue`/`/opsx:propose`) + +#### Scenario: Update stays within the plan + +- **WHEN** revising artifacts would imply changes to implementation code +- **THEN** the skill updates the planning artifacts only +- **AND** it directs the user to `/opsx:apply` to carry the revised plan into code, rather than editing code itself + +### Requirement: Graph-Driven Propagation + +The `/opsx:update` skill SHALL determine which artifacts are related, in what order to revisit them, and where they live by reading the change's artifact graph and resolved paths from the CLI, and SHALL NOT rely on hardcoded artifact names or assumed path separators. This makes the skill correct for custom schemas and on every platform, not only the default `spec-driven` schema. + +#### Scenario: Propagate to downstream dependents in revisit order + +- **WHEN** the user changes a given artifact +- **THEN** the skill obtains that artifact's downstream dependents and their revisit order from the CLI (`openspec status --impact --json`) +- **AND** it reviews each downstream artifact against its now-changed upstreams, in that order + +#### Scenario: Works for a custom schema + +- **WHEN** the active change uses a custom schema whose artifact ids are not `proposal`/`specs`/`design`/`tasks` +- **THEN** the skill uses the artifact ids and dependency edges reported by the CLI +- **AND** propagation works without any change to the skill + +#### Scenario: Resolve artifact paths cross-platform + +- **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows +- **THEN** it uses the resolved path provided by the CLI status/instructions output +- **AND** it does not assume forward-slash separators + +### Requirement: Cohesive Audit Mode + +The `/opsx:update` skill SHALL support an audit mode that reviews a whole change for artifacts that are stale or incoherent relative to their upstream dependencies and offers to fix them. + +#### Scenario: Audit reports stale edges + +- **WHEN** the user invokes `/opsx:update` in audit mode (no specific target artifact) +- **THEN** the skill reads the per-artifact staleness signal from `openspec status --json` +- **AND** it presents the stale artifacts and the upstreams they are stale against, in revisit order + +#### Scenario: Audit offers per-artifact fixes + +- **WHEN** audit mode finds one or more stale or incoherent artifacts +- **THEN** the skill proposes a concrete revision for each, in revisit order +- **AND** it applies a revision only after the user confirms it + +### Requirement: User-Confirmed Incremental Application + +The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation, re-checking coherence after each applied change. + +#### Scenario: Confirm before writing + +- **WHEN** the skill has a proposed revision for an artifact +- **THEN** it shows the user what it intends to change and why before writing +- **AND** it writes only after the user confirms + +#### Scenario: Intent change is redirected to a new change + +- **WHEN** the requested revision changes the intent of the change rather than refining it (per the "Update vs. Start Fresh" heuristic) +- **THEN** the skill recommends starting a new change (`/opsx:new`) instead of mutating the existing proposal into different work diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md new file mode 100644 index 0000000000..519fe0856c --- /dev/null +++ b/openspec/changes/add-update-workflow/tasks.md @@ -0,0 +1,45 @@ +# Tasks: `/opsx:update` — graph-driven artifact update + +> Sequenced so each layer is independently testable: graph primitives → CLI surface → skill → docs/cleanup. Cross-platform (`path.join`, mtime) and Windows CI are called out where paths are involved, per `openspec/config.yaml`. + +## 1. Artifact graph: reverse traversal + +- [ ] 1.1 Factor the reverse-edge `dependents` map (currently built and discarded inside `getBuildOrder()`, `graph.ts:98`) into a memoized field shared by forward and backward queries. +- [ ] 1.2 Add `getDependents(id)` — direct dependents; throw a descriptive error on unknown id. +- [ ] 1.3 Add `getDownstream(id)` — transitive dependents in topological order, excluding `id`; throw on unknown id. +- [ ] 1.4 Unit tests: linear chain, diamond, leaf (empty), unknown-id error, self-exclusion (mirror existing `artifact-graph` spec scenarios). + +## 2. Artifact graph: staleness signal + +- [ ] 2.1 In `outputs.ts`, add a helper to resolve an artifact's output modification time (newest matching file for glob outputs; absent when no output exists). Use `path.join`; no hardcoded separators. +- [ ] 2.2 In `state.ts`, add staleness computation: an artifact is stale when its output mtime is older than the newest output mtime among its transitive `requires`; return `{ stale, staleAgainst[] }` per artifact. Missing output → not present (not stale); root artifact → never stale. +- [ ] 2.3 Unit tests including a Windows path case: upstream-newer, downstream-newer, glob newest-file, missing output, root artifact. + +## 3. CLI: surface edges, staleness, and impact on `status` + +- [ ] 3.1 Extend the status JSON builder (`instruction-loader.ts` + `src/commands/workflow/instructions.ts` status path) to add `requires`, `dependents`, `stale`, `staleAgainst` to each artifact. +- [ ] 3.2 Add the `--impact ` option to `status` (`src/cli/index.ts`); when set, return the ordered downstream set with each artifact's resolved output path; error on unknown artifact id. +- [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. +- [ ] 3.4 Tests: JSON edge fields, stale/staleAgainst, `--impact` ordering, `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. + +## 4. The `/opsx:update` skill + +- [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. +- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → walk downstream via `--impact` → propose/confirm/apply/re-check, reading ids/paths/edges from JSON only. +- [ ] 4.3 Encode the two guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; use schema ids from the CLI. +- [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). +- [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. +- [ ] 4.6 Test: template generation snapshot; assert the template contains NO hardcoded artifact-name branching (it must read ids from JSON) — the anti-#777 guard. + +## 5. Supersede the stub & docs + +- [ ] 5.1 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single, graph-driven update proposal; preserve its staleness idea in this change's design. +- [ ] 5.2 Add a `/opsx:update` row to the command table in `docs/opsx.md`; add a short "Updating a change" usage section that uses targeted and audit modes. +- [ ] 5.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. + +## 6. End-to-end verification + +- [ ] 6.1 E2E: create a spec-driven change, edit `proposal.md`, run the impact/audit surface, confirm `specs`/`design`/`tasks` are reported stale in revisit order and code is never touched. +- [ ] 6.2 E2E with a custom (non-default ids) schema fixture: confirm propagation works with no skill changes. +- [ ] 6.3 Full validation: `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. +- [ ] 6.4 Run the suite on macOS, Linux, and Windows CI. From 090d06a2db509a31847f9ba6b499e2a568936e32 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 14:58:43 -0500 Subject: [PATCH 02/11] docs(openspec): make add-update-workflow deterministic & grounded (Tabish review) Reframe per the steer "more deterministic and grounded in reality": - Deterministic spine: the CLI computes the impact set (which downstream artifacts to revisit, in build order, with paths) as a pure function of schema edges + filesystem. The agent only rewrites prose. Grounded in real APIs already present: getUnlockedArtifacts (direct dependents), getBuildOrder (order), resolveArtifactOutputs (paths); reverse map built at graph.ts:82-87. - Replace fragile mtime staleness with a newline-normalized SHA-256 content digest (reproducible cross-platform). Drift = upstream digest vs recorded baseline; no baseline => "unknown", never a false positive. mtime and pure-git rejected with rationale; digest ledger is a separable, optional layer. - Explicit determinism boundary decision (CLI decides files/order/drift; agent rewrites). Skill MUST source the file list/order from `openspec status --impact`, never compute it. - Corrected all code citations to verified lines (graph.ts:82-87, instruction-loader.ts:366/429, status.ts); noted #1277's coverage helpers are not in this branch's base (coordinate, don't reuse). - Specs updated: artifact-graph Content Digest requirement; cli status digest + deterministic impact ordering; skill determinism + baseline-aware audit. tasks add digest/determinism/cross-platform tests + optional ledger section. Still validates clean under `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 51 +++++++++++-------- .../changes/add-update-workflow/proposal.md | 39 +++++++------- .../specs/artifact-graph/spec.md | 36 ++++++------- .../specs/cli-artifact-workflow/spec.md | 31 +++++++---- .../specs/opsx-update-skill/spec.md | 24 ++++++--- openspec/changes/add-update-workflow/tasks.md | 46 ++++++++++------- 6 files changed, 134 insertions(+), 93 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 53d3caac3f..3eb9c86314 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -4,7 +4,7 @@ OPSX models a change as a DAG of artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them and reports forward state (`getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete`). State is detected purely by filesystem existence ([src/core/artifact-graph/state.ts](../../../src/core/artifact-graph/state.ts)). -The graph is only ever traversed **forward** ("what can I build next?"). The update action requires the **backward** traversal ("I changed X — what downstream is now stale?"). Notably, `getBuildOrder()` already constructs the reverse-edge `dependents` map (graph.ts:98) for Kahn's algorithm — it is computed and discarded. This change exposes it and builds two thin layers on top. +The graph is only ever traversed **forward** ("what can I build next?"). The update action requires the **backward** traversal ("I changed X — which downstream artifacts must be revisited?"). Notably, `getBuildOrder()` already constructs the reverse-edge `dependents` map (graph.ts:82-87) for Kahn's algorithm — computed and discarded — and `getUnlockedArtifacts()` already returns direct dependents for one node. This change exposes the transitive form and builds two thin, deterministic layers on top. The orchestrating skills currently embed `proposal → specs → design → tasks` as hardcoded prose patterns that "override the schema instruction field" ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)); `continue-change.ts:103-112` even contradicts its own guardrail ("Use the schema's artifact sequence, don't assume specific artifact names"). The new skill must not repeat this — it reads ids and edges from CLI JSON. @@ -14,7 +14,7 @@ The orchestrating skills currently embed `proposal → specs → design → task - A `/opsx:update` action that propagates an edit to one artifact across exactly its downstream dependents, and an audit mode that checks a whole change for incoherence. - Drive everything from the schema's artifact graph — zero hardcoded artifact names — so custom schemas work. - Edit planning artifacts only; never touch code. Confirm every edit with the user. -- Reuse the graph the engine already has; add the minimum surface (one reverse query + one staleness signal + status fields). +- Reuse the graph the engine already has; add the minimum surface (one reverse query + one content-digest signal + status fields). Keep correctness-critical logic deterministic and in the CLI; keep the agent's role to semantic rewriting only. **Non-Goals** - Automatic, unattended regeneration (the user always chooses — same stance as the superseded stub). @@ -26,42 +26,53 @@ The orchestrating skills currently embed `proposal → specs → design → task ## Decisions ### 1. Expose the reverse edges instead of re-deriving them -Add `getDependents(id)` (direct dependents) and `getDownstream(id)` (transitive closure of dependents, in topological order) to `ArtifactGraph`. The direct map is already built inside `getBuildOrder()`; factor it into a memoized field so both forward and backward traversals share it. Topological order means a downstream walk revisits each artifact only after the upstreams it depends on have themselves been revisited — so a single pass converges (e.g. for spec-driven, editing `proposal` revisits `specs`, `design`, then `tasks`, never `tasks` before `specs`). +Add `getDependents(id)` (direct dependents) and `getDownstream(id)` (transitive closure of dependents, in topological order) to `ArtifactGraph`. This is not new logic so much as surfacing logic that already exists: `getBuildOrder()` builds the full reverse-adjacency `dependents` map (graph.ts:82-87) and discards it, and `getUnlockedArtifacts()` ([instruction-loader.ts:366](../../../src/core/artifact-graph/instruction-loader.ts)) already computes direct dependents (exposed as `unlocks`). Factor the reverse map into a shared helper; order `getDownstream` by the existing `getBuildOrder()`. Topological order guarantees a downstream walk revisits each artifact only after the upstreams it depends on — so a single pass converges (for spec-driven, editing `proposal` revisits `specs`, `design`, then `tasks`, never `tasks` before `specs`). -*Why not compute in the skill?* Because that is exactly the hardcoding #777 is about. The graph is the single source of truth for "how artifacts relate"; the skill must ask it. +The result is **deterministic**: a pure function of `requires` edges and the change directory's files, identical on every run and platform, and therefore unit-testable (see tasks). That determinism is the reason it belongs in the CLI rather than the skill — and *not* computing it in the skill is precisely how we avoid the #777 hardcoding class. -### 2. Staleness from `requires`-edge mtimes — advisory, not authoritative -An artifact is *stale* if `mtime(artifact_output) < max(mtime(output) for each transitive upstream it requires)`. For glob outputs (`specs/**/*.md`) use the newest matching file's mtime. This needs no metadata file and no pattern matching — it walks the **explicit** `requires` edges and the **explicit** `generates` outputs, satisfying [openspec/config.yaml](../../config.yaml)'s "explicit list lookup, not pattern matching" and "if we generate it, we track it by name" rules. +### 2. A grounded signal: content digests, not mtimes +The earlier draft detected staleness from filesystem mtimes. **Rejected.** mtime is not grounded in reality: `git checkout`/`clone`/`stash`, `touch`, editors, and formatters all rewrite mtime without changing content, and content can change while mtime is preserved (`git` does not restore historical mtimes). It is neither reproducible nor a true content signal — the opposite of what we want. -Staleness is **advisory**: it is a cheap filter that points the audit at edges worth a semantic look. mtime is noisy (a `git checkout`, a `touch`, or formatting all perturb it), so the signal never *acts* on its own — the skill confirms semantically and always asks before editing. Design records the limitation; Open Questions tracks a git-aware refinement and the [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) timestamp option. +Instead, each artifact carries a **content digest**: `sha256(normalizeNewlines(concat(sortedOutputFiles)))`. Newlines are normalized (CRLF→LF) so the digest is identical on Windows and POSIX ([openspec/config.yaml](../../config.yaml) cross-platform rules); files are concatenated in the deterministic sorted order `resolveArtifactOutputs` already returns. There is no content-hash utility in the repo today (only `crypto.randomUUID` in telemetry), so this is a small new helper in `outputs.ts`. The digest is exposed on `openspec status --json`. It is deterministic, reproducible, and grounded in the actual bytes. -[Risk] mtime false positives/negatives → Mitigation: advisory-only; skill re-reads and reasons about content; audit lists candidates, never auto-edits. +**Drift** is then defined deterministically: a downstream artifact *D* has drifted against upstream *U* iff `digest(U_now) ≠ digest(U_recorded_when_D_was_reconciled)`. The recorded baseline is a small per-change **digest ledger** (Decision 3). Until a baseline is recorded, drift is reported as **`unknown`**, never inferred — so the audit produces zero false positives, in contrast to the mtime heuristic which produced many. -### 3. The skill is a thin, graph-driven wrapper -`/opsx:update` mirrors `continue-change.ts`'s shape (select change → `openspec status --json` → act) but **only reads ids/paths/edges from JSON** — no embedded artifact-name patterns. Flow: +[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (declared-capability-without-spec, empty/missing output) and the user-named targeted flow, which need no baseline. + +### 3. The digest ledger (deterministic drift baseline) — separable layer +To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its upstream outputs at the moment it was last reconciled. Writing the ledger is itself a deterministic CLI operation (`openspec status` can record on read, or an explicit `--record`), invoked by the generating flows (`propose`/`continue`/`ff`) and by `/opsx:update` after each confirmed edit — so the agent triggers a deterministic write, it does not compute hashes. This layer is **cleanly separable**: the deterministic spine (Decision 1) and the digest signal (Decision 2) deliver the primary targeted flow without it; the ledger only powers unattended audit. It is a candidate to land in a fast-follow if Tabish prefers to keep this change's surface minimal (Open Questions). + +*Alternatives rejected:* (a) mtime — see Decision 2. (b) Pure git diff — deterministic only for committed files, but planning artifacts are routinely uncommitted while being drafted, so it misses the common case. Digests work regardless of git state. + +### 4. The determinism boundary (what the agent may and may not decide) +The CLI owns every decision that is a function of the graph and the bytes: **which** artifacts are downstream, **what order** to revisit them, **which** files each maps to, and **whether** content has drifted. The agent owns only the irreducibly semantic act: reading a downstream artifact against its changed upstream and **rewriting its prose** to restore coherence. The skill MUST obtain the file list and order from `openspec status --impact` — it may not enumerate or order artifacts itself. This is the same split #1277 applies to archive/validate, and it is what makes the feature "deterministic" rather than "an agent that usually gets it right." + +### 5. The skill flow (thin wrapper over the deterministic spine) +`/opsx:update` mirrors `continue-change.ts`'s shape (select change → `openspec status --json` → act) but **only reads ids/paths/edges/digests from JSON** — no embedded artifact-name patterns. Flow: 1. Resolve the change (infer from context or prompt via `openspec list --json`, like `/opsx:continue`). -2. `openspec status --change --json` → artifacts with `requires`, `dependents`, `stale`, `staleAgainst`, resolved paths. +2. `openspec status --change --json` → artifacts with `requires`, `dependents`, `digest`, resolved paths. 3. Pick mode: - - **Targeted**: user names the artifact they changed / want to change (or the skill infers it). `openspec status --impact --json` → ordered downstream set. For each downstream artifact: read it + its changed upstreams, assess coherence, propose a concrete diff, **ask**, apply, re-check. - - **Audit**: no target → walk every `stale` edge; present the list; offer per-artifact fixes in revisit order. -4. Stop at the planning boundary. Never edit code. If `tasks`/specs change implies code rework, point to `/opsx:apply`. + - **Targeted**: user names the artifact they changed / want to change (or the skill infers it and confirms). `openspec status --impact --json` → ordered downstream set. For each downstream artifact *in the CLI-given order*: read it + its changed upstreams, propose a concrete diff, **ask**, apply, re-check. + - **Audit**: no target → ask the CLI which artifacts have drifted (digest vs. recorded baseline); where no baseline exists, present the deterministic structural facts (declared-capability-without-spec, empty/missing output) and ask. Offer per-artifact fixes in revisit order. +4. Stop at the planning boundary. Never edit code. If a spec/tasks change implies code rework, point to `/opsx:apply`. **Intent-change guard.** If the user's revision changes the *intent* (not a refinement), apply the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216-300](../../../docs/opsx.md)) and recommend `/opsx:new` rather than mutating the proposal into different work. [Risk] Agent edits code while "updating the plan" (the [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) complaint) → Mitigation: explicit, repeated guardrail "planning artifacts only; if implementation must change, hand to `/opsx:apply`"; the skill's write targets are constrained to `resolvedOutputPath`s from the graph. -### 4. Naming: `/opsx:update` skill, not `openspec update` CLI +### 6. Naming: `/opsx:update` skill, not `openspec update` CLI `openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts:202](../../../src/cli/index.ts)). Reusing it would overload one verb with two unrelated meanings. Decision: the artifact-update action is the **skill** `/opsx:update`; CLI support is **read-only additions to `openspec status`** (`--impact`, extra JSON fields). No new top-level verb. Alternatives considered: (a) `openspec regen --from ` as #705 literally asks — rejected: a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. (b) `openspec update artifacts …` subcommand — rejected: still collides conceptually and splits the action across two surfaces. -### 5. Build on `cli-artifact-workflow`, compose with #1277 -The status-JSON additions live in the same loader (`instruction-loader.ts`) and command path (`src/commands/workflow/instructions.ts`) that [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches. Additions are purely additive fields, so they compose; if #1277 lands first, rebase the JSON shape onto its version of the status output. +### 7. Compose with #1277 +The status-JSON additions live in `formatChangeStatus` ([instruction-loader.ts](../../../src/core/artifact-graph/instruction-loader.ts)) and the `status` command ([src/commands/workflow/status.ts](../../../src/commands/workflow/status.ts)); [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches the same status path and adds deterministic capability-coverage helpers (`extractDeclaredCapabilities`, `validateChangeCapabilityCoverage`). Those helpers are **not in this branch's base** (main), so audit's structural checks should *reuse* them once #1277 lands rather than reimplement; the JSON additions here are purely additive fields and compose cleanly. ## Risks / Trade-offs -- **mtime fragility** (Decision 2) → advisory-only; never auto-edits. +- **Digest stability across platforms** → newline-normalize before hashing (Decision 2); a cross-platform test pins identical digests on CRLF and LF inputs. +- **No baseline → no auto-drift** → the deterministic spine (impact set) and structural checks still work with zero baseline; drift is `unknown`, never a false positive. - **Convergence** depends on a correct DAG. If `requires` edges are wrong (e.g. [#695](https://github.com/Fission-AI/OpenSpec/issues/695): `design` omits `specs`), propagation misses a real dependency. Trade-off: surface the consequence; the edge fix is #695's, not ours. - **Scope creep into cross-change audit** ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)) → explicitly phase 2; intra-change first. - **Skill drift back to hardcoding** → covered by a test asserting the update template contains no literal `proposal`/`specs`/`design`/`tasks` artifact-name branching (it must read ids from JSON). @@ -73,7 +84,7 @@ Purely additive. New graph methods, new optional status fields/flag, one new ski ## Open Questions 1. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)).** When `openspec/config.yaml` or a project guideline changes, the user wants *every active change* re-audited. Phase 2: `openspec status --impact` generalizes, but "config changed → which changes are stale" needs a dependency from changes to project config. Worth a follow-up proposal. -2. **Staleness signal source.** mtime now; revisit if [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) lands lifecycle timestamps, or add an optional `--since ` git-aware mode. -3. **Targeted-mode entry point.** Should the skill infer the changed artifact from git diff / mtime automatically, or always ask? Lean: offer the stale set as defaults, let the user confirm/override. +2. **Land the digest ledger now, or fast-follow?** The deterministic spine + targeted flow ship without it; the ledger (Decision 3) only powers unattended audit and adds a `ChangeMetadataSchema` field plus a record step in the generating flows. Recommend deciding scope with Tabish — minimal change (spine only) vs. full (spine + ledger). +3. **Targeted-mode entry point.** Should the skill infer the changed artifact from `git diff` (deterministic for committed files) and confirm, or always ask? Lean: when a baseline exists, default to the drifted set; otherwise ask. 4. **Relationship to `/opsx:apply`'s mid-flight edits.** `apply` already re-reads artifacts each run; should `/opsx:update` be invocable *from within* an apply session, or strictly before it? Lean: standalone, with apply pointing to it when it detects upstream drift. 5. **Retire hardcoded patterns in `continue`/`ff` now or later?** ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)) Doing it here widens scope; deferring risks the new skill being the only graph-driven one. Recommend a fast-follow change. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index c09b0dce02..85cccfe438 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -22,25 +22,25 @@ Verified against `Fission-AI/OpenSpec` on 2026-06-29. Six angles, one gap: ### The root cause, concretely -`ArtifactGraph` already computes the reverse-edge `dependents` map internally while topologically sorting ([src/core/artifact-graph/graph.ts:98](../../../src/core/artifact-graph/graph.ts)) — then discards it. Every query the engine exposes — `getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete` — answers *"what should I build next?"* (forward). Nothing answers *"I changed X — what downstream of X is now stale?"* (backward). The update action is missing not because it is hard, but because **the one graph query it needs was never surfaced.** The graph already knows how artifacts relate; we just read it the other direction. +The backward read is almost already built. `ArtifactGraph.getBuildOrder()` constructs the full reverse-adjacency `dependents` map while topologically sorting ([src/core/artifact-graph/graph.ts:82-87](../../../src/core/artifact-graph/graph.ts)) — then discards it at function exit. The *direct* dependents query even exists already under another name: `getUnlockedArtifacts()` ([instruction-loader.ts:366](../../../src/core/artifact-graph/instruction-loader.ts)) computes "who requires this artifact," and `openspec instructions --json` already returns it as `unlocks`. What is missing is only (a) promoting that to a first-class, *transitive* graph query, and (b) ordering the result by the build order the engine already computes ([instruction-loader.ts:429](../../../src/core/artifact-graph/instruction-loader.ts)). Every other engine query — `getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete` — answers *"what do I build next?"* (forward). None answers *"I changed X — which downstream artifacts must be revisited, in what order?"* (backward). That backward set is **deterministic** — a pure function of the schema's `requires` edges and the files on disk — which is exactly why it belongs in the CLI, not in an agent's judgment. There is a stub already in the repo — `openspec/changes/add-artifact-regeneration-support/proposal.md` (proposal-only, no specs or tasks) — which identifies staleness detection and regeneration but proposes tracking dependencies by hardcoded filename and metadata files. **This change supersedes that stub** with a graph-driven design and the user-facing `/opsx:update` command the cluster is actually asking for. ## What Changes -A new **`update`** action, implemented as a thin skill over one new graph query — consistent with the architectural direction of [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (deterministic logic in the CLI, skills as thin wrappers). Ordered by leverage: +The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) and the steer to make this *deterministic and grounded in reality*: **the CLI decides which files to revisit and in what order (deterministic, reproducible); the agent only rewrites prose (the irreducibly semantic part).** Everything that can be a pure function of the schema graph and the bytes on disk is computed by the CLI and verified by tests, not left to agent judgment. Ordered by leverage: -1. **FOUNDATION — expose the reverse edges (`artifact-graph`).** Surface the `dependents` map the engine already builds, plus a transitive, topologically-ordered downstream query. Given any artifact id, the graph answers "what depends on this, directly and transitively, in the order they should be revisited." This is the single primitive the whole feature stands on, and it is schema-agnostic by construction — it reads `requires` edges, never artifact names. +1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* -2. **SIGNAL — staleness along graph edges (`artifact-graph`).** An artifact is *stale* when any upstream it `requires` (transitively) was modified more recently than it. Computed by comparing filesystem modification times along the **explicit `requires` edges** — no metadata file, no pattern matching (per [openspec/config.yaml](../../config.yaml): "specify by explicit list lookup, not pattern matching"). Staleness is **advisory** — a cheap signal that points the audit at likely-incoherent edges; semantic confirmation is the skill's job, and the skill always asks before changing anything. +2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a newline-normalized SHA-256 of its output bytes (normalized so the digest is identical on Windows and POSIX). Digests are exposed on `openspec status --json`. *Drift* — "this downstream no longer reflects the upstream it was generated from" — is then defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline (a small per-change digest ledger) is a clearly-separable layer; until a baseline exists, drift is reported as **unknown**, never guessed (no false positives). See design for the ledger mechanism and why mtime/git were rejected. -3. **SURFACE — graph edges and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains per-artifact `requires`, `dependents`, and `stale`/`staleAgainst` fields, and a focused `openspec status --change --impact --json` returns the downstream set in revisit order. The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. +3. **SURFACE — graph edges, digests, and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains, per artifact, `requires`, `dependents`, and `digest`; a focused `openspec status --change --impact --json` returns the downstream revisit set (ordered, with paths and digests). The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. Default human-readable output is unchanged. -4. **COMMAND — the `/opsx:update` skill (`opsx-update-skill`).** The user-facing action, in two modes: - - **Targeted** — "I changed (or want to change) artifact X." The skill reads the graph, walks X's downstream dependents in revisit order, and for each one reads it against its now-changed upstreams, proposes a concrete revision, asks, applies, and re-checks. A single coherent pass over **exactly the files the graph says are related** — the user's "cohesive audit." - - **Audit** — "is this change still internally coherent?" The skill scans every stale edge the CLI reports, presents them, and offers per-artifact fixes. This is the within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). +4. **COMMAND — the `/opsx:update` skill (`opsx-update-skill`).** A thin wrapper over the deterministic spine, in two modes: + - **Targeted** — "I changed (or want to change) artifact X." The skill asks the CLI for X's impact set, then for each downstream artifact *in the order the CLI returns* reads it against its now-changed upstreams, proposes a concrete revision, asks, applies, and re-checks. A single coherent pass over **exactly the files the graph says are related** — the user's "cohesive audit." The skill never computes the file list or the order itself. + - **Audit** — "is this change still internally coherent?" The skill asks the CLI which artifacts have drifted (digest vs. recorded baseline) and offers per-artifact fixes; where no baseline exists it surfaces the deterministic structural facts (which declared capabilities lack specs, which outputs are empty/missing) and asks. The within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). - Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — it uses schema artifact ids from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. + Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — ids and order come from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. The naming boundary is deliberate: `openspec update` is already taken (it regenerates AI tool/skill files — [src/cli/index.ts:202](../../../src/cli/index.ts)). The artifact-update action is therefore the **skill** `/opsx:update` plus **read-only** additions to `openspec status`; this change adds **no** new top-level `openspec update*` CLI verb. (See design for the considered alternatives.) @@ -48,23 +48,24 @@ The naming boundary is deliberate: `openspec update` is already taken (it regene ### New Capabilities -- `opsx-update-skill`: A new `/opsx:update` workflow skill that propagates a change to one artifact across its downstream dependents (targeted mode) or audits a whole change for incoherent/stale artifacts (audit mode), driven entirely by the schema's artifact graph, editing planning artifacts only and never code, and confirming each edit with the user. +- `opsx-update-skill`: A new `/opsx:update` workflow skill that propagates a change to one artifact across its downstream dependents (targeted mode) or audits a whole change for drifted/incoherent artifacts (audit mode), driven entirely by the CLI-computed artifact graph (file list and order), editing planning artifacts only and never code, and confirming each edit with the user. ### Modified Capabilities -- `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a staleness signal computed along `requires` edges from filesystem modification times — the primitives an update/audit traversal needs, all schema-agnostic. -- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`) and a staleness signal (`stale`, `staleAgainst`); a new `--impact ` selector returns the downstream revisit set in order, so skills consume graph structure as data instead of hardcoding artifact names. +- `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a deterministic, newline-normalized content `digest` per artifact — the primitives an update/audit traversal needs, all schema-agnostic and reproducible across platforms. +- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`) and a content `digest`; a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), so skills consume deterministic graph structure as data instead of hardcoding artifact names. ## Impact -- `src/core/artifact-graph/graph.ts` — expose `getDependents(id)` (direct, from the map already built at line 98) and `getDownstream(id)` (transitive, topologically ordered); both throw on unknown id. -- `src/core/artifact-graph/state.ts` + `outputs.ts` — staleness computation: resolve each artifact's output mtime (newest match for glob outputs) and compare against the newest mtime among its transitive `requires`; return `{ stale, staleAgainst[] }` per artifact. Missing/empty outputs are "not present," not "stale." -- `src/core/artifact-graph/instruction-loader.ts` + `src/commands/workflow/instructions.ts` (status path) — add `requires`, `dependents`, `stale`, `staleAgainst` to per-artifact status JSON; add the `--impact ` selector returning the ordered downstream set; human-readable output unchanged by default. -- `src/cli/index.ts` — register the `--impact ` option on `status`. +- `src/core/artifact-graph/graph.ts` — expose `getDependents(id)` (promotes the reverse-adjacency loop at lines 82-87, and the existing `getUnlockedArtifacts` logic) and `getDownstream(id)` (transitive, ordered by the existing `getBuildOrder`); both throw on unknown id. +- `src/core/artifact-graph/outputs.ts` (new digest helper) — `artifactDigest(changeDir, generates)`: read `resolveArtifactOutputs` files, normalize newlines (CRLF→LF) for cross-platform stability, SHA-256 over the concatenation in the already-deterministic sorted path order; absent output → no digest. (No content-hash utility exists in the repo today; only `crypto.randomUUID` in telemetry.) +- `src/core/artifact-graph/instruction-loader.ts` (`formatChangeStatus`, ~397-453) — add `requires`, `dependents`, `digest` to each `ArtifactStatus`; the build-order sort at line 429 already gives revisit order. +- `src/commands/workflow/status.ts` (`StatusOptions`, `statusCommand`) + `src/cli/index.ts:488` — add the `--impact ` option returning the ordered downstream set (paths + digests); error on unknown artifact id; default human-readable output unchanged. +- (Optional, separable) `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact upstream-digest ledger to make *drift* (not just structure) deterministic; see design. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. - `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts` but reading graph edges from the CLI rather than embedding artifact-name patterns. - Skill/command registration + profile wiring (the expanded-workflow profile that already lists `continue`, `ff`, `verify`, …) so `/opsx:update` installs alongside its siblings; docs/opsx.md command table gains a `/opsx:update` row. - `openspec/changes/add-artifact-regeneration-support/` — superseded by this change (see below); retire or fold its staleness notes into design. -- Cross-platform tests (graph queries, mtime staleness with `path.join`, skill template generation) and Windows CI per [openspec/config.yaml](../../config.yaml). +- Cross-platform tests (graph queries, newline-normalized digest stability across CRLF/LF, `path.join` paths, skill template generation) and Windows CI per [openspec/config.yaml](../../config.yaml). ## Issues addressed @@ -83,7 +84,7 @@ Answers (workflow questions whose honest answer today is "no command exists"): Supersedes: -- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by a graph-driven design and the user-facing command. Its staleness-detection idea is preserved (graph-edge mtimes); its hardcoded-filename/metadata-file mechanism is dropped. +- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by a graph-driven design and the user-facing command. Its change-detection intent is preserved but made deterministic (content digests, not the stub's mtime/metadata-file mechanism); its hardcoded-filename dependency tracking is dropped. Related, addressed-in-part: @@ -94,5 +95,5 @@ Related, addressed-in-part: Related, out of scope (referenced, not closed): - [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084), [#906](https://github.com/Fission-AI/OpenSpec/issues/906) — completion state is existence-only, so an interrupted/partial artifact reads as "done." Audit mode can *surface* a suspiciously-stale or empty artifact, but content-level completion validation is a distinct change. -- [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. Would give staleness a more robust signal than mtime; complementary, tracked separately. +- [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. Orthogonal to the content-digest signal here (digests detect *content* drift; timestamps record *when*); complementary, tracked separately. - [#339](https://github.com/Fission-AI/OpenSpec/issues/339) — proposal-time codebase exploration; orthogonal. diff --git a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md index 58dd1b9024..50f280484b 100644 --- a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md +++ b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md @@ -43,32 +43,32 @@ The system SHALL compute the transitive set of artifacts that depend on a given - **WHEN** getDownstream("A") is called - **THEN** the result does not include A -### Requirement: Artifact Staleness Detection +### Requirement: Artifact Content Digest -The system SHALL detect whether an artifact is stale relative to its dependencies by comparing filesystem modification times along the schema's `requires` edges. An artifact is stale when its output was last modified before the most recently modified output among the artifacts it transitively requires. +The system SHALL compute a deterministic content digest for an artifact from the bytes of its output file(s), such that the same content yields the same digest on every run and on every platform. The digest SHALL normalize line endings before hashing so that otherwise-identical content produces an identical digest regardless of CRLF or LF encoding. -#### Scenario: Upstream modified after downstream +#### Scenario: Identical content yields identical digest -- **WHEN** an upstream dependency's output was modified more recently than an artifact that (transitively) requires it -- **THEN** the artifact is reported as stale -- **AND** the upstream is listed among the artifacts it is stale against +- **WHEN** an artifact's output content is unchanged between two computations +- **THEN** the digest is identical -#### Scenario: Artifact newer than all upstreams +#### Scenario: Changed content yields a different digest -- **WHEN** an artifact's output was modified more recently than every artifact it requires -- **THEN** the artifact is not reported as stale +- **WHEN** an artifact's output content changes +- **THEN** the digest changes -#### Scenario: Glob output uses newest matching file +#### Scenario: Line endings do not affect the digest -- **WHEN** an artifact generates a glob pattern (e.g. `specs/**/*.md`) with multiple files -- **THEN** staleness uses the most recently modified matching file as the artifact's modification time +- **WHEN** the same content is encoded with CRLF on one platform and LF on another +- **THEN** the digest is identical on both -#### Scenario: Missing output is not stale +#### Scenario: Glob output digests all matching files deterministically -- **WHEN** an artifact's output does not exist on disk -- **THEN** the artifact is reported as not present rather than stale +- **WHEN** an artifact generates a glob pattern (e.g. `specs/**/*.md`) with multiple files +- **THEN** the digest is computed over the matching files in a deterministic order +- **AND** the digest is stable across runs -#### Scenario: Root artifact is never stale +#### Scenario: Missing output has no digest -- **WHEN** an artifact has no dependencies -- **THEN** it is never reported as stale +- **WHEN** an artifact's output does not exist on disk +- **THEN** no digest is reported for that artifact diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md index f908147bc0..618944ae77 100644 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -15,20 +15,24 @@ The system SHALL include each artifact's dependency edges in the `openspec statu - **WHEN** the change uses a custom schema with non-default artifact ids - **THEN** the `requires` and `dependents` edges in the status output use that schema's artifact ids -### Requirement: Status Includes Staleness Signal +### Requirement: Status Includes Content Digest -The system SHALL include a per-artifact staleness signal in the `openspec status --json` output. +The system SHALL include a deterministic per-artifact content digest in the `openspec status --json` output, so consumers can detect content changes reproducibly without relying on filesystem timestamps. -#### Scenario: Stale artifact flagged in JSON +#### Scenario: Present artifact reports a digest -- **WHEN** an artifact's output is older than an upstream it transitively requires -- **THEN** that artifact's status JSON includes `stale: true` -- **AND** includes `staleAgainst` listing the upstream artifact ids it is stale against +- **WHEN** the user runs `openspec status --change --json` and an artifact's output exists +- **THEN** that artifact's status JSON includes a `digest` derived from its output content -#### Scenario: Fresh artifact reports not stale +#### Scenario: Digest is stable across runs -- **WHEN** an artifact is newer than all of its dependencies -- **THEN** its status JSON includes `stale: false` and an empty `staleAgainst` +- **WHEN** `openspec status --change --json` is run twice without the artifact's content changing +- **THEN** the artifact's `digest` is identical between runs + +#### Scenario: Missing output reports no digest + +- **WHEN** an artifact's output does not exist +- **THEN** the artifact's status JSON omits `digest` (or reports it as null) ### Requirement: Downstream Impact Query @@ -37,8 +41,13 @@ The system SHALL provide an impact selector on the status command that returns t #### Scenario: Impact returns ordered downstream set - **WHEN** the user runs `openspec status --change --impact --json` -- **THEN** the output lists the transitive downstream dependents of `` in topological order -- **AND** the listed artifacts include their resolved output paths so a consumer can read and rewrite them +- **THEN** the output lists the transitive downstream dependents of `` in topological (build) order +- **AND** the listed artifacts include their resolved output paths and content digests so a consumer can read and rewrite them + +#### Scenario: Impact ordering is deterministic + +- **WHEN** the impact query is run repeatedly for the same change and artifact +- **THEN** the downstream set and its order are identical every time #### Scenario: Impact on a leaf artifact diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index def361eef3..e96618ba2a 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -32,6 +32,12 @@ The `/opsx:update` skill SHALL determine which artifacts are related, in what or - **THEN** the skill obtains that artifact's downstream dependents and their revisit order from the CLI (`openspec status --impact --json`) - **AND** it reviews each downstream artifact against its now-changed upstreams, in that order +#### Scenario: Does not compute the file list or order itself + +- **WHEN** the skill needs to know which artifacts are affected and in what order +- **THEN** it uses the set and order returned by the CLI +- **AND** it does not enumerate, order, or filter artifacts by its own logic or by assumed artifact names + #### Scenario: Works for a custom schema - **WHEN** the active change uses a custom schema whose artifact ids are not `proposal`/`specs`/`design`/`tasks` @@ -46,17 +52,23 @@ The `/opsx:update` skill SHALL determine which artifacts are related, in what or ### Requirement: Cohesive Audit Mode -The `/opsx:update` skill SHALL support an audit mode that reviews a whole change for artifacts that are stale or incoherent relative to their upstream dependencies and offers to fix them. +The `/opsx:update` skill SHALL support an audit mode that reviews a whole change for artifacts that have drifted from or are incoherent with their upstream dependencies and offers to fix them, using the deterministic signals the CLI provides rather than its own heuristics. + +#### Scenario: Audit reports drifted artifacts when a baseline exists + +- **WHEN** the user invokes `/opsx:update` in audit mode and a recorded digest baseline exists +- **THEN** the skill uses the CLI's drift report (current upstream digest vs. recorded baseline) to identify which downstream artifacts to review +- **AND** it presents them in revisit order -#### Scenario: Audit reports stale edges +#### Scenario: Audit falls back to structural facts without a baseline -- **WHEN** the user invokes `/opsx:update` in audit mode (no specific target artifact) -- **THEN** the skill reads the per-artifact staleness signal from `openspec status --json` -- **AND** it presents the stale artifacts and the upstreams they are stale against, in revisit order +- **WHEN** no digest baseline has been recorded for the change +- **THEN** the skill does not guess at staleness +- **AND** it surfaces the deterministic structural facts the CLI reports (e.g. a declared capability without a spec, an empty or missing output) and asks the user how to proceed #### Scenario: Audit offers per-artifact fixes -- **WHEN** audit mode finds one or more stale or incoherent artifacts +- **WHEN** audit mode identifies one or more artifacts to revise - **THEN** the skill proposes a concrete revision for each, in revisit order - **AND** it applies a revision only after the user confirms it diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 519fe0856c..7844922f5c 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -1,45 +1,53 @@ # Tasks: `/opsx:update` — graph-driven artifact update -> Sequenced so each layer is independently testable: graph primitives → CLI surface → skill → docs/cleanup. Cross-platform (`path.join`, mtime) and Windows CI are called out where paths are involved, per `openspec/config.yaml`. +> Sequenced so each layer is independently testable: deterministic graph/digest primitives → CLI surface → skill → docs/cleanup. The deterministic spine (sections 1–3) ships the primary targeted flow; the optional digest ledger (section 7) is separable and gated on scope (see design Open Questions). Cross-platform (`path.join`, newline-normalized digests) and Windows CI are called out where relevant, per `openspec/config.yaml`. -## 1. Artifact graph: reverse traversal +## 1. Artifact graph: reverse traversal (deterministic) -- [ ] 1.1 Factor the reverse-edge `dependents` map (currently built and discarded inside `getBuildOrder()`, `graph.ts:98`) into a memoized field shared by forward and backward queries. +- [ ] 1.1 Factor the reverse-adjacency `dependents` map (built and discarded inside `getBuildOrder()`, graph.ts:82-87; same relation `getUnlockedArtifacts` computes for one node) into a shared helper. - [ ] 1.2 Add `getDependents(id)` — direct dependents; throw a descriptive error on unknown id. -- [ ] 1.3 Add `getDownstream(id)` — transitive dependents in topological order, excluding `id`; throw on unknown id. -- [ ] 1.4 Unit tests: linear chain, diamond, leaf (empty), unknown-id error, self-exclusion (mirror existing `artifact-graph` spec scenarios). +- [ ] 1.3 Add `getDownstream(id)` — transitive dependents ordered by the existing `getBuildOrder()`, excluding `id`; throw on unknown id. +- [ ] 1.4 Unit tests: linear chain, diamond ordering, leaf (empty), unknown-id error, self-exclusion; assert results are identical across repeated calls (determinism). -## 2. Artifact graph: staleness signal +## 2. Artifact graph: content digest (deterministic, grounded) -- [ ] 2.1 In `outputs.ts`, add a helper to resolve an artifact's output modification time (newest matching file for glob outputs; absent when no output exists). Use `path.join`; no hardcoded separators. -- [ ] 2.2 In `state.ts`, add staleness computation: an artifact is stale when its output mtime is older than the newest output mtime among its transitive `requires`; return `{ stale, staleAgainst[] }` per artifact. Missing output → not present (not stale); root artifact → never stale. -- [ ] 2.3 Unit tests including a Windows path case: upstream-newer, downstream-newer, glob newest-file, missing output, root artifact. +- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: read the files from `resolveArtifactOutputs` (already sorted deterministically), normalize newlines (CRLF→LF), SHA-256 over the concatenation; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) +- [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest** (cross-platform); glob multi-file determinism; missing output → undefined. -## 3. CLI: surface edges, staleness, and impact on `status` +## 3. CLI: surface edges, digest, and impact on `status` -- [ ] 3.1 Extend the status JSON builder (`instruction-loader.ts` + `src/commands/workflow/instructions.ts` status path) to add `requires`, `dependents`, `stale`, `staleAgainst` to each artifact. -- [ ] 3.2 Add the `--impact ` option to `status` (`src/cli/index.ts`); when set, return the ordered downstream set with each artifact's resolved output path; error on unknown artifact id. +- [ ] 3.1 Extend `formatChangeStatus` (instruction-loader.ts, the `ArtifactStatus` objects ~397-426) to add `requires`, `dependents`, and `digest` per artifact; the existing build-order sort (line 429) already yields revisit order. +- [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths + digest; error on unknown artifact id. - [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. -- [ ] 3.4 Tests: JSON edge fields, stale/staleAgainst, `--impact` ordering, `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. +- [ ] 3.4 Tests: JSON edge fields, per-artifact digest present/stable, `--impact` ordering + determinism (repeat-run equality), `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. -## 4. The `/opsx:update` skill +## 4. The `/opsx:update` skill (thin wrapper over the deterministic spine) - [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. -- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → walk downstream via `--impact` → propose/confirm/apply/re-check, reading ids/paths/edges from JSON only. -- [ ] 4.3 Encode the two guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; use schema ids from the CLI. +- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/re-check, reading ids/paths/edges/digests from JSON only. +- [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks. - [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). - [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. -- [ ] 4.6 Test: template generation snapshot; assert the template contains NO hardcoded artifact-name branching (it must read ids from JSON) — the anti-#777 guard. +- [ ] 4.6 Test: template generation snapshot; assert the template contains NO hardcoded artifact-name branching and NO self-computed ordering (it must read ids/order from JSON) — the anti-#777 guard. ## 5. Supersede the stub & docs -- [ ] 5.1 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single, graph-driven update proposal; preserve its staleness idea in this change's design. +- [ ] 5.1 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single, graph-driven update proposal; preserve its staleness idea (now via content digest) in this change's design. - [ ] 5.2 Add a `/opsx:update` row to the command table in `docs/opsx.md`; add a short "Updating a change" usage section that uses targeted and audit modes. - [ ] 5.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. ## 6. End-to-end verification -- [ ] 6.1 E2E: create a spec-driven change, edit `proposal.md`, run the impact/audit surface, confirm `specs`/`design`/`tasks` are reported stale in revisit order and code is never touched. +- [ ] 6.1 E2E: create a spec-driven change, edit `proposal.md`, run `openspec status --impact proposal --json`, assert the downstream set is exactly `[specs, design, tasks]` in build order with correct paths/digests, and that code is never touched by the skill flow. - [ ] 6.2 E2E with a custom (non-default ids) schema fixture: confirm propagation works with no skill changes. - [ ] 6.3 Full validation: `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. - [ ] 6.4 Run the suite on macOS, Linux, and Windows CI. + +## 7. (Optional, separable) Deterministic drift baseline — the digest ledger + +> Only if scoped in (design Open Question 2). Powers unattended audit; the targeted flow above does not need it. + +- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional per-artifact map of recorded upstream digests. +- [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current upstream digests for the affected artifact. +- [ ] 7.3 CLI drift report: an artifact is drifted iff a current upstream digest differs from its recorded baseline; no baseline → `unknown` (never a false positive). +- [ ] 7.4 Tests: record→no-drift; modify upstream→drift on exactly the dependents; absent baseline→`unknown`; ledger round-trips through metadata read/write. From 58511fcdf3b368a19da972dcc868f68bb3bb9a02 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 15:03:34 -0500 Subject: [PATCH 03/11] docs(openspec): harden add-update-workflow determinism; drop direct name refs - Digest ledger tracks DIRECT upstream digests; document that transitive drift emerges hop-by-hop as downstream is reconciled (no transitive bookkeeping). - Ground audit's no-baseline structural facts on signals available in this branch (missing/empty output, blocked/incomplete); capability-coverage is an add-on only when #1277's validateChangeCapabilityCoverage is present. - Add the "update revises only existing downstream; defer not-yet-created ones to /opsx:continue" rule across proposal/design/specs/tasks; impact entries now carry existence/status. - Note artifact-level (not file-level) granularity and that getDownstream terminates by the schema's acyclic guarantee. - Remove direct personal references from the docs. Validates clean under `openspec validate add-update-workflow --strict`; 10 deltas. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/add-update-workflow/design.md | 16 +++++++++++----- openspec/changes/add-update-workflow/proposal.md | 2 +- .../specs/cli-artifact-workflow/spec.md | 6 ++++++ .../specs/opsx-update-skill/spec.md | 8 +++++++- openspec/changes/add-update-workflow/tasks.md | 12 ++++++------ 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 3eb9c86314..18eb86ea93 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -30,6 +30,8 @@ Add `getDependents(id)` (direct dependents) and `getDownstream(id)` (transitive The result is **deterministic**: a pure function of `requires` edges and the change directory's files, identical on every run and platform, and therefore unit-testable (see tasks). That determinism is the reason it belongs in the CLI rather than the skill — and *not* computing it in the skill is precisely how we avoid the #777 hardcoding class. +Granularity is the **artifact** (the schema's unit), not the individual file. Editing one file under a glob artifact (e.g. one `specs//spec.md` within the `specs` artifact) means the `specs` artifact changed, and its dependents (`tasks`) are downstream; sibling spec files are not "downstream" of each other. `getDownstream` terminates because the schema loader already rejects cyclic graphs (`artifact-graph`'s "Cyclic dependencies detected"), so the closure is finite by construction. + ### 2. A grounded signal: content digests, not mtimes The earlier draft detected staleness from filesystem mtimes. **Rejected.** mtime is not grounded in reality: `git checkout`/`clone`/`stash`, `touch`, editors, and formatters all rewrite mtime without changing content, and content can change while mtime is preserved (`git` does not restore historical mtimes). It is neither reproducible nor a true content signal — the opposite of what we want. @@ -37,10 +39,14 @@ Instead, each artifact carries a **content digest**: `sha256(normalizeNewlines(c **Drift** is then defined deterministically: a downstream artifact *D* has drifted against upstream *U* iff `digest(U_now) ≠ digest(U_recorded_when_D_was_reconciled)`. The recorded baseline is a small per-change **digest ledger** (Decision 3). Until a baseline is recorded, drift is reported as **`unknown`**, never inferred — so the audit produces zero false positives, in contrast to the mtime heuristic which produced many. -[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (declared-capability-without-spec, empty/missing output) and the user-named targeted flow, which need no baseline. +[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks already available from `status` (missing/empty output, blocked/incomplete artifact) and the user-named targeted flow, which need no baseline. ### 3. The digest ledger (deterministic drift baseline) — separable layer -To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its upstream outputs at the moment it was last reconciled. Writing the ledger is itself a deterministic CLI operation (`openspec status` can record on read, or an explicit `--record`), invoked by the generating flows (`propose`/`continue`/`ff`) and by `/opsx:update` after each confirmed edit — so the agent triggers a deterministic write, it does not compute hashes. This layer is **cleanly separable**: the deterministic spine (Decision 1) and the digest signal (Decision 2) deliver the primary targeted flow without it; the ledger only powers unattended audit. It is a candidate to land in a fast-follow if Tabish prefers to keep this change's surface minimal (Open Questions). +To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. Writing the ledger is itself a deterministic CLI operation (`openspec status` can record on read, or an explicit `--record`), invoked by the generating flows (`propose`/`continue`/`ff`) and by `/opsx:update` after each confirmed edit — so the agent triggers a deterministic write, it does not compute hashes. + +Tracking **direct** (not transitive) upstreams is deliberate and makes drift compose correctly through the DAG: if `proposal` changes, `specs` drifts (its recorded `proposal` digest no longer matches); reconciling `specs` rewrites its content, which changes `specs`'s own digest, which in turn makes `tasks` drift. Transitive staleness therefore emerges one hop at a time as the user works downstream — no transitive bookkeeping required, and the order matches the revisit order from Decision 1. + +This layer is **cleanly separable**: the deterministic spine (Decision 1) and the digest signal (Decision 2) deliver the primary targeted flow without it; the ledger only powers unattended audit. It is a candidate to land in a fast-follow if we prefer to keep this change's surface minimal (Open Questions). *Alternatives rejected:* (a) mtime — see Decision 2. (b) Pure git diff — deterministic only for committed files, but planning artifacts are routinely uncommitted while being drafted, so it misses the common case. Digests work regardless of git state. @@ -53,8 +59,8 @@ The CLI owns every decision that is a function of the graph and the bytes: **whi 1. Resolve the change (infer from context or prompt via `openspec list --json`, like `/opsx:continue`). 2. `openspec status --change --json` → artifacts with `requires`, `dependents`, `digest`, resolved paths. 3. Pick mode: - - **Targeted**: user names the artifact they changed / want to change (or the skill infers it and confirms). `openspec status --impact --json` → ordered downstream set. For each downstream artifact *in the CLI-given order*: read it + its changed upstreams, propose a concrete diff, **ask**, apply, re-check. - - **Audit**: no target → ask the CLI which artifacts have drifted (digest vs. recorded baseline); where no baseline exists, present the deterministic structural facts (declared-capability-without-spec, empty/missing output) and ask. Offer per-artifact fixes in revisit order. + - **Targeted**: user names the artifact they changed / want to change (or the skill infers it and confirms). `openspec status --impact --json` → ordered downstream set. For each *existing* downstream artifact *in the CLI-given order*: read it + its changed upstreams, propose a concrete diff, **ask**, apply, re-check. Downstream artifacts that do not exist yet are not invented here — the skill notes them and points to `/opsx:continue` (update revises; continue creates). + - **Audit**: no target → ask the CLI which artifacts have drifted (digest vs. recorded baseline); where no baseline exists, present the deterministic structural facts already available from `status` — an artifact whose output is missing or empty, or one still `blocked`/incomplete — and ask. (When #1277's `validateChangeCapabilityCoverage` is present, declared-capability-without-spec joins this set.) Offer per-artifact fixes in revisit order. 4. Stop at the planning boundary. Never edit code. If a spec/tasks change implies code rework, point to `/opsx:apply`. **Intent-change guard.** If the user's revision changes the *intent* (not a refinement), apply the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216-300](../../../docs/opsx.md)) and recommend `/opsx:new` rather than mutating the proposal into different work. @@ -84,7 +90,7 @@ Purely additive. New graph methods, new optional status fields/flag, one new ski ## Open Questions 1. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)).** When `openspec/config.yaml` or a project guideline changes, the user wants *every active change* re-audited. Phase 2: `openspec status --impact` generalizes, but "config changed → which changes are stale" needs a dependency from changes to project config. Worth a follow-up proposal. -2. **Land the digest ledger now, or fast-follow?** The deterministic spine + targeted flow ship without it; the ledger (Decision 3) only powers unattended audit and adds a `ChangeMetadataSchema` field plus a record step in the generating flows. Recommend deciding scope with Tabish — minimal change (spine only) vs. full (spine + ledger). +2. **Land the digest ledger now, or fast-follow?** The deterministic spine + targeted flow ship without it; the ledger (Decision 3) only powers unattended audit and adds a `ChangeMetadataSchema` field plus a record step in the generating flows. Recommend deciding scope before build-out — minimal change (spine only) vs. full (spine + ledger). 3. **Targeted-mode entry point.** Should the skill infer the changed artifact from `git diff` (deterministic for committed files) and confirm, or always ask? Lean: when a baseline exists, default to the drifted set; otherwise ask. 4. **Relationship to `/opsx:apply`'s mid-flight edits.** `apply` already re-reads artifacts each run; should `/opsx:update` be invocable *from within* an apply session, or strictly before it? Lean: standalone, with apply pointing to it when it detects upstream drift. 5. **Retire hardcoded patterns in `continue`/`ff` now or later?** ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)) Doing it here widens scope; deferring risks the new skill being the only graph-driven one. Recommend a fast-follow change. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index 85cccfe438..0003caf91d 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -30,7 +30,7 @@ There is a stub already in the repo — `openspec/changes/add-artifact-regenerat The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) and the steer to make this *deterministic and grounded in reality*: **the CLI decides which files to revisit and in what order (deterministic, reproducible); the agent only rewrites prose (the irreducibly semantic part).** Everything that can be a pure function of the schema graph and the bytes on disk is computed by the CLI and verified by tests, not left to agent judgment. Ordered by leverage: -1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* +1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths and existence status. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. Update revises the downstream artifacts that **exist**; ones not yet created are deferred to `/opsx:continue` (update revises, continue creates). This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* 2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a newline-normalized SHA-256 of its output bytes (normalized so the digest is identical on Windows and POSIX). Digests are exposed on `openspec status --json`. *Drift* — "this downstream no longer reflects the upstream it was generated from" — is then defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline (a small per-change digest ledger) is a clearly-separable layer; until a baseline exists, drift is reported as **unknown**, never guessed (no false positives). See design for the ledger mechanism and why mtime/git were rejected. diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md index 618944ae77..fae71d5b7c 100644 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -49,6 +49,12 @@ The system SHALL provide an impact selector on the status command that returns t - **WHEN** the impact query is run repeatedly for the same change and artifact - **THEN** the downstream set and its order are identical every time +#### Scenario: Impact entries indicate which downstream artifacts exist + +- **WHEN** some transitive downstream artifacts have not been created yet +- **THEN** each impact entry reports its status (e.g. done vs. not-yet-created) +- **AND** a consumer can tell which downstream artifacts exist to revise versus which would need to be created + #### Scenario: Impact on a leaf artifact - **WHEN** the impact selector targets an artifact with no dependents diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index e96618ba2a..b1d244454d 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -16,6 +16,12 @@ The system SHALL provide a `/opsx:update` workflow skill that revises a change's - **THEN** the skill updates that artifact and only its already-existing downstream dependents - **AND** it does NOT create any artifact that does not yet exist (that remains the job of `/opsx:continue`/`/opsx:propose`) +#### Scenario: Absent downstream artifacts are deferred to continue + +- **WHEN** the impact set includes downstream artifacts that have not been created yet +- **THEN** the skill revises only the downstream artifacts that currently exist +- **AND** it notes the not-yet-created downstream artifacts and points the user to `/opsx:continue` to create them + #### Scenario: Update stays within the plan - **WHEN** revising artifacts would imply changes to implementation code @@ -64,7 +70,7 @@ The `/opsx:update` skill SHALL support an audit mode that reviews a whole change - **WHEN** no digest baseline has been recorded for the change - **THEN** the skill does not guess at staleness -- **AND** it surfaces the deterministic structural facts the CLI reports (e.g. a declared capability without a spec, an empty or missing output) and asks the user how to proceed +- **AND** it surfaces the deterministic structural facts the CLI reports (an artifact whose output is missing or empty, or one still blocked/incomplete) and asks the user how to proceed #### Scenario: Audit offers per-artifact fixes diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 7844922f5c..b5affc5ea1 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -17,7 +17,7 @@ ## 3. CLI: surface edges, digest, and impact on `status` - [ ] 3.1 Extend `formatChangeStatus` (instruction-loader.ts, the `ArtifactStatus` objects ~397-426) to add `requires`, `dependents`, and `digest` per artifact; the existing build-order sort (line 429) already yields revisit order. -- [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths + digest; error on unknown artifact id. +- [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths, digest, and existence/status (so consumers can tell which exist to revise vs. which would need creating); error on unknown artifact id. - [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. - [ ] 3.4 Tests: JSON edge fields, per-artifact digest present/stable, `--impact` ordering + determinism (repeat-run equality), `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. @@ -25,7 +25,7 @@ - [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. - [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/re-check, reading ids/paths/edges/digests from JSON only. -- [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks. +- [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks; (d) revise only existing downstream artifacts — defer not-yet-created ones to `/opsx:continue`. - [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). - [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. - [ ] 4.6 Test: template generation snapshot; assert the template contains NO hardcoded artifact-name branching and NO self-computed ordering (it must read ids/order from JSON) — the anti-#777 guard. @@ -47,7 +47,7 @@ > Only if scoped in (design Open Question 2). Powers unattended audit; the targeted flow above does not need it. -- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional per-artifact map of recorded upstream digests. -- [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current upstream digests for the affected artifact. -- [ ] 7.3 CLI drift report: an artifact is drifted iff a current upstream digest differs from its recorded baseline; no baseline → `unknown` (never a false positive). -- [ ] 7.4 Tests: record→no-drift; modify upstream→drift on exactly the dependents; absent baseline→`unknown`; ledger round-trips through metadata read/write. +- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional per-artifact map of recorded **direct** upstream digests. +- [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current direct-upstream digests for the affected artifact ("reconciled"). +- [ ] 7.3 CLI drift report: an artifact is drifted iff a current direct-upstream digest differs from its recorded baseline; no baseline → `unknown` (never a false positive). Verify transitive drift emerges hop-by-hop: changing `proposal` drifts `specs`; reconciling `specs` then drifts `tasks`. +- [ ] 7.4 Tests: record→no-drift; modify upstream→drift on exactly the direct dependents; hop-by-hop transitive drift; absent baseline→`unknown`; ledger round-trips through metadata read/write. From 7dbd733dea5e1cd224c9e7d5bb89c5bf42aa9cf1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 15:11:27 -0500 Subject: [PATCH 04/11] docs(openspec): full issue/PR/discussion coverage + command-family design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a comprehensive sweep of open issues, PRs, and discussions, grounded the proposal in the complete adjacent landscape and answered the open design questions the cluster raises: - #783 (Cross-artifact quality review before apply) is now a primary Closes: it IS audit mode. Answer its open "new skill vs. extend validate" question via the determinism split — deterministic checks (drift/completeness/coverage) are CLI/validate-shaped; the semantic cross-artifact review is the skill. Added a skill spec scenario for the #783 patterns (scope contradiction, spec gap, duplication). - Discussion #1206 ("refine proposal now?") + prior-art PR #372: official answer is /opsx:update. - New design Decision 8 (command family): delineate /opsx:update from /opsx:clarify (#702, within-artifact), /opsx:review (#1251, plan-vs-code), and verify; /opsx:update consolidates update+regen+refine into one action, addressing skill-sprawl (#1263, #783). - Reuse, don't reinvent: audit's empty/incomplete check reuses #1098's artifactOutputComplete (same outputs.ts the digest helper lives in); capability coverage reuses #1277's validateChangeCapabilityCoverage. - New open questions: surface deterministic coherence in `validate` for a CI gate (#783-B, #829); naming reconciliation with #783's /opsx:refine. - Confirmed add-update-command* branches are the `openspec update` tool-file refresh (not artifact update) — no collision. Validates clean under --strict; 10 deltas; all relative links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 22 ++++++++++++++++--- .../changes/add-update-workflow/proposal.md | 20 ++++++++++++----- .../specs/opsx-update-skill/spec.md | 6 +++++ openspec/changes/add-update-workflow/tasks.md | 4 ++-- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 18eb86ea93..5cd3db995d 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -39,7 +39,7 @@ Instead, each artifact carries a **content digest**: `sha256(normalizeNewlines(c **Drift** is then defined deterministically: a downstream artifact *D* has drifted against upstream *U* iff `digest(U_now) ≠ digest(U_recorded_when_D_was_reconciled)`. The recorded baseline is a small per-change **digest ledger** (Decision 3). Until a baseline is recorded, drift is reported as **`unknown`**, never inferred — so the audit produces zero false positives, in contrast to the mtime heuristic which produced many. -[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks already available from `status` (missing/empty output, blocked/incomplete artifact) and the user-named targeted flow, which need no baseline. +[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (missing/empty output — reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s `artifactOutputComplete` rather than reinventing — and blocked/incomplete status) and the user-named targeted flow, which need no baseline. ### 3. The digest ledger (deterministic drift baseline) — separable layer To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. Writing the ledger is itself a deterministic CLI operation (`openspec status` can record on read, or an explicit `--record`), invoked by the generating flows (`propose`/`continue`/`ff`) and by `/opsx:update` after each confirmed edit — so the agent triggers a deterministic write, it does not compute hashes. @@ -72,8 +72,22 @@ The CLI owns every decision that is a function of the graph and the bytes: **whi Alternatives considered: (a) `openspec regen --from ` as #705 literally asks — rejected: a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. (b) `openspec update artifacts …` subcommand — rejected: still collides conceptually and splits the action across two surfaces. -### 7. Compose with #1277 -The status-JSON additions live in `formatChangeStatus` ([instruction-loader.ts](../../../src/core/artifact-graph/instruction-loader.ts)) and the `status` command ([src/commands/workflow/status.ts](../../../src/commands/workflow/status.ts)); [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches the same status path and adds deterministic capability-coverage helpers (`extractDeclaredCapabilities`, `validateChangeCapabilityCoverage`). Those helpers are **not in this branch's base** (main), so audit's structural checks should *reuse* them once #1277 lands rather than reimplement; the JSON additions here are purely additive fields and compose cleanly. +### 7. Compose with #1277 and #1098 +The status-JSON additions live in `formatChangeStatus` ([instruction-loader.ts](../../../src/core/artifact-graph/instruction-loader.ts)) and the `status` command ([src/commands/workflow/status.ts](../../../src/commands/workflow/status.ts)); [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches the same status path and adds deterministic capability-coverage helpers (`extractDeclaredCapabilities`, `validateChangeCapabilityCoverage`), and [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098) adds `artifactOutputComplete`/`artifactOutputContentValid` in `outputs.ts` — the exact file the digest helper (Decision 2) lives in. None are in this branch's base (main), so audit's structural checks should *reuse* them as they land rather than reimplement; the JSON additions here are purely additive fields and compose cleanly. + +### 8. The command family: one action, distinct surfaces +The tracker has accumulated several adjacent requests; good design means one coherent action, not five overlapping commands ([#1263](https://github.com/Fission-AI/OpenSpec/issues/1263) and #783 both flag skill-count growth). The boundaries: + +| Command | Scope | Reads | Writes | +|---|---|---|---| +| `/opsx:clarify` ([#702](https://github.com/Fission-AI/OpenSpec/pull/702)) | *within* one artifact (ambiguity Q&A) | one artifact | that artifact | +| **`/opsx:update`** (this) | *across* a change's planning artifacts (propagate + audit) | the artifact graph | planning artifacts | +| `/opsx:review` ([#1251](https://github.com/Fission-AI/OpenSpec/pull/1251)), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073) | plan *vs. code* | artifacts + code | nothing (read-only) | +| `/opsx:verify` | archive readiness | artifacts + code | nothing | + +`/opsx:update` deliberately **subsumes** the update/regen/refine cluster (#1188/#705/#673/#783/#1206) into its two modes instead of spawning `/opsx:regen`, `/opsx:rebuild`, and `/opsx:refine` separately. It composes with clarify (a within-artifact step that often precedes an update) and review (a code-side check that follows apply); it never overlaps their read/write surfaces. + +**Answering #783's form question (skill vs. extend `validate`).** Both, split by determinism: the checks that are pure functions of content and graph — drift (digests), structural completeness (#1098), capability coverage (#1277) — are CLI-shaped and are the natural content of a future schema-aware `openspec validate` coherence rule (aligns with [#829](https://github.com/Fission-AI/OpenSpec/issues/829)); the cross-artifact *semantic* review (scope contradictions, duplication with existing specs, missing abstractions) cannot be deterministic and is the skill's job. This change ships the `status` data + skill; surfacing the deterministic subset in `validate` for a CI gate is a scoped follow-up (Open Questions). ## Risks / Trade-offs @@ -94,3 +108,5 @@ Purely additive. New graph methods, new optional status fields/flag, one new ski 3. **Targeted-mode entry point.** Should the skill infer the changed artifact from `git diff` (deterministic for committed files) and confirm, or always ask? Lean: when a baseline exists, default to the drifted set; otherwise ask. 4. **Relationship to `/opsx:apply`'s mid-flight edits.** `apply` already re-reads artifacts each run; should `/opsx:update` be invocable *from within* an apply session, or strictly before it? Lean: standalone, with apply pointing to it when it detects upstream drift. 5. **Retire hardcoded patterns in `continue`/`ff` now or later?** ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)) Doing it here widens scope; deferring risks the new skill being the only graph-driven one. Recommend a fast-follow change. +6. **Surface the deterministic coherence checks in `openspec validate` too?** ([#783](https://github.com/Fission-AI/OpenSpec/issues/783) option B, [#829](https://github.com/Fission-AI/OpenSpec/issues/829)) A schema-aware `validate` drift/completeness rule would give a CI gate independent of the agent. Natural fit, but it overlaps #1277/#1098/#829 surfaces — recommend landing as a coordinated follow-up rather than widening this change. +7. **Naming.** [#783](https://github.com/Fission-AI/OpenSpec/issues/783) suggests `/opsx:refine` for the cross-artifact review. We keep `/opsx:update` as the umbrella (it is the missing first-class *action*, and covers both propagate and audit); "refine" reads as audit mode. If the team prefers `refine` for the audit half, it can alias the same skill — but two commands for one action reintroduces the sprawl #1263 flags. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index 0003caf91d..de61e7f3ad 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -38,7 +38,7 @@ The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSp 4. **COMMAND — the `/opsx:update` skill (`opsx-update-skill`).** A thin wrapper over the deterministic spine, in two modes: - **Targeted** — "I changed (or want to change) artifact X." The skill asks the CLI for X's impact set, then for each downstream artifact *in the order the CLI returns* reads it against its now-changed upstreams, proposes a concrete revision, asks, applies, and re-checks. A single coherent pass over **exactly the files the graph says are related** — the user's "cohesive audit." The skill never computes the file list or the order itself. - - **Audit** — "is this change still internally coherent?" The skill asks the CLI which artifacts have drifted (digest vs. recorded baseline) and offers per-artifact fixes; where no baseline exists it surfaces the deterministic structural facts (which declared capabilities lack specs, which outputs are empty/missing) and asks. The within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). + - **Audit** — "is this change still internally coherent?" The pre-apply cross-artifact review of [#783](https://github.com/Fission-AI/OpenSpec/issues/783): the skill asks the CLI which artifacts have drifted (digest vs. recorded baseline) and which are structurally incomplete (reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s completeness check and, when present, [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277)'s capability coverage), then performs the semantic review those deterministic signals can't (scope contradictions, spec gaps, duplication) and offers per-artifact fixes. The split is the answer to #783's "skill vs. validate" question: deterministic checks in the CLI, semantic review in the skill. Also the within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — ids and order come from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. @@ -77,23 +77,33 @@ Closes (the missing-update-action family): - [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Support Rebuilding Downstream Artifacts from Modified Intermediate Files." The requested `regen --from ` becomes graph-driven downstream propagation in targeted mode. - [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — the "clarify" request: update existing artifacts without auto-generating the next one. `/opsx:update` revises in place and never advances the build frontier. - [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "utility to review and update all change proposals." Audit mode delivers the within-a-change form; cross-change audit is the scoped phase-2 extension (see design Open Questions). +- [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — "Cross-artifact quality review between propose/ff and apply." This is exactly audit mode: a pre-apply pass that catches cross-artifact contradictions (proposal scope vs. design Non-Goals), spec gaps, and duplication. The issue's open form question — new skill (A) vs. extend `openspec validate` (B) — is answered by the determinism split: the *deterministic* checks (content drift, structural completeness, capability coverage) are CLI/`validate`-shaped; the *semantic* cross-artifact review is the skill. See design. Answers (workflow questions whose honest answer today is "no command exists"): - [#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618) — "which command regenerates a document after the flow progressed / after apply?" → `/opsx:update`. +- Discussion [#1206](https://github.com/Fission-AI/OpenSpec/discussions/1206) ("Is there a good solution for refine proposal now?", links #1188 and the closed prior-art PR [#372](https://github.com/Fission-AI/OpenSpec/pull/372)) — the official answer becomes `/opsx:update`. Supersedes: - `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by a graph-driven design and the user-facing command. Its change-detection intent is preserved but made deterministic (content digests, not the stub's mtime/metadata-file mechanism); its hardcoded-filename dependency tracking is dropped. +Delineated from adjacent commands/PRs (distinct surfaces — coordinate, don't collide): + +- [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` (open) — resolves ambiguity *within a single artifact* via Q&A. Complementary upstream step: clarify sharpens one artifact; `/opsx:update` then propagates the resulting change across its dependents. Different scope (intra- vs. cross-artifact). +- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review` (draft) and [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073) — review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* internally coherent and never touches code. Clean split at the planning/code boundary. +- [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098) (open) — adds `artifactOutputComplete`/`artifactOutputContentValid` in `outputs.ts` (the [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084) fix). Audit's "empty/incomplete" structural check **reuses** this rather than reinventing; the new digest helper lives in the same file and composes. +- Skill-count concern ([#1263](https://github.com/Fission-AI/OpenSpec/issues/1263), and #783's own note): `/opsx:update` *consolidates* update + regen + refine into one first-class action rather than adding several adjacent commands. + Related, addressed-in-part: -- [#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666) — hardcoded artifact patterns override the schema. The new skill is graph-driven by construction; retiring the same hardcoding in `continue-change.ts`/`ff-change.ts` is recommended as a fast follow (noted in design), not done here, to keep this change focused. -- [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (prevent-silent-spec-drop) — same architectural principle (deterministic CLI, thin skills) and touches `artifact-graph`/`instruction-loader`; coordinate so the status-JSON additions compose. +- [#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666), [#829](https://github.com/Fission-AI/OpenSpec/issues/829) — hardcoded artifact patterns / schema-driven enforcement. The new skill is graph-driven by construction; retiring the same hardcoding in `continue-change.ts`/`ff-change.ts`, and surfacing the deterministic coherence checks in schema-aware `validate`, are recommended follow-ups (design), not done here, to keep this change focused. +- [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (prevent-silent-spec-drop) — same architectural principle (deterministic CLI, thin skills) and touches `artifact-graph`/`instruction-loader`; coordinate so the status-JSON additions compose, and reuse its `validateChangeCapabilityCoverage` for one of audit's structural checks. - [#695](https://github.com/Fission-AI/OpenSpec/issues/695) — `design` requires only `proposal`, not `specs`. The downstream propagation is only as good as the `requires` edges; this change surfaces the consequence but the edge fix belongs to #695. Related, out of scope (referenced, not closed): -- [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084), [#906](https://github.com/Fission-AI/OpenSpec/issues/906) — completion state is existence-only, so an interrupted/partial artifact reads as "done." Audit mode can *surface* a suspiciously-stale or empty artifact, but content-level completion validation is a distinct change. +- [#846](https://github.com/Fission-AI/OpenSpec/issues/846) — file-based context/tracking persistence. The optional digest ledger is a minimal, deterministic instance of this; the broader tracking-file proposal is separate. +- [#999](https://github.com/Fission-AI/OpenSpec/discussions/999), discussion [#169](https://github.com/Fission-AI/OpenSpec/discussions/169) — AI over-editing / reconciling manual code edits. The planning-only guardrail addresses the plan side; reconciling manual *code* edits back into specs is the `/opsx:review` (#1251) direction, not this change. - [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. Orthogonal to the content-digest signal here (digests detect *content* drift; timestamps record *when*); complementary, tracked separately. -- [#339](https://github.com/Fission-AI/OpenSpec/issues/339) — proposal-time codebase exploration; orthogonal. +- [#906](https://github.com/Fission-AI/OpenSpec/issues/906), [#339](https://github.com/Fission-AI/OpenSpec/issues/339) — skill resumption and proposal-time codebase exploration; orthogonal. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index b1d244454d..6848aa5039 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -72,6 +72,12 @@ The `/opsx:update` skill SHALL support an audit mode that reviews a whole change - **THEN** the skill does not guess at staleness - **AND** it surfaces the deterministic structural facts the CLI reports (an artifact whose output is missing or empty, or one still blocked/incomplete) and asks the user how to proceed +#### Scenario: Audit performs cross-artifact semantic review + +- **WHEN** audit mode runs +- **THEN** in addition to the deterministic drift and structural signals, the skill reviews the change's artifacts against each other for cross-artifact incoherence that those signals cannot detect — for example a scope item present in the proposal but excluded in design, behavior specified only in design but absent from the specs, or a task duplicating an existing capability +- **AND** it presents such findings for the user to confirm before any edit + #### Scenario: Audit offers per-artifact fixes - **WHEN** audit mode identifies one or more artifacts to revise diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index b5affc5ea1..dd7ac96f83 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -11,7 +11,7 @@ ## 2. Artifact graph: content digest (deterministic, grounded) -- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: read the files from `resolveArtifactOutputs` (already sorted deterministically), normalize newlines (CRLF→LF), SHA-256 over the concatenation; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) +- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: read the files from `resolveArtifactOutputs` (already sorted deterministically), normalize newlines (CRLF→LF), SHA-256 over the concatenation; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. - [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest** (cross-platform); glob multi-file determinism; missing output → undefined. ## 3. CLI: surface edges, digest, and impact on `status` @@ -24,7 +24,7 @@ ## 4. The `/opsx:update` skill (thin wrapper over the deterministic spine) - [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. -- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/re-check, reading ids/paths/edges/digests from JSON only. +- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/re-check, reading ids/paths/edges/digests from JSON only. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. - [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks; (d) revise only existing downstream artifacts — defer not-yet-created ones to `/opsx:continue`. - [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). - [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. From d85730eac250df9c93cc0abb8239aaa6a9629758 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 15:23:44 -0500 Subject: [PATCH 05/11] docs(openspec): resolve open questions to committed decisions; drift in scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review steer, every open question is now a committed happy-path decision so build-out has no dangling forks, and the deterministic drift baseline is pulled into scope (it is what makes audit-mode drift deterministic vs. agent-guessed): - Digest ledger IN SCOPE (design Decision 3): per-artifact DIRECT upstream digests in ChangeMetadataSchema, written by a deterministic `openspec status --record`; pre-existing changes (no baseline) degrade to drift `unknown` + structural checks. Generating-flow auto-recording stays optional (graceful). - cli-artifact-workflow spec: folded drift into the digest requirement (record baseline / drift vs baseline / unknown-without-baseline) — stays at 10 deltas. - opsx-update-skill spec: skill records baseline via `--record` after each confirmed edit, so audits clear once reconciled. - Replaced "## Open Questions" with "## Decisions resolved": ledger in scope; targeted entry baseline-aware; apply stays standalone (points to update on drift); cross-change (#247), continue/ff de-hardcoding (#777), and validate CI-gate (#783-B/#829) are named follow-ups, not deferrals of the core feature; /opsx:update kept as the umbrella name. - Migration Plan + Capabilities + Impact + tasks updated; status JSON gains `drift`, CLI gains `--record`. Re-synced with upstream main (0 behind). Validates clean under --strict; 10 deltas; all links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 30 ++++++++++--------- .../changes/add-update-workflow/proposal.md | 8 ++--- .../specs/cli-artifact-workflow/spec.md | 19 ++++++++++-- .../specs/opsx-update-skill/spec.md | 6 ++++ openspec/changes/add-update-workflow/tasks.md | 9 +++--- 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 5cd3db995d..3359432368 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -20,7 +20,7 @@ The orchestrating skills currently embed `proposal → specs → design → task - Automatic, unattended regeneration (the user always chooses — same stance as the superseded stub). - A new top-level `openspec update*` CLI verb (name is taken; see Decision 4). - Content-level completion validation of artifacts ([#1084](https://github.com/Fission-AI/OpenSpec/issues/1084)) — distinct change. -- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — phase 2; see Open Questions. +- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — phase 2; see Decisions resolved. - Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. ## Decisions @@ -41,12 +41,12 @@ Instead, each artifact carries a **content digest**: `sha256(normalizeNewlines(c [Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (missing/empty output — reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s `artifactOutputComplete` rather than reinventing — and blocked/incomplete status) and the user-named targeted flow, which need no baseline. -### 3. The digest ledger (deterministic drift baseline) — separable layer -To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. Writing the ledger is itself a deterministic CLI operation (`openspec status` can record on read, or an explicit `--record`), invoked by the generating flows (`propose`/`continue`/`ff`) and by `/opsx:update` after each confirmed edit — so the agent triggers a deterministic write, it does not compute hashes. +### 3. The digest ledger (deterministic drift baseline) — in scope +To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. Writing the ledger is itself a deterministic CLI operation (an explicit `openspec status --record `), invoked by `/opsx:update` after each confirmed edit and available for the generating flows (`propose`/`continue`/`ff`) to call after they write an artifact — so the agent triggers a deterministic write, it never computes hashes. For changes that predate the ledger (no recorded baseline), drift is reported `unknown` and the audit falls back to the structural checks — honest, never a false positive. Tracking **direct** (not transitive) upstreams is deliberate and makes drift compose correctly through the DAG: if `proposal` changes, `specs` drifts (its recorded `proposal` digest no longer matches); reconciling `specs` rewrites its content, which changes `specs`'s own digest, which in turn makes `tasks` drift. Transitive staleness therefore emerges one hop at a time as the user works downstream — no transitive bookkeeping required, and the order matches the revisit order from Decision 1. -This layer is **cleanly separable**: the deterministic spine (Decision 1) and the digest signal (Decision 2) deliver the primary targeted flow without it; the ledger only powers unattended audit. It is a candidate to land in a fast-follow if we prefer to keep this change's surface minimal (Open Questions). +**Why in scope (not deferred):** the ledger is what makes audit-mode drift *deterministic* rather than agent-guessed; deferring it would ship audit as "structural facts + ask," which is the weaker half. The cost is contained — one optional metadata field and one `--record` write path — and the generating-flow integration is optional (absent baseline degrades gracefully to `unknown`), so this does not block on touching `propose`/`continue`/`ff`. *Alternatives rejected:* (a) mtime — see Decision 2. (b) Pure git diff — deterministic only for committed files, but planning artifacts are routinely uncommitted while being drafted, so it misses the common case. Digests work regardless of git state. @@ -87,7 +87,7 @@ The tracker has accumulated several adjacent requests; good design means one coh `/opsx:update` deliberately **subsumes** the update/regen/refine cluster (#1188/#705/#673/#783/#1206) into its two modes instead of spawning `/opsx:regen`, `/opsx:rebuild`, and `/opsx:refine` separately. It composes with clarify (a within-artifact step that often precedes an update) and review (a code-side check that follows apply); it never overlaps their read/write surfaces. -**Answering #783's form question (skill vs. extend `validate`).** Both, split by determinism: the checks that are pure functions of content and graph — drift (digests), structural completeness (#1098), capability coverage (#1277) — are CLI-shaped and are the natural content of a future schema-aware `openspec validate` coherence rule (aligns with [#829](https://github.com/Fission-AI/OpenSpec/issues/829)); the cross-artifact *semantic* review (scope contradictions, duplication with existing specs, missing abstractions) cannot be deterministic and is the skill's job. This change ships the `status` data + skill; surfacing the deterministic subset in `validate` for a CI gate is a scoped follow-up (Open Questions). +**Answering #783's form question (skill vs. extend `validate`).** Both, split by determinism: the checks that are pure functions of content and graph — drift (digests), structural completeness (#1098), capability coverage (#1277) — are CLI-shaped and are the natural content of a future schema-aware `openspec validate` coherence rule (aligns with [#829](https://github.com/Fission-AI/OpenSpec/issues/829)); the cross-artifact *semantic* review (scope contradictions, duplication with existing specs, missing abstractions) cannot be deterministic and is the skill's job. This change ships the `status` data + skill; surfacing the deterministic subset in `validate` for a CI gate is a scoped follow-up (Decisions resolved, item 6). ## Risks / Trade-offs @@ -99,14 +99,16 @@ The tracker has accumulated several adjacent requests; good design means one coh ## Migration Plan -Purely additive. New graph methods, new optional status fields/flag, one new skill template behind the expanded-workflow profile. No existing command changes behavior; no data migration. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. +Backward-compatible and additive. New graph methods; new status JSON fields (`requires`/`dependents`/`digest`/`drift`); new `--impact` and `--record` flags; one new optional field on `ChangeMetadataSchema` (absent on existing changes → drift `unknown`, no migration); one new skill template behind the expanded-workflow profile. No existing command changes its default human-readable behavior. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. -## Open Questions +## Decisions resolved (for build-out) -1. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)).** When `openspec/config.yaml` or a project guideline changes, the user wants *every active change* re-audited. Phase 2: `openspec status --impact` generalizes, but "config changed → which changes are stale" needs a dependency from changes to project config. Worth a follow-up proposal. -2. **Land the digest ledger now, or fast-follow?** The deterministic spine + targeted flow ship without it; the ledger (Decision 3) only powers unattended audit and adds a `ChangeMetadataSchema` field plus a record step in the generating flows. Recommend deciding scope before build-out — minimal change (spine only) vs. full (spine + ledger). -3. **Targeted-mode entry point.** Should the skill infer the changed artifact from `git diff` (deterministic for committed files) and confirm, or always ask? Lean: when a baseline exists, default to the drifted set; otherwise ask. -4. **Relationship to `/opsx:apply`'s mid-flight edits.** `apply` already re-reads artifacts each run; should `/opsx:update` be invocable *from within* an apply session, or strictly before it? Lean: standalone, with apply pointing to it when it detects upstream drift. -5. **Retire hardcoded patterns in `continue`/`ff` now or later?** ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)) Doing it here widens scope; deferring risks the new skill being the only graph-driven one. Recommend a fast-follow change. -6. **Surface the deterministic coherence checks in `openspec validate` too?** ([#783](https://github.com/Fission-AI/OpenSpec/issues/783) option B, [#829](https://github.com/Fission-AI/OpenSpec/issues/829)) A schema-aware `validate` drift/completeness rule would give a CI gate independent of the agent. Natural fit, but it overlaps #1277/#1098/#829 surfaces — recommend landing as a coordinated follow-up rather than widening this change. -7. **Naming.** [#783](https://github.com/Fission-AI/OpenSpec/issues/783) suggests `/opsx:refine` for the cross-artifact review. We keep `/opsx:update` as the umbrella (it is the missing first-class *action*, and covers both propagate and audit); "refine" reads as audit mode. If the team prefers `refine` for the audit half, it can alias the same skill — but two commands for one action reintroduces the sprawl #1263 flags. +These were open; each is now committed to the happy path so build-out has no dangling forks. Boundaries are deliberate scope lines, not deferrals of the core feature. + +1. **Digest ledger: in scope.** Drift is deterministic via the recorded baseline (Decision 3), not agent-guessed. Pre-existing changes without a baseline degrade to `unknown` + structural checks. +2. **Targeted-mode entry point: baseline-aware.** When a recorded baseline exists, the skill defaults to the drifted set and asks the user to confirm/override; with no baseline it asks which artifact changed. It does not silently act on inference. +3. **Relationship to `/opsx:apply`: standalone.** `/opsx:update` is its own action; `apply` does not embed it. Where `apply` re-reads artifacts and detects upstream drift, it points the user to `/opsx:update` rather than updating planning artifacts itself (keeps apply's job = code, update's job = plan). +4. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)): out of scope, named follow-up.** This change is intra-change. Cross-change ("config/guideline changed → re-audit every active change") reuses the same impact/digest primitives and is a clean follow-up proposal once those land — explicitly not blocked on, and not crammed in. +5. **Retire hardcoded patterns in `continue`/`ff` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)): fast-follow.** This change establishes the graph-driven pattern and proves it in the new skill (with the anti-hardcoding test); applying it to the existing `continue`/`ff` templates is a focused follow-up so this change does not also rewrite two unrelated templates. +6. **Deterministic coherence in `openspec validate` ([#783](https://github.com/Fission-AI/OpenSpec/issues/783) option B, [#829](https://github.com/Fission-AI/OpenSpec/issues/829)): coordinated follow-up.** The drift/completeness data lives in `status` for the skill now; promoting it to a CI-runnable `validate` rule is valuable but overlaps the #1277/#1098/#829 validate surfaces, so it lands once those settle rather than widening this change. +7. **Naming: `/opsx:update` (umbrella).** It is the missing first-class *action* and covers both propagate and audit; #783's `/opsx:refine` reads as audit mode and would, as a second command for one action, reintroduce the sprawl #1263 flags. If the team wants the word "refine," it aliases the same skill rather than splitting the action. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index de61e7f3ad..f01d5cd11c 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -32,7 +32,7 @@ The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSp 1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths and existence status. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. Update revises the downstream artifacts that **exist**; ones not yet created are deferred to `/opsx:continue` (update revises, continue creates). This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* -2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a newline-normalized SHA-256 of its output bytes (normalized so the digest is identical on Windows and POSIX). Digests are exposed on `openspec status --json`. *Drift* — "this downstream no longer reflects the upstream it was generated from" — is then defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline (a small per-change digest ledger) is a clearly-separable layer; until a baseline exists, drift is reported as **unknown**, never guessed (no false positives). See design for the ledger mechanism and why mtime/git were rejected. +2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a newline-normalized SHA-256 of its output bytes (normalized so the digest is identical on Windows and POSIX). Digests are exposed on `openspec status --json`. *Drift* is defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline — a per-change **digest ledger** in change metadata, tracking each artifact's **direct** upstream digests — is **in scope**: it is what makes audit-mode drift deterministic rather than agent-guessed. It is written by a deterministic `openspec status --record` op (invoked by `/opsx:update` after each confirmed edit); changes that predate the ledger report drift as **unknown** and fall back to structural checks (never a false positive). See design for why mtime/git were rejected and why direct-upstream tracking composes through the DAG. 3. **SURFACE — graph edges, digests, and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains, per artifact, `requires`, `dependents`, and `digest`; a focused `openspec status --change --impact --json` returns the downstream revisit set (ordered, with paths and digests). The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. Default human-readable output is unchanged. @@ -53,7 +53,7 @@ The naming boundary is deliberate: `openspec update` is already taken (it regene ### Modified Capabilities - `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a deterministic, newline-normalized content `digest` per artifact — the primitives an update/audit traversal needs, all schema-agnostic and reproducible across platforms. -- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`) and a content `digest`; a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), so skills consume deterministic graph structure as data instead of hardcoding artifact names. +- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`), a content `digest`, and a deterministic drift signal (current upstream digest vs. recorded baseline, or `unknown` when none); a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), and `--record` writes the digest baseline — so skills consume deterministic graph structure as data instead of hardcoding artifact names. ## Impact @@ -61,7 +61,7 @@ The naming boundary is deliberate: `openspec update` is already taken (it regene - `src/core/artifact-graph/outputs.ts` (new digest helper) — `artifactDigest(changeDir, generates)`: read `resolveArtifactOutputs` files, normalize newlines (CRLF→LF) for cross-platform stability, SHA-256 over the concatenation in the already-deterministic sorted path order; absent output → no digest. (No content-hash utility exists in the repo today; only `crypto.randomUUID` in telemetry.) - `src/core/artifact-graph/instruction-loader.ts` (`formatChangeStatus`, ~397-453) — add `requires`, `dependents`, `digest` to each `ArtifactStatus`; the build-order sort at line 429 already gives revisit order. - `src/commands/workflow/status.ts` (`StatusOptions`, `statusCommand`) + `src/cli/index.ts:488` — add the `--impact ` option returning the ordered downstream set (paths + digests); error on unknown artifact id; default human-readable output unchanged. -- (Optional, separable) `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact upstream-digest ledger to make *drift* (not just structure) deterministic; see design. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. +- `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact direct-upstream digest ledger (the drift baseline); see design Decision 3. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. The `--record` write path and the drift comparison live in the `status`/`outputs` path alongside the digest helper. - `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts` but reading graph edges from the CLI rather than embedding artifact-name patterns. - Skill/command registration + profile wiring (the expanded-workflow profile that already lists `continue`, `ff`, `verify`, …) so `/opsx:update` installs alongside its siblings; docs/opsx.md command table gains a `/opsx:update` row. - `openspec/changes/add-artifact-regeneration-support/` — superseded by this change (see below); retire or fold its staleness notes into design. @@ -76,7 +76,7 @@ Closes (the missing-update-action family): - [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task." Delivered as `/opsx:update`, artifacts-only (never touches code), which is the specific pain in the report. - [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Support Rebuilding Downstream Artifacts from Modified Intermediate Files." The requested `regen --from ` becomes graph-driven downstream propagation in targeted mode. - [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — the "clarify" request: update existing artifacts without auto-generating the next one. `/opsx:update` revises in place and never advances the build frontier. -- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "utility to review and update all change proposals." Audit mode delivers the within-a-change form; cross-change audit is the scoped phase-2 extension (see design Open Questions). +- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "utility to review and update all change proposals." Audit mode delivers the within-a-change form; cross-change audit is the scoped phase-2 follow-up (see design Decisions resolved). - [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — "Cross-artifact quality review between propose/ff and apply." This is exactly audit mode: a pre-apply pass that catches cross-artifact contradictions (proposal scope vs. design Non-Goals), spec gaps, and duplication. The issue's open form question — new skill (A) vs. extend `openspec validate` (B) — is answered by the determinism split: the *deterministic* checks (content drift, structural completeness, capability coverage) are CLI/`validate`-shaped; the *semantic* cross-artifact review is the skill. See design. Answers (workflow questions whose honest answer today is "no command exists"): diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md index fae71d5b7c..3495ce39c5 100644 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -15,9 +15,9 @@ The system SHALL include each artifact's dependency edges in the `openspec statu - **WHEN** the change uses a custom schema with non-default artifact ids - **THEN** the `requires` and `dependents` edges in the status output use that schema's artifact ids -### Requirement: Status Includes Content Digest +### Requirement: Status Includes Content Digest and Drift -The system SHALL include a deterministic per-artifact content digest in the `openspec status --json` output, so consumers can detect content changes reproducibly without relying on filesystem timestamps. +The system SHALL include a deterministic per-artifact content digest in the `openspec status --json` output, and a drift signal computed by comparing each artifact's recorded upstream-digest baseline against the current upstream digests, so consumers can detect content changes reproducibly without relying on filesystem timestamps. The system SHALL provide a way to record the baseline so the drift signal has a deterministic reference. #### Scenario: Present artifact reports a digest @@ -34,6 +34,21 @@ The system SHALL include a deterministic per-artifact content digest in the `ope - **WHEN** an artifact's output does not exist - **THEN** the artifact's status JSON omits `digest` (or reports it as null) +#### Scenario: Recording a baseline + +- **WHEN** the user runs `openspec status --change --record ` +- **THEN** the system records the current digests of that artifact's direct upstream dependencies as its baseline + +#### Scenario: Drift reported against a recorded baseline + +- **WHEN** an upstream dependency's current digest differs from the value recorded in an artifact's baseline +- **THEN** the artifact's status JSON reports it as drifted, listing the upstream ids whose digest changed + +#### Scenario: Drift is unknown without a baseline + +- **WHEN** an artifact has no recorded baseline +- **THEN** the artifact's status JSON reports drift as `unknown` rather than drifted or clean + ### Requirement: Downstream Impact Query The system SHALL provide an impact selector on the status command that returns the downstream artifacts affected by a change to a given artifact, in revisit (topological) order. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index 6848aa5039..1a2f30bdb8 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -98,3 +98,9 @@ The `/opsx:update` skill SHALL propose each artifact revision and apply it only - **WHEN** the requested revision changes the intent of the change rather than refining it (per the "Update vs. Start Fresh" heuristic) - **THEN** the skill recommends starting a new change (`/opsx:new`) instead of mutating the existing proposal into different work + +#### Scenario: Records the drift baseline after an applied edit + +- **WHEN** the skill has applied and confirmed a revision to an artifact +- **THEN** it records that artifact's drift baseline via the CLI (`openspec status --record`) +- **AND** subsequent audits report that artifact as no longer drifted until an upstream changes again diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index dd7ac96f83..ca6d3f21d7 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -1,6 +1,6 @@ # Tasks: `/opsx:update` — graph-driven artifact update -> Sequenced so each layer is independently testable: deterministic graph/digest primitives → CLI surface → skill → docs/cleanup. The deterministic spine (sections 1–3) ships the primary targeted flow; the optional digest ledger (section 7) is separable and gated on scope (see design Open Questions). Cross-platform (`path.join`, newline-normalized digests) and Windows CI are called out where relevant, per `openspec/config.yaml`. +> Sequenced so each layer is independently testable: deterministic graph/digest primitives → drift baseline → CLI surface → skill → docs/verification. Cross-platform (`path.join`, newline-normalized digests) and Windows CI are called out where relevant, per `openspec/config.yaml`. The digest ledger (section 7) is in scope (design Decision 3); it degrades gracefully to `unknown` for changes without a recorded baseline. ## 1. Artifact graph: reverse traversal (deterministic) @@ -18,13 +18,14 @@ - [ ] 3.1 Extend `formatChangeStatus` (instruction-loader.ts, the `ArtifactStatus` objects ~397-426) to add `requires`, `dependents`, and `digest` per artifact; the existing build-order sort (line 429) already yields revisit order. - [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths, digest, and existence/status (so consumers can tell which exist to revise vs. which would need creating); error on unknown artifact id. +- [ ] 3.3a Add per-artifact `drift` (`drifted`/`clean`/`unknown` + changed-upstream ids) to status JSON, comparing current upstream digests to the recorded baseline (section 7). Add `--record ` to write/refresh that baseline. - [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. - [ ] 3.4 Tests: JSON edge fields, per-artifact digest present/stable, `--impact` ordering + determinism (repeat-run equality), `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. ## 4. The `/opsx:update` skill (thin wrapper over the deterministic spine) - [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. -- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/re-check, reading ids/paths/edges/digests from JSON only. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. +- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/`--record`/re-check, reading ids/paths/edges/digests/drift from JSON only. Targeted entry is baseline-aware: with a baseline, default to the drifted set and confirm; without one, ask which artifact changed. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. - [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks; (d) revise only existing downstream artifacts — defer not-yet-created ones to `/opsx:continue`. - [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). - [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. @@ -43,9 +44,9 @@ - [ ] 6.3 Full validation: `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. - [ ] 6.4 Run the suite on macOS, Linux, and Windows CI. -## 7. (Optional, separable) Deterministic drift baseline — the digest ledger +## 7. Deterministic drift baseline — the digest ledger -> Only if scoped in (design Open Question 2). Powers unattended audit; the targeted flow above does not need it. +> In scope (design Decision 3). Powers unattended/audit drift; consumed by 3.3a's drift signal and 4.x's record-after-edit. - [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional per-artifact map of recorded **direct** upstream digests. - [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current direct-upstream digests for the affected artifact ("reconciled"). From bf0705af565538a59b070a83de1fc99fd15b47e9 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 15:32:00 -0500 Subject: [PATCH 06/11] =?UTF-8?q?docs(openspec):=20harden=20add-update-wor?= =?UTF-8?q?kflow=20=E2=80=94=20close=20cross-OS,=20read-only,=20edge=20gap?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stress-tested every claim against live source and fixed the soft spots: - Cross-OS digest determinism (real bug): resolveArtifactOutputs (outputs.ts:34) sorts ABSOLUTE paths via .sort(), which differs by OS — so a multi-file glob artifact (specs/**/*.md) would hash differently on Windows vs POSIX. Digest now specified to order files by change-relative forward-slash path and hash relpath+content. Added spec scenarios (cross-platform glob stability; rename changes digest) and a cross-OS test task. - Read-only status invariant: moved baseline recording OFF `openspec status` (a read command silently mutating the drift reference is a footgun) to a dedicated `openspec reconcile` write verb. Updated spec, skill, design, impact, capabilities, tasks; reconciled the "no new verb" claims. - Edge case: missing upstream at record time is stored as an explicit `absent` marker so later creating it registers as drift (spec scenario added). - Edge case: coherent change yields no edits (clean-path scenario). - Grounding fixes: continue-change hardcoded block is duplicated (skill 103-112 + command 225-234) — both must be fixed in the #777 follow-up; verified no content-hash util exists. - Fixed two stale claims the layered edits left: the Impact digest bullet (concatenation→relative-path) and the naming-boundary line. Validates clean under --strict; 10 deltas (4+3+3), 44 scenarios; all links resolve; re-synced with upstream main (0 behind); issue/PR/discussion sweep re-run, no new items. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 19 +++++++++++------ .../changes/add-update-workflow/proposal.md | 10 ++++----- .../specs/artifact-graph/spec.md | 13 ++++++++---- .../specs/cli-artifact-workflow/spec.md | 21 ++++++++++++------- .../specs/opsx-update-skill/spec.md | 7 ++++++- openspec/changes/add-update-workflow/tasks.md | 8 +++---- 6 files changed, 51 insertions(+), 27 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 3359432368..faa110cde5 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -6,7 +6,7 @@ OPSX models a change as a DAG of artifacts. Each schema declares artifacts with The graph is only ever traversed **forward** ("what can I build next?"). The update action requires the **backward** traversal ("I changed X — which downstream artifacts must be revisited?"). Notably, `getBuildOrder()` already constructs the reverse-edge `dependents` map (graph.ts:82-87) for Kahn's algorithm — computed and discarded — and `getUnlockedArtifacts()` already returns direct dependents for one node. This change exposes the transitive form and builds two thin, deterministic layers on top. -The orchestrating skills currently embed `proposal → specs → design → tasks` as hardcoded prose patterns that "override the schema instruction field" ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)); `continue-change.ts:103-112` even contradicts its own guardrail ("Use the schema's artifact sequence, don't assume specific artifact names"). The new skill must not repeat this — it reads ids and edges from CLI JSON. +The orchestrating skills currently embed `proposal → specs → design → tasks` as hardcoded prose patterns that "override the schema instruction field" ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)); `continue-change.ts` even contradicts its own guardrail ("Use the schema's artifact sequence, don't assume specific artifact names") — and the hardcoded block is duplicated across both templates in that file (the skill at lines 103-112 and the command variant at 225-234), so the de-hardcoding follow-up must fix both copies. The new skill must not repeat this — it reads ids and edges from CLI JSON. ## Goals / Non-Goals @@ -35,18 +35,25 @@ Granularity is the **artifact** (the schema's unit), not the individual file. Ed ### 2. A grounded signal: content digests, not mtimes The earlier draft detected staleness from filesystem mtimes. **Rejected.** mtime is not grounded in reality: `git checkout`/`clone`/`stash`, `touch`, editors, and formatters all rewrite mtime without changing content, and content can change while mtime is preserved (`git` does not restore historical mtimes). It is neither reproducible nor a true content signal — the opposite of what we want. -Instead, each artifact carries a **content digest**: `sha256(normalizeNewlines(concat(sortedOutputFiles)))`. Newlines are normalized (CRLF→LF) so the digest is identical on Windows and POSIX ([openspec/config.yaml](../../config.yaml) cross-platform rules); files are concatenated in the deterministic sorted order `resolveArtifactOutputs` already returns. There is no content-hash utility in the repo today (only `crypto.randomUUID` in telemetry), so this is a small new helper in `outputs.ts`. The digest is exposed on `openspec status --json`. It is deterministic, reproducible, and grounded in the actual bytes. +Instead, each artifact carries a **content digest**: SHA-256 over its output file(s), where each file contributes its **change-relative forward-slash path** plus its newline-normalized (CRLF→LF) content, and files are ordered by that relative path. Two cross-platform subtleties make this non-trivial and must be specified, not assumed: + +- `resolveArtifactOutputs` returns **absolute, canonicalized** paths sorted with `.sort()` ([outputs.ts:34](../../../src/core/artifact-graph/outputs.ts)). Absolute-path order differs by OS (separators, drive letters, case-folding), so for a multi-file glob artifact (`specs/**/*.md`) a naive "concat in returned order" would hash to **different** values on Windows vs. POSIX. The digest helper therefore re-derives each file's path *relative to the change dir*, converts to forward slashes, and orders by that — independent of the OS. +- Including the relative path in the hash (not just content) means renaming or moving a file inside a glob artifact changes the digest, which is correct: the artifact's file set is part of its identity. + +There is no content-hash utility in the repo today (verified: no `createHash`/`sha256` usage; only `crypto.randomUUID` in telemetry), so this is a small new helper in `outputs.ts`. The digest is exposed on read-only `openspec status --json`. It is deterministic, reproducible, and grounded in the actual bytes. **Drift** is then defined deterministically: a downstream artifact *D* has drifted against upstream *U* iff `digest(U_now) ≠ digest(U_recorded_when_D_was_reconciled)`. The recorded baseline is a small per-change **digest ledger** (Decision 3). Until a baseline is recorded, drift is reported as **`unknown`**, never inferred — so the audit produces zero false positives, in contrast to the mtime heuristic which produced many. [Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (missing/empty output — reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s `artifactOutputComplete` rather than reinventing — and blocked/incomplete status) and the user-named targeted flow, which need no baseline. ### 3. The digest ledger (deterministic drift baseline) — in scope -To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. Writing the ledger is itself a deterministic CLI operation (an explicit `openspec status --record `), invoked by `/opsx:update` after each confirmed edit and available for the generating flows (`propose`/`continue`/`ff`) to call after they write an artifact — so the agent triggers a deterministic write, it never computes hashes. For changes that predate the ledger (no recorded baseline), drift is reported `unknown` and the audit falls back to the structural checks — honest, never a false positive. +To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. If a direct upstream has no output when the baseline is recorded, it is stored as an explicit *absent* marker, so that later creating that upstream registers as drift rather than being silently missed. + +Recording is a **dedicated, explicit write operation** (working name `openspec reconcile --artifact `), invoked by `/opsx:update` after each confirmed edit and available for the generating flows (`propose`/`continue`/`ff`) to call after they write an artifact — so the agent triggers a deterministic write and never computes hashes. It is deliberately **not** a flag on `openspec status`: `status` is read-only, and a read command that silently mutates a baseline is a footgun (every status call would move the reference the next drift check compares against). Keeping the read (`status`) and the write (`reconcile`) on separate verbs preserves that invariant. For changes that predate the ledger (no recorded baseline), drift is reported `unknown` and the audit falls back to the structural checks — honest, never a false positive. Tracking **direct** (not transitive) upstreams is deliberate and makes drift compose correctly through the DAG: if `proposal` changes, `specs` drifts (its recorded `proposal` digest no longer matches); reconciling `specs` rewrites its content, which changes `specs`'s own digest, which in turn makes `tasks` drift. Transitive staleness therefore emerges one hop at a time as the user works downstream — no transitive bookkeeping required, and the order matches the revisit order from Decision 1. -**Why in scope (not deferred):** the ledger is what makes audit-mode drift *deterministic* rather than agent-guessed; deferring it would ship audit as "structural facts + ask," which is the weaker half. The cost is contained — one optional metadata field and one `--record` write path — and the generating-flow integration is optional (absent baseline degrades gracefully to `unknown`), so this does not block on touching `propose`/`continue`/`ff`. +**Why in scope (not deferred):** the ledger is what makes audit-mode drift *deterministic* rather than agent-guessed; deferring it would ship audit as "structural facts + ask," which is the weaker half. The cost is contained — one optional metadata field and one dedicated `reconcile` write path — and the generating-flow integration is optional (absent baseline degrades gracefully to `unknown`), so this does not block on touching `propose`/`continue`/`ff`. *Alternatives rejected:* (a) mtime — see Decision 2. (b) Pure git diff — deterministic only for committed files, but planning artifacts are routinely uncommitted while being drafted, so it misses the common case. Digests work regardless of git state. @@ -68,7 +75,7 @@ The CLI owns every decision that is a function of the graph and the bytes: **whi [Risk] Agent edits code while "updating the plan" (the [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) complaint) → Mitigation: explicit, repeated guardrail "planning artifacts only; if implementation must change, hand to `/opsx:apply`"; the skill's write targets are constrained to `resolvedOutputPath`s from the graph. ### 6. Naming: `/opsx:update` skill, not `openspec update` CLI -`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts:202](../../../src/cli/index.ts)). Reusing it would overload one verb with two unrelated meanings. Decision: the artifact-update action is the **skill** `/opsx:update`; CLI support is **read-only additions to `openspec status`** (`--impact`, extra JSON fields). No new top-level verb. +`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts:202](../../../src/cli/index.ts)). Reusing it would overload one verb with two unrelated meanings. Decision: the artifact-update action is the **skill** `/opsx:update`; CLI support is read-only additions to `openspec status` (`--impact`, extra JSON fields) plus one small write verb `openspec reconcile` for the drift baseline (kept off `status` to preserve its read-only invariant — Decision 3). No new `openspec update*` verb; the only new verb is `reconcile`, named for what it does. Alternatives considered: (a) `openspec regen --from ` as #705 literally asks — rejected: a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. (b) `openspec update artifacts …` subcommand — rejected: still collides conceptually and splits the action across two surfaces. @@ -99,7 +106,7 @@ The tracker has accumulated several adjacent requests; good design means one coh ## Migration Plan -Backward-compatible and additive. New graph methods; new status JSON fields (`requires`/`dependents`/`digest`/`drift`); new `--impact` and `--record` flags; one new optional field on `ChangeMetadataSchema` (absent on existing changes → drift `unknown`, no migration); one new skill template behind the expanded-workflow profile. No existing command changes its default human-readable behavior. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. +Backward-compatible and additive. New graph methods; new status JSON fields (`requires`/`dependents`/`digest`/`drift`); a new read-only `--impact` flag and a separate `reconcile` write command; one new optional field on `ChangeMetadataSchema` (absent on existing changes → drift `unknown`, no migration); one new skill template behind the expanded-workflow profile. No existing command changes its default human-readable behavior. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. ## Decisions resolved (for build-out) diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index f01d5cd11c..d3c56111f5 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -32,7 +32,7 @@ The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSp 1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths and existence status. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. Update revises the downstream artifacts that **exist**; ones not yet created are deferred to `/opsx:continue` (update revises, continue creates). This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* -2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a newline-normalized SHA-256 of its output bytes (normalized so the digest is identical on Windows and POSIX). Digests are exposed on `openspec status --json`. *Drift* is defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline — a per-change **digest ledger** in change metadata, tracking each artifact's **direct** upstream digests — is **in scope**: it is what makes audit-mode drift deterministic rather than agent-guessed. It is written by a deterministic `openspec status --record` op (invoked by `/opsx:update` after each confirmed edit); changes that predate the ledger report drift as **unknown** and fall back to structural checks (never a false positive). See design for why mtime/git were rejected and why direct-upstream tracking composes through the DAG. +2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a SHA-256 over its output file(s), newline-normalized and ordered by change-relative forward-slash path (not the OS absolute path), so the digest is identical on Windows and POSIX — including multi-file glob artifacts like `specs/**/*.md`. Digests are exposed on `openspec status --json`. *Drift* is defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline — a per-change **digest ledger** in change metadata, tracking each artifact's **direct** upstream digests — is **in scope**: it is what makes audit-mode drift deterministic rather than agent-guessed. It is written by a deterministic, dedicated `openspec reconcile` op (invoked by `/opsx:update` after each confirmed edit; read-only `status` never mutates it); changes that predate the ledger report drift as **unknown** and fall back to structural checks (never a false positive). See design for why mtime/git were rejected and why direct-upstream tracking composes through the DAG. 3. **SURFACE — graph edges, digests, and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains, per artifact, `requires`, `dependents`, and `digest`; a focused `openspec status --change --impact --json` returns the downstream revisit set (ordered, with paths and digests). The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. Default human-readable output is unchanged. @@ -42,7 +42,7 @@ The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSp Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — ids and order come from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. -The naming boundary is deliberate: `openspec update` is already taken (it regenerates AI tool/skill files — [src/cli/index.ts:202](../../../src/cli/index.ts)). The artifact-update action is therefore the **skill** `/opsx:update` plus **read-only** additions to `openspec status`; this change adds **no** new top-level `openspec update*` CLI verb. (See design for the considered alternatives.) +The naming boundary is deliberate: `openspec update` is already taken (it regenerates AI tool/skill files — [src/cli/index.ts:202](../../../src/cli/index.ts)). The artifact-update action is therefore the **skill** `/opsx:update`, backed by read-only additions to `openspec status` and one small dedicated write op (`openspec reconcile`, which records the drift baseline so read-only `status` never mutates state). This change adds **no** new `openspec update*` verb. (See design for the considered alternatives.) ## Capabilities @@ -53,15 +53,15 @@ The naming boundary is deliberate: `openspec update` is already taken (it regene ### Modified Capabilities - `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a deterministic, newline-normalized content `digest` per artifact — the primitives an update/audit traversal needs, all schema-agnostic and reproducible across platforms. -- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`), a content `digest`, and a deterministic drift signal (current upstream digest vs. recorded baseline, or `unknown` when none); a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), and `--record` writes the digest baseline — so skills consume deterministic graph structure as data instead of hardcoding artifact names. +- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`), a content `digest`, and a deterministic drift signal (current upstream digest vs. recorded baseline, or `unknown` when none); a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), and a dedicated read/write split keeps `status` read-only while a separate `openspec reconcile` op writes the digest baseline — so skills consume deterministic graph structure as data instead of hardcoding artifact names. ## Impact - `src/core/artifact-graph/graph.ts` — expose `getDependents(id)` (promotes the reverse-adjacency loop at lines 82-87, and the existing `getUnlockedArtifacts` logic) and `getDownstream(id)` (transitive, ordered by the existing `getBuildOrder`); both throw on unknown id. -- `src/core/artifact-graph/outputs.ts` (new digest helper) — `artifactDigest(changeDir, generates)`: read `resolveArtifactOutputs` files, normalize newlines (CRLF→LF) for cross-platform stability, SHA-256 over the concatenation in the already-deterministic sorted path order; absent output → no digest. (No content-hash utility exists in the repo today; only `crypto.randomUUID` in telemetry.) +- `src/core/artifact-graph/outputs.ts` (new digest helper) — `artifactDigest(changeDir, generates)`: from `resolveArtifactOutputs`, order files by their **change-relative forward-slash path** (not the OS absolute-path `.sort()` order, which differs across platforms), then SHA-256 over each file's relative path + newline-normalized (CRLF→LF) content; absent output → no digest. (No content-hash utility exists in the repo today; only `crypto.randomUUID` in telemetry.) - `src/core/artifact-graph/instruction-loader.ts` (`formatChangeStatus`, ~397-453) — add `requires`, `dependents`, `digest` to each `ArtifactStatus`; the build-order sort at line 429 already gives revisit order. - `src/commands/workflow/status.ts` (`StatusOptions`, `statusCommand`) + `src/cli/index.ts:488` — add the `--impact ` option returning the ordered downstream set (paths + digests); error on unknown artifact id; default human-readable output unchanged. -- `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact direct-upstream digest ledger (the drift baseline); see design Decision 3. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. The `--record` write path and the drift comparison live in the `status`/`outputs` path alongside the digest helper. +- `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact direct-upstream digest ledger (the drift baseline); see design Decision 3. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. The drift comparison lives in the read-only `status`/`outputs` path alongside the digest helper; the baseline write is the separate `openspec reconcile` op. - `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts` but reading graph edges from the CLI rather than embedding artifact-name patterns. - Skill/command registration + profile wiring (the expanded-workflow profile that already lists `continue`, `ff`, `verify`, …) so `/opsx:update` installs alongside its siblings; docs/opsx.md command table gains a `/opsx:update` row. - `openspec/changes/add-artifact-regeneration-support/` — superseded by this change (see below); retire or fold its staleness notes into design. diff --git a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md index 50f280484b..84f536f50f 100644 --- a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md +++ b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md @@ -45,7 +45,7 @@ The system SHALL compute the transitive set of artifacts that depend on a given ### Requirement: Artifact Content Digest -The system SHALL compute a deterministic content digest for an artifact from the bytes of its output file(s), such that the same content yields the same digest on every run and on every platform. The digest SHALL normalize line endings before hashing so that otherwise-identical content produces an identical digest regardless of CRLF or LF encoding. +The system SHALL compute a deterministic content digest for an artifact from its output file(s), such that the same content yields the same digest on every run and on every platform. For an artifact with multiple output files (a glob), the digest SHALL be computed over the files ordered by their change-relative path expressed with forward slashes, and SHALL incorporate each file's relative path together with its content, so that the digest is stable regardless of the operating system's absolute-path sorting, separators, or drive letters. Content SHALL be line-ending-normalized (CRLF to LF) before hashing so that otherwise-identical content produces an identical digest regardless of encoding. #### Scenario: Identical content yields identical digest @@ -62,11 +62,16 @@ The system SHALL compute a deterministic content digest for an artifact from the - **WHEN** the same content is encoded with CRLF on one platform and LF on another - **THEN** the digest is identical on both -#### Scenario: Glob output digests all matching files deterministically +#### Scenario: Glob output digest is stable across platforms - **WHEN** an artifact generates a glob pattern (e.g. `specs/**/*.md`) with multiple files -- **THEN** the digest is computed over the matching files in a deterministic order -- **AND** the digest is stable across runs +- **THEN** the digest orders the files by change-relative forward-slash path before hashing +- **AND** the digest is identical on Windows and POSIX for the same file set and contents + +#### Scenario: Renaming a file within a glob changes the digest + +- **WHEN** a file in a glob artifact is renamed or moved (same total content, different relative path) +- **THEN** the digest changes, because the relative path is part of the hash #### Scenario: Missing output has no digest diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md index 3495ce39c5..2e005e3238 100644 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -15,9 +15,9 @@ The system SHALL include each artifact's dependency edges in the `openspec statu - **WHEN** the change uses a custom schema with non-default artifact ids - **THEN** the `requires` and `dependents` edges in the status output use that schema's artifact ids -### Requirement: Status Includes Content Digest and Drift +### Requirement: Content Digest and Drift Reporting -The system SHALL include a deterministic per-artifact content digest in the `openspec status --json` output, and a drift signal computed by comparing each artifact's recorded upstream-digest baseline against the current upstream digests, so consumers can detect content changes reproducibly without relying on filesystem timestamps. The system SHALL provide a way to record the baseline so the drift signal has a deterministic reference. +The system SHALL report a deterministic per-artifact content digest and a drift signal in the read-only `openspec status --json` output, the drift signal computed by comparing each artifact's recorded upstream-digest baseline against the current upstream digests, so consumers can detect content changes reproducibly without relying on filesystem timestamps. Recording the baseline SHALL be a separate, explicit write operation; `openspec status` SHALL remain read-only and never mutate the baseline. #### Scenario: Present artifact reports a digest @@ -34,11 +34,6 @@ The system SHALL include a deterministic per-artifact content digest in the `ope - **WHEN** an artifact's output does not exist - **THEN** the artifact's status JSON omits `digest` (or reports it as null) -#### Scenario: Recording a baseline - -- **WHEN** the user runs `openspec status --change --record ` -- **THEN** the system records the current digests of that artifact's direct upstream dependencies as its baseline - #### Scenario: Drift reported against a recorded baseline - **WHEN** an upstream dependency's current digest differs from the value recorded in an artifact's baseline @@ -49,6 +44,18 @@ The system SHALL include a deterministic per-artifact content digest in the `ope - **WHEN** an artifact has no recorded baseline - **THEN** the artifact's status JSON reports drift as `unknown` rather than drifted or clean +#### Scenario: Recording a baseline is an explicit write, separate from status + +- **WHEN** the baseline is recorded for an artifact via the dedicated record operation +- **THEN** the system stores the current digests of that artifact's direct upstream dependencies as its baseline +- **AND** running `openspec status` does not by itself create or change any baseline + +#### Scenario: Missing upstream is recorded as absent + +- **WHEN** an artifact's baseline is recorded while one of its direct upstreams has no output +- **THEN** that upstream is recorded as absent +- **AND** later creating that upstream's output reports the artifact as drifted + ### Requirement: Downstream Impact Query The system SHALL provide an impact selector on the status command that returns the downstream artifacts affected by a change to a given artifact, in revisit (topological) order. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index 1a2f30bdb8..2e2d495c7d 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -84,6 +84,11 @@ The `/opsx:update` skill SHALL support an audit mode that reviews a whole change - **THEN** the skill proposes a concrete revision for each, in revisit order - **AND** it applies a revision only after the user confirms it +#### Scenario: Coherent change yields no changes + +- **WHEN** audit mode finds no drifted artifacts, no structural gaps, and no cross-artifact incoherence +- **THEN** the skill reports the change as coherent and makes no edits + ### Requirement: User-Confirmed Incremental Application The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation, re-checking coherence after each applied change. @@ -102,5 +107,5 @@ The `/opsx:update` skill SHALL propose each artifact revision and apply it only #### Scenario: Records the drift baseline after an applied edit - **WHEN** the skill has applied and confirmed a revision to an artifact -- **THEN** it records that artifact's drift baseline via the CLI (`openspec status --record`) +- **THEN** it records that artifact's drift baseline via the CLI's dedicated record operation (not by computing digests itself, and not via read-only `status`) - **AND** subsequent audits report that artifact as no longer drifted until an upstream changes again diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index ca6d3f21d7..92f1e1e139 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -11,21 +11,21 @@ ## 2. Artifact graph: content digest (deterministic, grounded) -- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: read the files from `resolveArtifactOutputs` (already sorted deterministically), normalize newlines (CRLF→LF), SHA-256 over the concatenation; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. -- [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest** (cross-platform); glob multi-file determinism; missing output → undefined. +- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: from `resolveArtifactOutputs`, re-derive each file's path **relative to `changeDir` as a forward-slash string**, order files by that relative path (NOT by the absolute-path `.sort()` order `resolveArtifactOutputs` returns — that differs by OS), then SHA-256 over, per file, `relPath` + NUL + newline-normalized (CRLF→LF) content; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. +- [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest**; **multi-file glob digest identical under simulated Windows vs POSIX absolute paths** (the cross-OS ordering guard); renaming a file within a glob → different digest; missing output → undefined. ## 3. CLI: surface edges, digest, and impact on `status` - [ ] 3.1 Extend `formatChangeStatus` (instruction-loader.ts, the `ArtifactStatus` objects ~397-426) to add `requires`, `dependents`, and `digest` per artifact; the existing build-order sort (line 429) already yields revisit order. - [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths, digest, and existence/status (so consumers can tell which exist to revise vs. which would need creating); error on unknown artifact id. -- [ ] 3.3a Add per-artifact `drift` (`drifted`/`clean`/`unknown` + changed-upstream ids) to status JSON, comparing current upstream digests to the recorded baseline (section 7). Add `--record ` to write/refresh that baseline. +- [ ] 3.3a Add per-artifact `drift` (`drifted`/`clean`/`unknown` + changed-upstream ids) to status JSON, comparing current upstream digests to the recorded baseline (section 7). Keep `status` read-only; add a dedicated `openspec reconcile --artifact ` command to write/refresh that baseline. - [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. - [ ] 3.4 Tests: JSON edge fields, per-artifact digest present/stable, `--impact` ordering + determinism (repeat-run equality), `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. ## 4. The `/opsx:update` skill (thin wrapper over the deterministic spine) - [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. -- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/`--record`/re-check, reading ids/paths/edges/digests/drift from JSON only. Targeted entry is baseline-aware: with a baseline, default to the drifted set and confirm; without one, ask which artifact changed. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. +- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/`reconcile`/re-check, reading ids/paths/edges/digests/drift from JSON only. Targeted entry is baseline-aware: with a baseline, default to the drifted set and confirm; without one, ask which artifact changed. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. - [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks; (d) revise only existing downstream artifacts — defer not-yet-created ones to `/opsx:continue`. - [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). - [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. From ae743a4e9bf4fe703d5ff85ab15e41aade3472a0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 29 Jun 2026 15:43:38 -0500 Subject: [PATCH 07/11] docs(openspec): pin data contracts + digest forward-compat; delineate #880 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded the surface so an implementer builds it without guessing, and added proportionate forward-compatibility: - New design "Data contracts" section with exact shapes: extended ArtifactStatus (requires/dependents/digest/drift/driftFrom — additive to the real interface at instruction-loader.ts:120), the --impact response, and the `.openspec.yaml` baselines ledger. All additive; nothing existing changes type. - Digest scheme tag (`sha256-relpath-v1:`) + forward-compat: drift compares only same-scheme digests; an unrecognized/older scheme reports `unknown` rather than silently mis-comparing — re-reconcile restores it. Added a cli spec scenario and tasks for it. - Grounded the ledger write: there is no central change-metadata writer today (change-metadata/index.ts only re-exports schema), so reconcile does a safe read-modify-write of .openspec.yaml mirroring the store's parse/serialize/writeStoreMetadataState pattern (foundation.ts). - Coverage: re-swept; folded #880 (/opsx:validate code-vs-living-specs) into the plan-vs-code delineation alongside #1251/#1073. Main unchanged (546224e); all citations still valid. Validates clean under --strict; 10 deltas; links resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 41 ++++++++++++++++++- .../changes/add-update-workflow/proposal.md | 2 +- .../specs/cli-artifact-workflow/spec.md | 6 +++ openspec/changes/add-update-workflow/tasks.md | 5 ++- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index faa110cde5..f37b231cae 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -89,13 +89,52 @@ The tracker has accumulated several adjacent requests; good design means one coh |---|---|---|---| | `/opsx:clarify` ([#702](https://github.com/Fission-AI/OpenSpec/pull/702)) | *within* one artifact (ambiguity Q&A) | one artifact | that artifact | | **`/opsx:update`** (this) | *across* a change's planning artifacts (propagate + audit) | the artifact graph | planning artifacts | -| `/opsx:review` ([#1251](https://github.com/Fission-AI/OpenSpec/pull/1251)), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073) | plan *vs. code* | artifacts + code | nothing (read-only) | +| `/opsx:review` ([#1251](https://github.com/Fission-AI/OpenSpec/pull/1251)), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073), [#880](https://github.com/Fission-AI/OpenSpec/issues/880) | plan *vs. code* | artifacts + code | nothing (read-only) | | `/opsx:verify` | archive readiness | artifacts + code | nothing | `/opsx:update` deliberately **subsumes** the update/regen/refine cluster (#1188/#705/#673/#783/#1206) into its two modes instead of spawning `/opsx:regen`, `/opsx:rebuild`, and `/opsx:refine` separately. It composes with clarify (a within-artifact step that often precedes an update) and review (a code-side check that follows apply); it never overlaps their read/write surfaces. **Answering #783's form question (skill vs. extend `validate`).** Both, split by determinism: the checks that are pure functions of content and graph — drift (digests), structural completeness (#1098), capability coverage (#1277) — are CLI-shaped and are the natural content of a future schema-aware `openspec validate` coherence rule (aligns with [#829](https://github.com/Fission-AI/OpenSpec/issues/829)); the cross-artifact *semantic* review (scope contradictions, duplication with existing specs, missing abstractions) cannot be deterministic and is the skill's job. This change ships the `status` data + skill; surfacing the deterministic subset in `validate` for a CI gate is a scoped follow-up (Decisions resolved, item 6). +## Data contracts + +Concrete shapes so the surface is unambiguous for implementers. All additions are **additive** to existing structures; nothing existing changes type. + +**Extended `ArtifactStatus`** (each entry of `status --json`'s `artifacts[]`; existing fields `id`/`outputPath`/`status`/`missingDeps?` from [instruction-loader.ts:120](../../../src/core/artifact-graph/instruction-loader.ts) unchanged): + +```jsonc +{ + "id": "tasks", + "status": "done", + "requires": ["specs", "design"], // direct upstreams (schema edge) + "dependents": [], // direct dependents (reverse edge) + "digest": "sha256-relpath-v1:9f2b…", // scheme-tagged; null when no output + "drift": "drifted", // "clean" | "drifted" | "unknown" + "driftFrom": ["specs"] // upstream ids whose digest changed; omitted unless drifted +} +``` + +**Impact response** (`status --change --impact --json`): the named artifact's transitive downstream in build order, each an extended `ArtifactStatus` plus its `resolvedOutputPath`, so a consumer reads and rewrites without a second call: + +```jsonc +{ "impactOf": "proposal", "downstream": [ { /* ArtifactStatus + resolvedOutputPath */ } ] } +``` + +**Drift baseline ledger** (optional `baselines` map on `ChangeMetadataSchema`, persisted in `.openspec.yaml`): + +```yaml +baselines: + tasks: + scheme: sha256-relpath-v1 # digest scheme tag (see forward-compat below) + upstreams: + specs: "sha256-relpath-v1:1a3c…" + design: null # recorded absent → creating it later = drift +``` + +**Digest scheme tag & forward-compat.** Every digest and baseline carries a scheme tag (e.g. `sha256-relpath-v1`). Drift compares **only same-scheme** digests; if a baseline's scheme is unrecognized or older than the current one (e.g. the canonicalization changes in a future version), that artifact's drift is reported `unknown` rather than silently mis-compared, and a re-`reconcile` re-establishes it. This keeps the deterministic guarantee honest across versions instead of producing confident wrong answers. + +**Writing the ledger.** There is no central change-metadata writer today ([change-metadata/index.ts](../../../src/core/change-metadata/index.ts) only re-exports the schema). `openspec reconcile` performs a safe read-modify-write of `.openspec.yaml` — preserving all existing fields — mirroring the store's existing `parseStoreMetadataState`/`serializeStoreMetadataState`/`writeStoreMetadataState` pattern ([store/foundation.ts](../../../src/core/store/foundation.ts)) rather than inventing a new persistence style. + ## Risks / Trade-offs - **Digest stability across platforms** → newline-normalize before hashing (Decision 2); a cross-platform test pins identical digests on CRLF and LF inputs. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index d3c56111f5..f4e36e3474 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -91,7 +91,7 @@ Supersedes: Delineated from adjacent commands/PRs (distinct surfaces — coordinate, don't collide): - [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` (open) — resolves ambiguity *within a single artifact* via Q&A. Complementary upstream step: clarify sharpens one artifact; `/opsx:update` then propagates the resulting change across its dependents. Different scope (intra- vs. cross-artifact). -- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review` (draft) and [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073) — review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* internally coherent and never touches code. Clean split at the planning/code boundary. +- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review` (draft), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073), and [#880](https://github.com/Fission-AI/OpenSpec/issues/880) (`/opsx:validate` code-vs-living-specs, future-roadmap) — all review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* internally coherent and never touches code. Clean split at the planning/code boundary. - [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098) (open) — adds `artifactOutputComplete`/`artifactOutputContentValid` in `outputs.ts` (the [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084) fix). Audit's "empty/incomplete" structural check **reuses** this rather than reinventing; the new digest helper lives in the same file and composes. - Skill-count concern ([#1263](https://github.com/Fission-AI/OpenSpec/issues/1263), and #783's own note): `/opsx:update` *consolidates* update + regen + refine into one first-class action rather than adding several adjacent commands. diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md index 2e005e3238..8c980293c0 100644 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md @@ -44,6 +44,12 @@ The system SHALL report a deterministic per-artifact content digest and a drift - **WHEN** an artifact has no recorded baseline - **THEN** the artifact's status JSON reports drift as `unknown` rather than drifted or clean +#### Scenario: Drift is unknown when the baseline scheme is unrecognized + +- **WHEN** an artifact's recorded baseline uses a digest scheme the current version does not recognize +- **THEN** the artifact's status JSON reports drift as `unknown` rather than comparing across schemes +- **AND** re-recording the baseline restores a comparable drift signal + #### Scenario: Recording a baseline is an explicit write, separate from status - **WHEN** the baseline is recorded for an artifact via the dedicated record operation diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 92f1e1e139..e9d7cd2a93 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -11,7 +11,7 @@ ## 2. Artifact graph: content digest (deterministic, grounded) -- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: from `resolveArtifactOutputs`, re-derive each file's path **relative to `changeDir` as a forward-slash string**, order files by that relative path (NOT by the absolute-path `.sort()` order `resolveArtifactOutputs` returns — that differs by OS), then SHA-256 over, per file, `relPath` + NUL + newline-normalized (CRLF→LF) content; return undefined when no output exists. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. +- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: from `resolveArtifactOutputs`, re-derive each file's path **relative to `changeDir` as a forward-slash string**, order files by that relative path (NOT by the absolute-path `.sort()` order `resolveArtifactOutputs` returns — that differs by OS), then SHA-256 over, per file, `relPath` + NUL + newline-normalized (CRLF→LF) content; return undefined when no output exists. Prefix the digest with a scheme tag (e.g. `sha256-relpath-v1:`) so future canonicalization changes are detectable, not silently mis-compared. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. - [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest**; **multi-file glob digest identical under simulated Windows vs POSIX absolute paths** (the cross-OS ordering guard); renaming a file within a glob → different digest; missing output → undefined. ## 3. CLI: surface edges, digest, and impact on `status` @@ -48,7 +48,8 @@ > In scope (design Decision 3). Powers unattended/audit drift; consumed by 3.3a's drift signal and 4.x's record-after-edit. -- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional per-artifact map of recorded **direct** upstream digests. +- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional `baselines` map: per artifact, a `scheme` tag and a `upstreams` map of direct-upstream id → recorded digest (or `null` for absent). Add a safe read-modify-write helper for `.openspec.yaml` that preserves existing fields, mirroring the store's `parseStoreMetadataState`/`serializeStoreMetadataState`/`writeStoreMetadataState` pattern (`src/core/store/foundation.ts`) — there is no central change-metadata writer today (`change-metadata/index.ts` only re-exports the schema). +- [ ] 7.1b Drift comparison compares only same-scheme digests; unrecognized/older scheme → `unknown` (re-`reconcile` restores it). - [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current direct-upstream digests for the affected artifact ("reconciled"). - [ ] 7.3 CLI drift report: an artifact is drifted iff a current direct-upstream digest differs from its recorded baseline; no baseline → `unknown` (never a false positive). Verify transitive drift emerges hop-by-hop: changing `proposal` drifts `specs`; reconciling `specs` then drifts `tasks`. - [ ] 7.4 Tests: record→no-drift; modify upstream→drift on exactly the direct dependents; hop-by-hop transitive drift; absent baseline→`unknown`; ledger round-trips through metadata read/write. From b2f8639fcfeacec6409f3ae2f3a59c50a51fab7e Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 16:21:40 -0500 Subject: [PATCH 08/11] docs(openspec): simplify add-update-workflow to a thin /opsx:update skill Rework per @TabishB review (PR #1278): the proposal over-built. Drop the deterministic-spine machinery and lean on the existing status command. - Cut the reverse-dependency graph API (getDependents/getDownstream), SHA-256 content digests, the .openspec.yaml baseline ledger, the `openspec reconcile` write op, the drift report, and `status --impact`. Removes the artifact-graph and cli-artifact-workflow spec deltas. - Reframe propagation as bidirectional coherence (editing design can require revising proposal), not downstream-only. - Center the feature on one thin skill over the existing `openspec status` / `openspec list`; design now sketches the actual minimal skill instruction body ("written by hand"). - v1 adds no new CLI/graph/schema code: just update-change.ts + wiring. Validates clean: `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-update-workflow/design.md | 199 ++++++------------ .../changes/add-update-workflow/proposal.md | 106 +++------- .../specs/artifact-graph/spec.md | 79 ------- .../specs/cli-artifact-workflow/spec.md | 99 --------- .../specs/opsx-update-skill/spec.md | 90 ++++---- openspec/changes/add-update-workflow/tasks.md | 64 ++---- 6 files changed, 161 insertions(+), 476 deletions(-) delete mode 100644 openspec/changes/add-update-workflow/specs/artifact-graph/spec.md delete mode 100644 openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index f37b231cae..6aa2ece109 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -1,160 +1,101 @@ -# Design: `/opsx:update` — graph-driven artifact update +# Design: `/opsx:update` — a thin update skill ## Context -OPSX models a change as a DAG of artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them and reports forward state (`getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete`). State is detected purely by filesystem existence ([src/core/artifact-graph/state.ts](../../../src/core/artifact-graph/state.ts)). +OPSX models a change as a small DAG of planning artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them, and `openspec status --change --json` already reports, per artifact: its `status` (`done`/`ready`/`blocked`), its `outputPath`, and — via `artifactPaths` — its resolved and existing on-disk paths, plus the change's `schemaName` and `isComplete`. `openspec list --json` lists changes by recency. -The graph is only ever traversed **forward** ("what can I build next?"). The update action requires the **backward** traversal ("I changed X — which downstream artifacts must be revisited?"). Notably, `getBuildOrder()` already constructs the reverse-edge `dependents` map (graph.ts:82-87) for Kahn's algorithm — computed and discarded — and `getUnlockedArtifacts()` already returns direct dependents for one node. This change exposes the transitive form and builds two thin, deterministic layers on top. +That is everything an update skill needs. The artifacts are a handful of markdown files on disk; the agent can read them. So `/opsx:update` is built as a thin skill over the **existing** CLI, in the same shape as `continue-change.ts` (select change → `openspec status --json` → act). -The orchestrating skills currently embed `proposal → specs → design → tasks` as hardcoded prose patterns that "override the schema instruction field" ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)); `continue-change.ts` even contradicts its own guardrail ("Use the schema's artifact sequence, don't assume specific artifact names") — and the hardcoded block is duplicated across both templates in that file (the skill at lines 103-112 and the command variant at 225-234), so the de-hardcoding follow-up must fix both copies. The new skill must not repeat this — it reads ids and edges from CLI JSON. +This proposal began larger — a reverse-dependency graph API, content digests, a baseline ledger, a `reconcile` write op, a `status --impact` selector. Review feedback ([PR #1278](https://github.com/Fission-AI/OpenSpec/pull/1278)) was that this over-builds: coding agents tend to over-complicate skills, and the feature should work off the existing `status` command with as little new code as possible. This design follows that steer. ## Goals / Non-Goals **Goals** -- A `/opsx:update` action that propagates an edit to one artifact across exactly its downstream dependents, and an audit mode that checks a whole change for incoherence. -- Drive everything from the schema's artifact graph — zero hardcoded artifact names — so custom schemas work. +- A `/opsx:update` action that revises a change's existing planning artifacts and keeps them coherent with one another. +- Drive it from the artifact set and paths the CLI already reports — zero hardcoded artifact names — so custom schemas work. - Edit planning artifacts only; never touch code. Confirm every edit with the user. -- Reuse the graph the engine already has; add the minimum surface (one reverse query + one content-digest signal + status fields). Keep correctness-critical logic deterministic and in the CLI; keep the agent's role to semantic rewriting only. +- Add as little code as possible: one skill template, no changes to the graph engine, the `status` command, or the metadata schema. **Non-Goals** -- Automatic, unattended regeneration (the user always chooses — same stance as the superseded stub). -- A new top-level `openspec update*` CLI verb (name is taken; see Decision 4). -- Content-level completion validation of artifacts ([#1084](https://github.com/Fission-AI/OpenSpec/issues/1084)) — distinct change. -- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — phase 2; see Decisions resolved. +- A new top-level `openspec update*` CLI verb (name is taken; see Naming). +- Automatic, unattended regeneration (the user always confirms). +- Content digests, a drift/staleness signal, a baseline ledger, a `reconcile` op, or a `status --impact` selector (see "Why not the heavier machinery"). - Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. +- Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — a later proposal; this change is intra-change. -## Decisions - -### 1. Expose the reverse edges instead of re-deriving them -Add `getDependents(id)` (direct dependents) and `getDownstream(id)` (transitive closure of dependents, in topological order) to `ArtifactGraph`. This is not new logic so much as surfacing logic that already exists: `getBuildOrder()` builds the full reverse-adjacency `dependents` map (graph.ts:82-87) and discards it, and `getUnlockedArtifacts()` ([instruction-loader.ts:366](../../../src/core/artifact-graph/instruction-loader.ts)) already computes direct dependents (exposed as `unlocks`). Factor the reverse map into a shared helper; order `getDownstream` by the existing `getBuildOrder()`. Topological order guarantees a downstream walk revisits each artifact only after the upstreams it depends on — so a single pass converges (for spec-driven, editing `proposal` revisits `specs`, `design`, then `tasks`, never `tasks` before `specs`). - -The result is **deterministic**: a pure function of `requires` edges and the change directory's files, identical on every run and platform, and therefore unit-testable (see tasks). That determinism is the reason it belongs in the CLI rather than the skill — and *not* computing it in the skill is precisely how we avoid the #777 hardcoding class. - -Granularity is the **artifact** (the schema's unit), not the individual file. Editing one file under a glob artifact (e.g. one `specs//spec.md` within the `specs` artifact) means the `specs` artifact changed, and its dependents (`tasks`) are downstream; sibling spec files are not "downstream" of each other. `getDownstream` terminates because the schema loader already rejects cyclic graphs (`artifact-graph`'s "Cyclic dependencies detected"), so the closure is finite by construction. - -### 2. A grounded signal: content digests, not mtimes -The earlier draft detected staleness from filesystem mtimes. **Rejected.** mtime is not grounded in reality: `git checkout`/`clone`/`stash`, `touch`, editors, and formatters all rewrite mtime without changing content, and content can change while mtime is preserved (`git` does not restore historical mtimes). It is neither reproducible nor a true content signal — the opposite of what we want. - -Instead, each artifact carries a **content digest**: SHA-256 over its output file(s), where each file contributes its **change-relative forward-slash path** plus its newline-normalized (CRLF→LF) content, and files are ordered by that relative path. Two cross-platform subtleties make this non-trivial and must be specified, not assumed: - -- `resolveArtifactOutputs` returns **absolute, canonicalized** paths sorted with `.sort()` ([outputs.ts:34](../../../src/core/artifact-graph/outputs.ts)). Absolute-path order differs by OS (separators, drive letters, case-folding), so for a multi-file glob artifact (`specs/**/*.md`) a naive "concat in returned order" would hash to **different** values on Windows vs. POSIX. The digest helper therefore re-derives each file's path *relative to the change dir*, converts to forward slashes, and orders by that — independent of the OS. -- Including the relative path in the hash (not just content) means renaming or moving a file inside a glob artifact changes the digest, which is correct: the artifact's file set is part of its identity. - -There is no content-hash utility in the repo today (verified: no `createHash`/`sha256` usage; only `crypto.randomUUID` in telemetry), so this is a small new helper in `outputs.ts`. The digest is exposed on read-only `openspec status --json`. It is deterministic, reproducible, and grounded in the actual bytes. - -**Drift** is then defined deterministically: a downstream artifact *D* has drifted against upstream *U* iff `digest(U_now) ≠ digest(U_recorded_when_D_was_reconciled)`. The recorded baseline is a small per-change **digest ledger** (Decision 3). Until a baseline is recorded, drift is reported as **`unknown`**, never inferred — so the audit produces zero false positives, in contrast to the mtime heuristic which produced many. - -[Risk] No baseline yet (e.g. pre-existing changes) → Mitigation: report `unknown`, fall back to the deterministic *structural* checks (missing/empty output — reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s `artifactOutputComplete` rather than reinventing — and blocked/incomplete status) and the user-named targeted flow, which need no baseline. - -### 3. The digest ledger (deterministic drift baseline) — in scope -To detect drift without the user naming what changed, record the baseline. Extend `ChangeMetadataSchema` (today just `schema`/`created`/`goal`/`affected_areas`/`initiative`) with an optional map: for each artifact, the digests of its **direct** upstream outputs at the moment it was last *reconciled* — where "reconciled" means the artifact was just (re)generated or explicitly confirmed up-to-date against those upstreams. If a direct upstream has no output when the baseline is recorded, it is stored as an explicit *absent* marker, so that later creating that upstream registers as drift rather than being silently missed. - -Recording is a **dedicated, explicit write operation** (working name `openspec reconcile --artifact `), invoked by `/opsx:update` after each confirmed edit and available for the generating flows (`propose`/`continue`/`ff`) to call after they write an artifact — so the agent triggers a deterministic write and never computes hashes. It is deliberately **not** a flag on `openspec status`: `status` is read-only, and a read command that silently mutates a baseline is a footgun (every status call would move the reference the next drift check compares against). Keeping the read (`status`) and the write (`reconcile`) on separate verbs preserves that invariant. For changes that predate the ledger (no recorded baseline), drift is reported `unknown` and the audit falls back to the structural checks — honest, never a false positive. - -Tracking **direct** (not transitive) upstreams is deliberate and makes drift compose correctly through the DAG: if `proposal` changes, `specs` drifts (its recorded `proposal` digest no longer matches); reconciling `specs` rewrites its content, which changes `specs`'s own digest, which in turn makes `tasks` drift. Transitive staleness therefore emerges one hop at a time as the user works downstream — no transitive bookkeeping required, and the order matches the revisit order from Decision 1. - -**Why in scope (not deferred):** the ledger is what makes audit-mode drift *deterministic* rather than agent-guessed; deferring it would ship audit as "structural facts + ask," which is the weaker half. The cost is contained — one optional metadata field and one dedicated `reconcile` write path — and the generating-flow integration is optional (absent baseline degrades gracefully to `unknown`), so this does not block on touching `propose`/`continue`/`ff`. - -*Alternatives rejected:* (a) mtime — see Decision 2. (b) Pure git diff — deterministic only for committed files, but planning artifacts are routinely uncommitted while being drafted, so it misses the common case. Digests work regardless of git state. - -### 4. The determinism boundary (what the agent may and may not decide) -The CLI owns every decision that is a function of the graph and the bytes: **which** artifacts are downstream, **what order** to revisit them, **which** files each maps to, and **whether** content has drifted. The agent owns only the irreducibly semantic act: reading a downstream artifact against its changed upstream and **rewriting its prose** to restore coherence. The skill MUST obtain the file list and order from `openspec status --impact` — it may not enumerate or order artifacts itself. This is the same split #1277 applies to archive/validate, and it is what makes the feature "deterministic" rather than "an agent that usually gets it right." - -### 5. The skill flow (thin wrapper over the deterministic spine) -`/opsx:update` mirrors `continue-change.ts`'s shape (select change → `openspec status --json` → act) but **only reads ids/paths/edges/digests from JSON** — no embedded artifact-name patterns. Flow: - -1. Resolve the change (infer from context or prompt via `openspec list --json`, like `/opsx:continue`). -2. `openspec status --change --json` → artifacts with `requires`, `dependents`, `digest`, resolved paths. -3. Pick mode: - - **Targeted**: user names the artifact they changed / want to change (or the skill infers it and confirms). `openspec status --impact --json` → ordered downstream set. For each *existing* downstream artifact *in the CLI-given order*: read it + its changed upstreams, propose a concrete diff, **ask**, apply, re-check. Downstream artifacts that do not exist yet are not invented here — the skill notes them and points to `/opsx:continue` (update revises; continue creates). - - **Audit**: no target → ask the CLI which artifacts have drifted (digest vs. recorded baseline); where no baseline exists, present the deterministic structural facts already available from `status` — an artifact whose output is missing or empty, or one still `blocked`/incomplete — and ask. (When #1277's `validateChangeCapabilityCoverage` is present, declared-capability-without-spec joins this set.) Offer per-artifact fixes in revisit order. -4. Stop at the planning boundary. Never edit code. If a spec/tasks change implies code rework, point to `/opsx:apply`. +## The skill, written by hand -**Intent-change guard.** If the user's revision changes the *intent* (not a refinement), apply the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216-300](../../../docs/opsx.md)) and recommend `/opsx:new` rather than mutating the proposal into different work. +Working backwards from "what is the minimal instruction set," here is the skill body in sketch form. It is short on purpose — few tokens, few commands: -[Risk] Agent edits code while "updating the plan" (the [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) complaint) → Mitigation: explicit, repeated guardrail "planning artifacts only; if implementation must change, hand to `/opsx:apply`"; the skill's write targets are constrained to `resolvedOutputPath`s from the graph. - -### 6. Naming: `/opsx:update` skill, not `openspec update` CLI -`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts:202](../../../src/cli/index.ts)). Reusing it would overload one verb with two unrelated meanings. Decision: the artifact-update action is the **skill** `/opsx:update`; CLI support is read-only additions to `openspec status` (`--impact`, extra JSON fields) plus one small write verb `openspec reconcile` for the drift baseline (kept off `status` to preserve its read-only invariant — Decision 3). No new `openspec update*` verb; the only new verb is `reconcile`, named for what it does. - -Alternatives considered: (a) `openspec regen --from ` as #705 literally asks — rejected: a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. (b) `openspec update artifacts …` subcommand — rejected: still collides conceptually and splits the action across two surfaces. - -### 7. Compose with #1277 and #1098 -The status-JSON additions live in `formatChangeStatus` ([instruction-loader.ts](../../../src/core/artifact-graph/instruction-loader.ts)) and the `status` command ([src/commands/workflow/status.ts](../../../src/commands/workflow/status.ts)); [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) touches the same status path and adds deterministic capability-coverage helpers (`extractDeclaredCapabilities`, `validateChangeCapabilityCoverage`), and [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098) adds `artifactOutputComplete`/`artifactOutputContentValid` in `outputs.ts` — the exact file the digest helper (Decision 2) lives in. None are in this branch's base (main), so audit's structural checks should *reuse* them as they land rather than reimplement; the JSON additions here are purely additive fields and compose cleanly. - -### 8. The command family: one action, distinct surfaces -The tracker has accumulated several adjacent requests; good design means one coherent action, not five overlapping commands ([#1263](https://github.com/Fission-AI/OpenSpec/issues/1263) and #783 both flag skill-count growth). The boundaries: - -| Command | Scope | Reads | Writes | -|---|---|---|---| -| `/opsx:clarify` ([#702](https://github.com/Fission-AI/OpenSpec/pull/702)) | *within* one artifact (ambiguity Q&A) | one artifact | that artifact | -| **`/opsx:update`** (this) | *across* a change's planning artifacts (propagate + audit) | the artifact graph | planning artifacts | -| `/opsx:review` ([#1251](https://github.com/Fission-AI/OpenSpec/pull/1251)), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073), [#880](https://github.com/Fission-AI/OpenSpec/issues/880) | plan *vs. code* | artifacts + code | nothing (read-only) | -| `/opsx:verify` | archive readiness | artifacts + code | nothing | - -`/opsx:update` deliberately **subsumes** the update/regen/refine cluster (#1188/#705/#673/#783/#1206) into its two modes instead of spawning `/opsx:regen`, `/opsx:rebuild`, and `/opsx:refine` separately. It composes with clarify (a within-artifact step that often precedes an update) and review (a code-side check that follows apply); it never overlaps their read/write surfaces. - -**Answering #783's form question (skill vs. extend `validate`).** Both, split by determinism: the checks that are pure functions of content and graph — drift (digests), structural completeness (#1098), capability coverage (#1277) — are CLI-shaped and are the natural content of a future schema-aware `openspec validate` coherence rule (aligns with [#829](https://github.com/Fission-AI/OpenSpec/issues/829)); the cross-artifact *semantic* review (scope contradictions, duplication with existing specs, missing abstractions) cannot be deterministic and is the skill's job. This change ships the `status` data + skill; surfacing the deterministic subset in `validate` for a CI gate is a scoped follow-up (Decisions resolved, item 6). - -## Data contracts +``` +Revise a change's planning artifacts and keep them coherent. Never edit code. + +1. Resolve the change. + - If named, use it. Else infer from context; if unclear, run `openspec list --json` + and ask the user to choose (most-recently-modified first). Never auto-select. + +2. Get the artifacts. + - Run `openspec status --change "" --json`. + - Read `artifacts[]` (ids + status) and `artifactPaths` (resolved paths). These come + from the active schema — do not assume the artifact ids or paths. + +3. Understand the request. + - If the user named a change ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent," treat it as a coherence review. + +4. Read and reconcile. + - Read the artifact(s) the request touches and the other existing artifacts in the change. + - Apply the requested edit. Then check every other existing artifact against it — in any + direction (an edit to design may require revising the proposal, not only the tasks) — + and note what is now inconsistent, missing, or contradictory. + - Do not invent artifacts that don't exist yet; point the user to `/opsx:continue` to create them. + +5. Confirm and apply, one artifact at a time. + - Show each proposed revision and why. Write only after the user confirms. + - When a substantial rewrite is needed, `openspec instructions --change "" --json` + gives that artifact's rules/template to follow. + +Guardrails: +- Planning artifacts only. If the plan now implies code changes, stop and point to `/opsx:apply`. +- Use artifact ids/paths from `openspec status`; never branch on literal proposal/specs/design/tasks names. +- If the request changes the change's *intent* rather than refining it, recommend `/opsx:new` + (the "Update vs. Start Fresh" heuristic, docs/opsx.md). +``` -Concrete shapes so the surface is unambiguous for implementers. All additions are **additive** to existing structures; nothing existing changes type. +The `spec-driven` artifact names may appear once, as a worked *example* of how to apply step 4, exactly as `continue-change.ts` does today — but the control flow reads ids from the CLI, so the skill never branches on those names. A template test asserts there is no name-based branching (the anti-[#777](https://github.com/Fission-AI/OpenSpec/issues/777) guard). -**Extended `ArtifactStatus`** (each entry of `status --json`'s `artifacts[]`; existing fields `id`/`outputPath`/`status`/`missingDeps?` from [instruction-loader.ts:120](../../../src/core/artifact-graph/instruction-loader.ts) unchanged): +## Decisions -```jsonc -{ - "id": "tasks", - "status": "done", - "requires": ["specs", "design"], // direct upstreams (schema edge) - "dependents": [], // direct dependents (reverse edge) - "digest": "sha256-relpath-v1:9f2b…", // scheme-tagged; null when no output - "drift": "drifted", // "clean" | "drifted" | "unknown" - "driftFrom": ["specs"] // upstream ids whose digest changed; omitted unless drifted -} -``` +### 1. Bidirectional coherence, not downstream propagation +The artifact graph has a build *order*, but "what needs updating after an edit" is not strictly downstream. If `design` changes, the `proposal` it elaborates may need to change too; if `tasks` reveal a missing capability, the `specs` may need a new requirement. The skill therefore reads the change's artifacts and reconciles them in whatever direction the edit demands. Build order is still useful as a default *reading* order and for presenting fixes, but it is not a constraint on which artifacts may be revised. This is why the design does not add a one-directional `getDownstream` / `--impact` primitive: it would encode the wrong model. -**Impact response** (`status --change --impact --json`): the named artifact's transitive downstream in build order, each an extended `ArtifactStatus` plus its `resolvedOutputPath`, so a consumer reads and rewrites without a second call: +### 2. Lean on the existing `status` command +`openspec status --change --json` already returns the artifact set, per-artifact status, and resolved paths (`artifactPaths..resolvedOutputPath` / `existingOutputPaths`). The skill needs nothing more to know what exists and where it lives. Picking the change reuses `openspec list --json`, exactly like `/opsx:continue`. No new CLI surface is introduced. -```jsonc -{ "impactOf": "proposal", "downstream": [ { /* ArtifactStatus + resolvedOutputPath */ } ] } -``` +### 3. Why not the heavier machinery (digests, ledger, reconcile, impact) +The first draft proposed SHA-256 content digests, a per-change baseline ledger in `.openspec.yaml`, an `openspec reconcile` write op, a derived drift signal, and a `status --impact` selector — so the CLI could tell the agent *which* artifacts are stale without the agent reading them. -**Drift baseline ledger** (optional `baselines` map on `ChangeMetadataSchema`, persisted in `.openspec.yaml`): +Rejected for v1, because the cost outweighs the need: +- The artifacts are a few markdown files. An agent that is going to *rewrite* them must read them anyway, so computing staleness for it saves little and adds a stateful subsystem (a ledger that `status` must not mutate, a separate write verb, scheme-versioning for forward-compat, cross-platform digest canonicalization, and the round-trip tests for all of it). +- A digest/ledger only earns its keep when something must judge staleness *without* reading content — e.g. unattended drift detection across many changes ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) cross-change, [#846](https://github.com/Fission-AI/OpenSpec/issues/846) tracking files). Those are out of scope here. When one of them becomes concrete, this machinery can be designed against that real need. -```yaml -baselines: - tasks: - scheme: sha256-relpath-v1 # digest scheme tag (see forward-compat below) - upstreams: - specs: "sha256-relpath-v1:1a3c…" - design: null # recorded absent → creating it later = drift -``` +So `/opsx:update` v1 has the agent read the change's artifacts and judge coherence directly. If, after using it, a deterministic signal proves necessary, the smallest first step is to expose the schema's `requires` edges on `status --json` (a single additive field, no new command) — and only then consider digests. -**Digest scheme tag & forward-compat.** Every digest and baseline carries a scheme tag (e.g. `sha256-relpath-v1`). Drift compares **only same-scheme** digests; if a baseline's scheme is unrecognized or older than the current one (e.g. the canonicalization changes in a future version), that artifact's drift is reported `unknown` rather than silently mis-compared, and a re-`reconcile` re-establishes it. This keeps the deterministic guarantee honest across versions instead of producing confident wrong answers. +### 4. Naming: `/opsx:update` skill, not `openspec update` CLI +`openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts](../../../src/cli/index.ts)). Overloading it would give one verb two unrelated meanings. The artifact-update action is therefore the **skill** `/opsx:update`, with no new `openspec` verb at all. Considered and rejected: `openspec regen --from ` ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) — a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. -**Writing the ledger.** There is no central change-metadata writer today ([change-metadata/index.ts](../../../src/core/change-metadata/index.ts) only re-exports the schema). `openspec reconcile` performs a safe read-modify-write of `.openspec.yaml` — preserving all existing fields — mirroring the store's existing `parseStoreMetadataState`/`serializeStoreMetadataState`/`writeStoreMetadataState` pattern ([store/foundation.ts](../../../src/core/store/foundation.ts)) rather than inventing a new persistence style. +### 5. Guardrails (the part that makes it the requested command) +- **Planning artifacts only.** The skill's write targets are the artifact paths from `status`; if a revision implies code changes it stops and points to `/opsx:apply`. This directly answers [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint that the manual workaround edits code. +- **Schema-driven.** Ids and paths come from `status`; no branching on literal `proposal`/`specs`/`design`/`tasks`. Works for custom schemas ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). +- **Confirm each edit.** One artifact at a time, shown before writing. +- **Intent guard.** A revision that changes intent rather than refining it is redirected to `/opsx:new` (the "Update vs. Start Fresh" heuristic, [docs/opsx.md](../../../docs/opsx.md)). ## Risks / Trade-offs -- **Digest stability across platforms** → newline-normalize before hashing (Decision 2); a cross-platform test pins identical digests on CRLF and LF inputs. -- **No baseline → no auto-drift** → the deterministic spine (impact set) and structural checks still work with zero baseline; drift is `unknown`, never a false positive. -- **Convergence** depends on a correct DAG. If `requires` edges are wrong (e.g. [#695](https://github.com/Fission-AI/OpenSpec/issues/695): `design` omits `specs`), propagation misses a real dependency. Trade-off: surface the consequence; the edge fix is #695's, not ours. -- **Scope creep into cross-change audit** ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)) → explicitly phase 2; intra-change first. -- **Skill drift back to hardcoding** → covered by a test asserting the update template contains no literal `proposal`/`specs`/`design`/`tasks` artifact-name branching (it must read ids from JSON). +- **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3). +- **Coherence quality depends on the agent.** Mitigated by confirming every edit and by keeping scope to one change's artifacts (a small, readable set). +- **Skill drifts back to hardcoding artifact names.** Mitigated by a template test asserting the control flow reads ids from `status` JSON and contains no name-based branching. ## Migration Plan -Backward-compatible and additive. New graph methods; new status JSON fields (`requires`/`dependents`/`digest`/`drift`); a new read-only `--impact` flag and a separate `reconcile` write command; one new optional field on `ChangeMetadataSchema` (absent on existing changes → drift `unknown`, no migration); one new skill template behind the expanded-workflow profile. No existing command changes its default human-readable behavior. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. - -## Decisions resolved (for build-out) - -These were open; each is now committed to the happy path so build-out has no dangling forks. Boundaries are deliberate scope lines, not deferrals of the core feature. - -1. **Digest ledger: in scope.** Drift is deterministic via the recorded baseline (Decision 3), not agent-guessed. Pre-existing changes without a baseline degrade to `unknown` + structural checks. -2. **Targeted-mode entry point: baseline-aware.** When a recorded baseline exists, the skill defaults to the drifted set and asks the user to confirm/override; with no baseline it asks which artifact changed. It does not silently act on inference. -3. **Relationship to `/opsx:apply`: standalone.** `/opsx:update` is its own action; `apply` does not embed it. Where `apply` re-reads artifacts and detects upstream drift, it points the user to `/opsx:update` rather than updating planning artifacts itself (keeps apply's job = code, update's job = plan). -4. **Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)): out of scope, named follow-up.** This change is intra-change. Cross-change ("config/guideline changed → re-audit every active change") reuses the same impact/digest primitives and is a clean follow-up proposal once those land — explicitly not blocked on, and not crammed in. -5. **Retire hardcoded patterns in `continue`/`ff` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777)): fast-follow.** This change establishes the graph-driven pattern and proves it in the new skill (with the anti-hardcoding test); applying it to the existing `continue`/`ff` templates is a focused follow-up so this change does not also rewrite two unrelated templates. -6. **Deterministic coherence in `openspec validate` ([#783](https://github.com/Fission-AI/OpenSpec/issues/783) option B, [#829](https://github.com/Fission-AI/OpenSpec/issues/829)): coordinated follow-up.** The drift/completeness data lives in `status` for the skill now; promoting it to a CI-runnable `validate` rule is valuable but overlaps the #1277/#1098/#829 validate surfaces, so it lands once those settle rather than widening this change. -7. **Naming: `/opsx:update` (umbrella).** It is the missing first-class *action* and covers both propagate and audit; #783's `/opsx:refine` reads as audit mode and would, as a second command for one action, reintroduce the sprawl #1263 flags. If the team wants the word "refine," it aliases the same skill rather than splitting the action. +Additive and backward-compatible. One new skill template behind the expanded-workflow profile; one docs row. No existing command changes behavior; no schema or graph changes. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index f4e36e3474..1ba7efd8fd 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -1,109 +1,65 @@ ## Why -OPSX names **four** first-class actions — "create, implement, **update**, archive — do any of them anytime" ([docs/opsx.md:52](../../../docs/opsx.md)). Three ship as commands. **`update` does not exist.** The only mechanism offered is *"edit the files manually"* — and when you edit one artifact, nothing tells you which others now contradict it. +OPSX names **four** first-class actions — "create, implement, **update**, archive — do any of them anytime" ([docs/opsx.md:52](../../../docs/opsx.md)). Three ship as commands. **`update` does not exist.** The only mechanism offered is *"edit the files manually"* — and when you edit one artifact, nothing helps you keep the rest of the change coherent. Worse, the manual workaround lets the agent edit **code** when the user only wanted to revise the **plan** ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). -It is the most-requested missing capability in the tracker, and it is one gap: *after artifacts exist, a user revises one, and no command propagates that revision to the artifacts depending on it.* The manual workarounds let the agent edit **code** when the user only wanted to revise the **plan**. Yet the fix is latent: every schema is an artifact DAG, but the graph is only read **forwards** ("what next?"); update needs the **backward** read ("I changed X — what's stale downstream?"). This change surfaces that one missing query and puts `/opsx:update` on top — graph-driven, so it asks what depends on what instead of hardcoding file names. - -## Background: the request cluster - -Verified against `Fission-AI/OpenSpec` on 2026-06-29. Six angles, one gap: - -1. **No update command exists.** "I want a command that can modify the proposal, design and task and then I can check the design and apply." The same issue reports the exact failure mode of the manual workaround: *"the agent is not very clever sometimes, it modified the proposal and the code at the same time. So I must say that you only modify the proposal, design and task, not the code every time."* ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)) - -2. **No way to rebuild downstream artifacts from a modified upstream.** "Once a proposal has been applied, there is **no built-in command to regenerate downstream artifacts** after modifying an upstream file… This breaks the promise of a declarative, spec-driven workflow." The issue asks for `openspec regen --from proposal`. ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) - -3. **Users don't know which command updates an artifact.** "If I need to update or change the content of a document, which opsx command should I use to regenerate it? Is it possible to regenerate a document even after the coding phase has started?" Today the honest answer is "none — edit by hand." ([#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618)) - -4. **`/opsx:continue` over-reaches when you only wanted to revise.** "I just ask AI to update the spec/proposal/design… but it sometimes generates another artifact. I'd be happier if it just did the updates I asked for and waited." ([#673](https://github.com/Fission-AI/OpenSpec/issues/673)) - -5. **The cohesive-audit need is explicit.** "Technical decisions in my project keep changing… I need to manually request my coding agent to review and update all features' specs, design, proposal and tasks to comply." The request is a command that "verifies and updates the necessary files." ([#247](https://github.com/Fission-AI/OpenSpec/issues/247)) - -6. **The would-be fix must not be hardcoded.** The existing in-repo stub proposes tracking dependencies by literal filename. But the orchestrating skills already encode `proposal → specs → design → tasks` as **hardcoded artifact patterns** that "override the schema instruction field," breaking custom schemas. Any update mechanism that re-hardcodes those names inherits the same bug. ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)) - -### The root cause, concretely - -The backward read is almost already built. `ArtifactGraph.getBuildOrder()` constructs the full reverse-adjacency `dependents` map while topologically sorting ([src/core/artifact-graph/graph.ts:82-87](../../../src/core/artifact-graph/graph.ts)) — then discards it at function exit. The *direct* dependents query even exists already under another name: `getUnlockedArtifacts()` ([instruction-loader.ts:366](../../../src/core/artifact-graph/instruction-loader.ts)) computes "who requires this artifact," and `openspec instructions --json` already returns it as `unlocks`. What is missing is only (a) promoting that to a first-class, *transitive* graph query, and (b) ordering the result by the build order the engine already computes ([instruction-loader.ts:429](../../../src/core/artifact-graph/instruction-loader.ts)). Every other engine query — `getBuildOrder`, `getNextArtifacts`, `getBlocked`, `isComplete` — answers *"what do I build next?"* (forward). None answers *"I changed X — which downstream artifacts must be revisited, in what order?"* (backward). That backward set is **deterministic** — a pure function of the schema's `requires` edges and the files on disk — which is exactly why it belongs in the CLI, not in an agent's judgment. - -There is a stub already in the repo — `openspec/changes/add-artifact-regeneration-support/proposal.md` (proposal-only, no specs or tasks) — which identifies staleness detection and regeneration but proposes tracking dependencies by hardcoded filename and metadata files. **This change supersedes that stub** with a graph-driven design and the user-facing `/opsx:update` command the cluster is actually asking for. +This is the most-requested missing capability in the tracker. It is one gap with several faces, and the fix is small: a thin `/opsx:update` skill that revises a change's planning artifacts and keeps them coherent with each other, built on the **existing** `openspec status` / `openspec list` commands. No new graph engine, no digests, no ledger — just an agent that reads the change's artifacts and updates what needs updating, with the user's confirmation. ## What Changes -The design principle, borrowed from [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) and the steer to make this *deterministic and grounded in reality*: **the CLI decides which files to revisit and in what order (deterministic, reproducible); the agent only rewrites prose (the irreducibly semantic part).** Everything that can be a pure function of the schema graph and the bytes on disk is computed by the CLI and verified by tests, not left to agent judgment. Ordered by leverage: +The whole feature is a single new workflow skill, `/opsx:update`. Written by hand, its instruction set is short: -1. **DETERMINISTIC SPINE — the impact set (`artifact-graph` + `cli-artifact-workflow`).** Given an artifact id, the CLI returns the exact transitive set of downstream artifacts to revisit, in build order, each with its resolved on-disk paths and existence status. This is a pure function of `requires` edges and the filesystem — same inputs, same output, on every platform — so it is testable and reproducible. It is built by promoting logic the engine already has (`getUnlockedArtifacts` for direct dependents, `getBuildOrder` for ordering, `resolveArtifactOutputs` for paths), and it is schema-agnostic: it reads ids and edges, never artifact names. Update revises the downstream artifacts that **exist**; ones not yet created are deferred to `/opsx:continue` (update revises, continue creates). This is the whole value of the primary flow — *the user (or the agent, confirmed) names what changed, and the CLI deterministically says exactly what else must be reviewed.* +1. **Understand the request** — what the user wants to revise (or, with no specific ask, "review this change for coherence"). +2. **Get the artifacts** — run `openspec status --change --json` to learn which artifacts exist, their status, and their resolved paths. (`openspec list --json` to pick the change when it isn't given.) +3. **Read and revise** — read the relevant artifacts, make the requested edit, then check the change's **other** artifacts against it and propose any follow-on edits needed to keep the plan coherent. +4. **Confirm and apply** — show each proposed revision, write only after the user confirms. -2. **GROUNDED SIGNAL — content digests, not clock times (`artifact-graph` + `cli-artifact-workflow`).** The earlier draft proposed mtime-based staleness; that is **rejected** because mtime is not grounded in reality — a `git checkout`, `git clone`, `touch`, or formatter perturbs it, so it is neither reproducible nor a true signal of content change. Instead each artifact carries a **content digest**: a SHA-256 over its output file(s), newline-normalized and ordered by change-relative forward-slash path (not the OS absolute path), so the digest is identical on Windows and POSIX — including multi-file glob artifacts like `specs/**/*.md`. Digests are exposed on `openspec status --json`. *Drift* is defined deterministically as *current upstream digest ≠ the digest recorded when the downstream was last reconciled*. The recorded baseline — a per-change **digest ledger** in change metadata, tracking each artifact's **direct** upstream digests — is **in scope**: it is what makes audit-mode drift deterministic rather than agent-guessed. It is written by a deterministic, dedicated `openspec reconcile` op (invoked by `/opsx:update` after each confirmed edit; read-only `status` never mutates it); changes that predate the ledger report drift as **unknown** and fall back to structural checks (never a false positive). See design for why mtime/git were rejected and why direct-upstream tracking composes through the DAG. +Two guardrails make it the command the cluster asked for: -3. **SURFACE — graph edges, digests, and impact in the CLI (`cli-artifact-workflow`).** `openspec status --json` gains, per artifact, `requires`, `dependents`, and `digest`; a focused `openspec status --change --impact --json` returns the downstream revisit set (ordered, with paths and digests). The skill consumes this structured data instead of re-deriving the graph from hardcoded names — closing the [#777](https://github.com/Fission-AI/OpenSpec/issues/777) class of bug for the new surface. Default human-readable output is unchanged. +- **Planning artifacts only, never code.** If a revised plan implies code changes, it hands off to `/opsx:apply` ([#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)). +- **Schema-driven, not name-driven.** Artifact ids and paths come from `openspec status`, so the skill works for custom schemas, not just the default `proposal → specs → design → tasks` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). -4. **COMMAND — the `/opsx:update` skill (`opsx-update-skill`).** A thin wrapper over the deterministic spine, in two modes: - - **Targeted** — "I changed (or want to change) artifact X." The skill asks the CLI for X's impact set, then for each downstream artifact *in the order the CLI returns* reads it against its now-changed upstreams, proposes a concrete revision, asks, applies, and re-checks. A single coherent pass over **exactly the files the graph says are related** — the user's "cohesive audit." The skill never computes the file list or the order itself. - - **Audit** — "is this change still internally coherent?" The pre-apply cross-artifact review of [#783](https://github.com/Fission-AI/OpenSpec/issues/783): the skill asks the CLI which artifacts have drifted (digest vs. recorded baseline) and which are structurally incomplete (reusing [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098)'s completeness check and, when present, [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277)'s capability coverage), then performs the semantic review those deterministic signals can't (scope contradictions, spec gaps, duplication) and offers per-artifact fixes. The split is the answer to #783's "skill vs. validate" question: deterministic checks in the CLI, semantic review in the skill. Also the within-a-change form of [#247](https://github.com/Fission-AI/OpenSpec/issues/247). +**Coherence is bidirectional.** Earlier framing treated update as strictly "downstream" propagation. That is wrong: in `proposal → specs → design → tasks`, editing `design` can require revising `proposal` too. The skill reads the change's artifacts and reconciles them in whatever direction the edit demands, rather than assuming a fixed flow. - Two guardrails make it the command the cluster asked for: it **edits planning artifacts only, never code** (directly answering [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint), and it is **graph-driven, never name-driven** — ids and order come from the CLI, so it works for custom schemas, not just `spec-driven` ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). When the user's revision is an *intent* change rather than a refinement, it applies the existing "Update vs. Start Fresh" heuristic ([docs/opsx.md:216](../../../docs/opsx.md)) and points to `/opsx:new` instead of silently sprawling. +### Deliberately not built (yet) -The naming boundary is deliberate: `openspec update` is already taken (it regenerates AI tool/skill files — [src/cli/index.ts:202](../../../src/cli/index.ts)). The artifact-update action is therefore the **skill** `/opsx:update`, backed by read-only additions to `openspec status` and one small dedicated write op (`openspec reconcile`, which records the drift baseline so read-only `status` never mutates state). This change adds **no** new `openspec update*` verb. (See design for the considered alternatives.) +Per the steer to introduce as little code as possible, and only when there is a defined need, this change does **not** add: a reverse-dependency graph API, content digests / staleness signals, a `.openspec.yaml` baseline ledger, an `openspec reconcile` write op, a drift report, or a `status --impact` selector. The agent reads the change's artifacts directly — a handful of markdown files — which is enough to judge coherence. If a future, concrete need emerges (e.g. unattended drift detection across many changes), exposing the schema's `requires` edges on `openspec status --json` is a one-field additive follow-up. It is out of scope here. ## Capabilities ### New Capabilities -- `opsx-update-skill`: A new `/opsx:update` workflow skill that propagates a change to one artifact across its downstream dependents (targeted mode) or audits a whole change for drifted/incoherent artifacts (audit mode), driven entirely by the CLI-computed artifact graph (file list and order), editing planning artifacts only and never code, and confirming each edit with the user. - -### Modified Capabilities - -- `artifact-graph`: The graph exposes reverse-dependency queries (direct `dependents` and transitive, topologically-ordered `downstream`) and a deterministic, newline-normalized content `digest` per artifact — the primitives an update/audit traversal needs, all schema-agnostic and reproducible across platforms. -- `cli-artifact-workflow`: `openspec status --json` includes per-artifact dependency edges (`requires`, `dependents`), a content `digest`, and a deterministic drift signal (current upstream digest vs. recorded baseline, or `unknown` when none); a new `--impact ` selector returns the downstream revisit set in build order (with paths and digests), and a dedicated read/write split keeps `status` read-only while a separate `openspec reconcile` op writes the digest baseline — so skills consume deterministic graph structure as data instead of hardcoding artifact names. +- `opsx-update-skill`: A new `/opsx:update` workflow skill that revises a change's existing planning artifacts and keeps them coherent with one another. It reads the artifact set and paths from `openspec status`, reviews related artifacts in any direction (not only downstream), edits planning artifacts only and never code, and confirms each edit with the user. ## Impact -- `src/core/artifact-graph/graph.ts` — expose `getDependents(id)` (promotes the reverse-adjacency loop at lines 82-87, and the existing `getUnlockedArtifacts` logic) and `getDownstream(id)` (transitive, ordered by the existing `getBuildOrder`); both throw on unknown id. -- `src/core/artifact-graph/outputs.ts` (new digest helper) — `artifactDigest(changeDir, generates)`: from `resolveArtifactOutputs`, order files by their **change-relative forward-slash path** (not the OS absolute-path `.sort()` order, which differs across platforms), then SHA-256 over each file's relative path + newline-normalized (CRLF→LF) content; absent output → no digest. (No content-hash utility exists in the repo today; only `crypto.randomUUID` in telemetry.) -- `src/core/artifact-graph/instruction-loader.ts` (`formatChangeStatus`, ~397-453) — add `requires`, `dependents`, `digest` to each `ArtifactStatus`; the build-order sort at line 429 already gives revisit order. -- `src/commands/workflow/status.ts` (`StatusOptions`, `statusCommand`) + `src/cli/index.ts:488` — add the `--impact ` option returning the ordered downstream set (paths + digests); error on unknown artifact id; default human-readable output unchanged. -- `src/core/change-metadata/schema.ts` — extend `ChangeMetadataSchema` with an optional per-artifact direct-upstream digest ledger (the drift baseline); see design Decision 3. Today the schema holds only `schema`/`created`/`goal`/`affected_areas`/`initiative`. The drift comparison lives in the read-only `status`/`outputs` path alongside the digest helper; the baseline write is the separate `openspec reconcile` op. -- `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts` but reading graph edges from the CLI rather than embedding artifact-name patterns. -- Skill/command registration + profile wiring (the expanded-workflow profile that already lists `continue`, `ff`, `verify`, …) so `/opsx:update` installs alongside its siblings; docs/opsx.md command table gains a `/opsx:update` row. -- `openspec/changes/add-artifact-regeneration-support/` — superseded by this change (see below); retire or fold its staleness notes into design. -- Cross-platform tests (graph queries, newline-normalized digest stability across CRLF/LF, `path.join` paths, skill template generation) and Windows CI per [openspec/config.yaml](../../config.yaml). +- `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts`. Reads artifact ids and paths from `openspec status --json`; embeds no artifact-name patterns. +- Skill/command registration + the expanded-workflow profile that already lists `continue`, `ff`, `verify`, … so `/opsx:update` installs alongside its siblings. +- `docs/opsx.md` — add a `/opsx:update` row to the command table and a short "Updating a change" usage note. +- `openspec/changes/add-artifact-regeneration-support/` — the in-repo proposal-only stub for this gap is superseded; retire it or fold its notes into design. +- No changes to `src/core/artifact-graph/*`, `src/commands/workflow/status.ts`, or `ChangeMetadataSchema`. The skill uses `openspec status` / `openspec list` as they exist today. ## Issues addressed -All references verified against `Fission-AI/OpenSpec` on 2026-06-29. +Verified against `Fission-AI/OpenSpec` on 2026-06-30. Closes (the missing-update-action family): -- [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task." Delivered as `/opsx:update`, artifacts-only (never touches code), which is the specific pain in the report. -- [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Support Rebuilding Downstream Artifacts from Modified Intermediate Files." The requested `regen --from ` becomes graph-driven downstream propagation in targeted mode. -- [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — the "clarify" request: update existing artifacts without auto-generating the next one. `/opsx:update` revises in place and never advances the build frontier. -- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "utility to review and update all change proposals." Audit mode delivers the within-a-change form; cross-change audit is the scoped phase-2 follow-up (see design Decisions resolved). -- [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — "Cross-artifact quality review between propose/ff and apply." This is exactly audit mode: a pre-apply pass that catches cross-artifact contradictions (proposal scope vs. design Non-Goals), spec gaps, and duplication. The issue's open form question — new skill (A) vs. extend `openspec validate` (B) — is answered by the determinism split: the *deterministic* checks (content drift, structural completeness, capability coverage) are CLI/`validate`-shaped; the *semantic* cross-artifact review is the skill. See design. +- [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188) — "Add a command to update proposal, design and task" (and stop it editing code). Delivered as `/opsx:update`, planning-artifacts-only. +- [#705](https://github.com/Fission-AI/OpenSpec/issues/705) — "Rebuild downstream artifacts from a modified upstream." Delivered as the skill's read-and-reconcile pass over the change's artifacts. +- [#673](https://github.com/Fission-AI/OpenSpec/issues/673) — "clarify": update existing artifacts without auto-advancing the build frontier. `/opsx:update` revises in place and never creates the next artifact. +- [#247](https://github.com/Fission-AI/OpenSpec/issues/247) — "review and update all change proposals." Delivered as the within-a-change coherence review; cross-change audit is a separate, later proposal. -Answers (workflow questions whose honest answer today is "no command exists"): +Answers (questions whose honest answer today is "no command exists"): - [#694](https://github.com/Fission-AI/OpenSpec/issues/694), [#684](https://github.com/Fission-AI/OpenSpec/issues/684), [#618](https://github.com/Fission-AI/OpenSpec/issues/618) — "which command regenerates a document after the flow progressed / after apply?" → `/opsx:update`. -- Discussion [#1206](https://github.com/Fission-AI/OpenSpec/discussions/1206) ("Is there a good solution for refine proposal now?", links #1188 and the closed prior-art PR [#372](https://github.com/Fission-AI/OpenSpec/pull/372)) — the official answer becomes `/opsx:update`. +- Discussion [#1206](https://github.com/Fission-AI/OpenSpec/discussions/1206) — the official answer becomes `/opsx:update`. Supersedes: -- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by a graph-driven design and the user-facing command. Its change-detection intent is preserved but made deterministic (content digests, not the stub's mtime/metadata-file mechanism); its hardcoded-filename dependency tracking is dropped. - -Delineated from adjacent commands/PRs (distinct surfaces — coordinate, don't collide): - -- [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` (open) — resolves ambiguity *within a single artifact* via Q&A. Complementary upstream step: clarify sharpens one artifact; `/opsx:update` then propagates the resulting change across its dependents. Different scope (intra- vs. cross-artifact). -- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review` (draft), [#1073](https://github.com/Fission-AI/OpenSpec/issues/1073), and [#880](https://github.com/Fission-AI/OpenSpec/issues/880) (`/opsx:validate` code-vs-living-specs, future-roadmap) — all review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* internally coherent and never touches code. Clean split at the planning/code boundary. -- [#1098](https://github.com/Fission-AI/OpenSpec/pull/1098) (open) — adds `artifactOutputComplete`/`artifactOutputContentValid` in `outputs.ts` (the [#1084](https://github.com/Fission-AI/OpenSpec/issues/1084) fix). Audit's "empty/incomplete" structural check **reuses** this rather than reinventing; the new digest helper lives in the same file and composes. -- Skill-count concern ([#1263](https://github.com/Fission-AI/OpenSpec/issues/1263), and #783's own note): `/opsx:update` *consolidates* update + regen + refine into one first-class action rather than adding several adjacent commands. - -Related, addressed-in-part: - -- [#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666), [#829](https://github.com/Fission-AI/OpenSpec/issues/829) — hardcoded artifact patterns / schema-driven enforcement. The new skill is graph-driven by construction; retiring the same hardcoding in `continue-change.ts`/`ff-change.ts`, and surfacing the deterministic coherence checks in schema-aware `validate`, are recommended follow-ups (design), not done here, to keep this change focused. -- [#1277](https://github.com/Fission-AI/OpenSpec/pull/1277) (prevent-silent-spec-drop) — same architectural principle (deterministic CLI, thin skills) and touches `artifact-graph`/`instruction-loader`; coordinate so the status-JSON additions compose, and reuse its `validateChangeCapabilityCoverage` for one of audit's structural checks. -- [#695](https://github.com/Fission-AI/OpenSpec/issues/695) — `design` requires only `proposal`, not `specs`. The downstream propagation is only as good as the `requires` edges; this change surfaces the consequence but the edge fix belongs to #695. +- `openspec/changes/add-artifact-regeneration-support` (in-repo, proposal-only stub) — same problem, replaced by this skill. Its hardcoded-filename dependency tracking and metadata-file staleness mechanism are dropped in favor of letting the agent read the artifacts. -Related, out of scope (referenced, not closed): +Delineated from adjacent commands (distinct surfaces — coordinate, don't collide): -- [#846](https://github.com/Fission-AI/OpenSpec/issues/846) — file-based context/tracking persistence. The optional digest ledger is a minimal, deterministic instance of this; the broader tracking-file proposal is separate. -- [#999](https://github.com/Fission-AI/OpenSpec/discussions/999), discussion [#169](https://github.com/Fission-AI/OpenSpec/discussions/169) — AI over-editing / reconciling manual code edits. The planning-only guardrail addresses the plan side; reconciling manual *code* edits back into specs is the `/opsx:review` (#1251) direction, not this change. -- [#1245](https://github.com/Fission-AI/OpenSpec/issues/1245) — first-class lifecycle timestamps. Orthogonal to the content-digest signal here (digests detect *content* drift; timestamps record *when*); complementary, tracked separately. -- [#906](https://github.com/Fission-AI/OpenSpec/issues/906), [#339](https://github.com/Fission-AI/OpenSpec/issues/339) — skill resumption and proposal-time codebase exploration; orthogonal. +- [#702](https://github.com/Fission-AI/OpenSpec/pull/702) `/opsx:clarify` — resolves ambiguity *within one artifact* via Q&A; a complementary upstream step. `/opsx:update` then reconciles the change's artifacts with each other. +- [#1251](https://github.com/Fission-AI/OpenSpec/pull/1251) `/opsx:review`, [#880](https://github.com/Fission-AI/OpenSpec/issues/880) — review the *implementation (code)* against the plan. `/opsx:update` is the mirror image: it keeps the *plan* coherent and never touches code. +- [#783](https://github.com/Fission-AI/OpenSpec/issues/783) — cross-artifact quality review. The skill's coherence pass is the lightweight form of this; a deterministic `validate`-side check is a separate proposal. diff --git a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md b/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md deleted file mode 100644 index 84f536f50f..0000000000 --- a/openspec/changes/add-update-workflow/specs/artifact-graph/spec.md +++ /dev/null @@ -1,79 +0,0 @@ -## ADDED Requirements - -### Requirement: Direct Dependent Query - -The system SHALL identify which artifacts directly depend on a given artifact (the reverse of the `requires` relationship). - -#### Scenario: Artifact with dependents - -- **WHEN** artifact B requires artifact A and getDependents("A") is called -- **THEN** the result includes B - -#### Scenario: Artifact with no dependents - -- **WHEN** no artifact requires artifact T and getDependents("T") is called -- **THEN** the result is empty - -#### Scenario: Unknown artifact id - -- **WHEN** getDependents() is called with an id not present in the schema -- **THEN** the system throws an error identifying the unknown id - -### Requirement: Transitive Downstream Query - -The system SHALL compute the transitive set of artifacts that depend on a given artifact, returned in topological order so each artifact appears after the upstreams it depends on. - -#### Scenario: Linear chain downstream - -- **WHEN** artifacts form a linear chain (A → B → C) and getDownstream("A") is called -- **THEN** the result is [B, C] in that order - -#### Scenario: Diamond downstream order - -- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D) and getDownstream("A") is called -- **THEN** the result includes B and C before D - -#### Scenario: Leaf artifact has no downstream - -- **WHEN** getDownstream() is called on an artifact with no dependents -- **THEN** the result is empty - -#### Scenario: Downstream excludes the artifact itself - -- **WHEN** getDownstream("A") is called -- **THEN** the result does not include A - -### Requirement: Artifact Content Digest - -The system SHALL compute a deterministic content digest for an artifact from its output file(s), such that the same content yields the same digest on every run and on every platform. For an artifact with multiple output files (a glob), the digest SHALL be computed over the files ordered by their change-relative path expressed with forward slashes, and SHALL incorporate each file's relative path together with its content, so that the digest is stable regardless of the operating system's absolute-path sorting, separators, or drive letters. Content SHALL be line-ending-normalized (CRLF to LF) before hashing so that otherwise-identical content produces an identical digest regardless of encoding. - -#### Scenario: Identical content yields identical digest - -- **WHEN** an artifact's output content is unchanged between two computations -- **THEN** the digest is identical - -#### Scenario: Changed content yields a different digest - -- **WHEN** an artifact's output content changes -- **THEN** the digest changes - -#### Scenario: Line endings do not affect the digest - -- **WHEN** the same content is encoded with CRLF on one platform and LF on another -- **THEN** the digest is identical on both - -#### Scenario: Glob output digest is stable across platforms - -- **WHEN** an artifact generates a glob pattern (e.g. `specs/**/*.md`) with multiple files -- **THEN** the digest orders the files by change-relative forward-slash path before hashing -- **AND** the digest is identical on Windows and POSIX for the same file set and contents - -#### Scenario: Renaming a file within a glob changes the digest - -- **WHEN** a file in a glob artifact is renamed or moved (same total content, different relative path) -- **THEN** the digest changes, because the relative path is part of the hash - -#### Scenario: Missing output has no digest - -- **WHEN** an artifact's output does not exist on disk -- **THEN** no digest is reported for that artifact diff --git a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md b/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md deleted file mode 100644 index 8c980293c0..0000000000 --- a/openspec/changes/add-update-workflow/specs/cli-artifact-workflow/spec.md +++ /dev/null @@ -1,99 +0,0 @@ -## ADDED Requirements - -### Requirement: Status Includes Dependency Edges - -The system SHALL include each artifact's dependency edges in the `openspec status --json` output, so consumers can determine how artifacts relate without hardcoding artifact names. - -#### Scenario: Status JSON exposes requires and dependents - -- **WHEN** the user runs `openspec status --change --json` -- **THEN** each artifact in the `artifacts` array includes a `requires` array (the artifact ids it depends on) -- **AND** each artifact includes a `dependents` array (the artifact ids that directly depend on it) - -#### Scenario: Edges reflect the active schema - -- **WHEN** the change uses a custom schema with non-default artifact ids -- **THEN** the `requires` and `dependents` edges in the status output use that schema's artifact ids - -### Requirement: Content Digest and Drift Reporting - -The system SHALL report a deterministic per-artifact content digest and a drift signal in the read-only `openspec status --json` output, the drift signal computed by comparing each artifact's recorded upstream-digest baseline against the current upstream digests, so consumers can detect content changes reproducibly without relying on filesystem timestamps. Recording the baseline SHALL be a separate, explicit write operation; `openspec status` SHALL remain read-only and never mutate the baseline. - -#### Scenario: Present artifact reports a digest - -- **WHEN** the user runs `openspec status --change --json` and an artifact's output exists -- **THEN** that artifact's status JSON includes a `digest` derived from its output content - -#### Scenario: Digest is stable across runs - -- **WHEN** `openspec status --change --json` is run twice without the artifact's content changing -- **THEN** the artifact's `digest` is identical between runs - -#### Scenario: Missing output reports no digest - -- **WHEN** an artifact's output does not exist -- **THEN** the artifact's status JSON omits `digest` (or reports it as null) - -#### Scenario: Drift reported against a recorded baseline - -- **WHEN** an upstream dependency's current digest differs from the value recorded in an artifact's baseline -- **THEN** the artifact's status JSON reports it as drifted, listing the upstream ids whose digest changed - -#### Scenario: Drift is unknown without a baseline - -- **WHEN** an artifact has no recorded baseline -- **THEN** the artifact's status JSON reports drift as `unknown` rather than drifted or clean - -#### Scenario: Drift is unknown when the baseline scheme is unrecognized - -- **WHEN** an artifact's recorded baseline uses a digest scheme the current version does not recognize -- **THEN** the artifact's status JSON reports drift as `unknown` rather than comparing across schemes -- **AND** re-recording the baseline restores a comparable drift signal - -#### Scenario: Recording a baseline is an explicit write, separate from status - -- **WHEN** the baseline is recorded for an artifact via the dedicated record operation -- **THEN** the system stores the current digests of that artifact's direct upstream dependencies as its baseline -- **AND** running `openspec status` does not by itself create or change any baseline - -#### Scenario: Missing upstream is recorded as absent - -- **WHEN** an artifact's baseline is recorded while one of its direct upstreams has no output -- **THEN** that upstream is recorded as absent -- **AND** later creating that upstream's output reports the artifact as drifted - -### Requirement: Downstream Impact Query - -The system SHALL provide an impact selector on the status command that returns the downstream artifacts affected by a change to a given artifact, in revisit (topological) order. - -#### Scenario: Impact returns ordered downstream set - -- **WHEN** the user runs `openspec status --change --impact --json` -- **THEN** the output lists the transitive downstream dependents of `` in topological (build) order -- **AND** the listed artifacts include their resolved output paths and content digests so a consumer can read and rewrite them - -#### Scenario: Impact ordering is deterministic - -- **WHEN** the impact query is run repeatedly for the same change and artifact -- **THEN** the downstream set and its order are identical every time - -#### Scenario: Impact entries indicate which downstream artifacts exist - -- **WHEN** some transitive downstream artifacts have not been created yet -- **THEN** each impact entry reports its status (e.g. done vs. not-yet-created) -- **AND** a consumer can tell which downstream artifacts exist to revise versus which would need to be created - -#### Scenario: Impact on a leaf artifact - -- **WHEN** the impact selector targets an artifact with no dependents -- **THEN** the downstream set is empty - -#### Scenario: Impact on an unknown artifact - -- **WHEN** the impact selector names an artifact id not in the change's schema -- **THEN** the command reports an error identifying the unknown artifact id - -#### Scenario: Default human-readable output is unchanged - -- **WHEN** the user runs `openspec status --change ` without `--json` or `--impact` -- **THEN** the default human-readable status output is unchanged from prior behavior diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index 2e2d495c7d..82865e043a 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -8,19 +8,19 @@ The system SHALL provide a `/opsx:update` workflow skill that revises a change's - **WHEN** the user invokes `/opsx:update` without a change name - **THEN** the skill infers the change from conversation context if possible -- **AND** if it cannot, it lists available changes (most-recently-modified first) and asks the user to choose, never auto-selecting +- **AND** if it cannot, it lists available changes (most-recently-modified first) via `openspec list --json` and asks the user to choose, never auto-selecting #### Scenario: Revise without advancing the frontier - **WHEN** the user asks `/opsx:update` to revise an existing artifact -- **THEN** the skill updates that artifact and only its already-existing downstream dependents +- **THEN** the skill updates that artifact and reconciles the change's other existing artifacts with it - **AND** it does NOT create any artifact that does not yet exist (that remains the job of `/opsx:continue`/`/opsx:propose`) -#### Scenario: Absent downstream artifacts are deferred to continue +#### Scenario: Missing artifacts are deferred to continue -- **WHEN** the impact set includes downstream artifacts that have not been created yet -- **THEN** the skill revises only the downstream artifacts that currently exist -- **AND** it notes the not-yet-created downstream artifacts and points the user to `/opsx:continue` to create them +- **WHEN** keeping the change coherent would require an artifact that has not been created yet +- **THEN** the skill revises only the artifacts that currently exist +- **AND** it notes the not-yet-created artifacts and points the user to `/opsx:continue` to create them #### Scenario: Update stays within the plan @@ -28,70 +28,64 @@ The system SHALL provide a `/opsx:update` workflow skill that revises a change's - **THEN** the skill updates the planning artifacts only - **AND** it directs the user to `/opsx:apply` to carry the revised plan into code, rather than editing code itself -### Requirement: Graph-Driven Propagation +### Requirement: Schema-Driven Artifact Resolution -The `/opsx:update` skill SHALL determine which artifacts are related, in what order to revisit them, and where they live by reading the change's artifact graph and resolved paths from the CLI, and SHALL NOT rely on hardcoded artifact names or assumed path separators. This makes the skill correct for custom schemas and on every platform, not only the default `spec-driven` schema. +The `/opsx:update` skill SHALL learn which artifacts exist and where they live by reading the change's status from the CLI, and SHALL NOT rely on hardcoded artifact names or assumed path separators. This makes the skill correct for custom schemas and on every platform, not only the default `spec-driven` schema. -#### Scenario: Propagate to downstream dependents in revisit order +#### Scenario: Reads the artifact set from status -- **WHEN** the user changes a given artifact -- **THEN** the skill obtains that artifact's downstream dependents and their revisit order from the CLI (`openspec status --impact --json`) -- **AND** it reviews each downstream artifact against its now-changed upstreams, in that order +- **WHEN** the skill needs to know which artifacts a change has and where they are +- **THEN** it runs `openspec status --change --json` and uses the reported artifact ids, statuses, and resolved paths +- **AND** it does not assume the artifact ids or output paths -#### Scenario: Does not compute the file list or order itself +#### Scenario: Does not branch on hardcoded artifact names -- **WHEN** the skill needs to know which artifacts are affected and in what order -- **THEN** it uses the set and order returned by the CLI -- **AND** it does not enumerate, order, or filter artifacts by its own logic or by assumed artifact names +- **WHEN** the skill decides which artifacts to read and revise +- **THEN** its control flow uses the ids reported by the CLI +- **AND** it does not branch on literal `proposal`/`specs`/`design`/`tasks` names #### Scenario: Works for a custom schema - **WHEN** the active change uses a custom schema whose artifact ids are not `proposal`/`specs`/`design`/`tasks` -- **THEN** the skill uses the artifact ids and dependency edges reported by the CLI -- **AND** propagation works without any change to the skill +- **THEN** the skill uses the artifact ids and paths reported by the CLI +- **AND** it works without any change to the skill #### Scenario: Resolve artifact paths cross-platform - **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows -- **THEN** it uses the resolved path provided by the CLI status/instructions output +- **THEN** it uses the resolved path provided by the CLI status output - **AND** it does not assume forward-slash separators -### Requirement: Cohesive Audit Mode +### Requirement: Bidirectional Coherence Review -The `/opsx:update` skill SHALL support an audit mode that reviews a whole change for artifacts that have drifted from or are incoherent with their upstream dependencies and offers to fix them, using the deterministic signals the CLI provides rather than its own heuristics. +The `/opsx:update` skill SHALL keep a change's existing planning artifacts coherent with one another after a revision, reviewing affected artifacts in any direction rather than assuming a fixed downstream flow. -#### Scenario: Audit reports drifted artifacts when a baseline exists +#### Scenario: Reconcile related artifacts after an edit -- **WHEN** the user invokes `/opsx:update` in audit mode and a recorded digest baseline exists -- **THEN** the skill uses the CLI's drift report (current upstream digest vs. recorded baseline) to identify which downstream artifacts to review -- **AND** it presents them in revisit order +- **WHEN** the user revises one artifact +- **THEN** the skill reviews the change's other existing artifacts against the revision +- **AND** it proposes follow-on edits to any artifact that is now inconsistent, whether that artifact is upstream or downstream of the edited one -#### Scenario: Audit falls back to structural facts without a baseline +#### Scenario: Upstream artifact may be revised -- **WHEN** no digest baseline has been recorded for the change -- **THEN** the skill does not guess at staleness -- **AND** it surfaces the deterministic structural facts the CLI reports (an artifact whose output is missing or empty, or one still blocked/incomplete) and asks the user how to proceed +- **WHEN** an edit to a later artifact (for example design) contradicts an earlier one (for example the proposal) +- **THEN** the skill may propose revising the earlier artifact to restore coherence +- **AND** it does not treat propagation as downstream-only -#### Scenario: Audit performs cross-artifact semantic review +#### Scenario: Coherence review with no specific edit -- **WHEN** audit mode runs -- **THEN** in addition to the deterministic drift and structural signals, the skill reviews the change's artifacts against each other for cross-artifact incoherence that those signals cannot detect — for example a scope item present in the proposal but excluded in design, behavior specified only in design but absent from the specs, or a task duplicating an existing capability -- **AND** it presents such findings for the user to confirm before any edit - -#### Scenario: Audit offers per-artifact fixes - -- **WHEN** audit mode identifies one or more artifacts to revise -- **THEN** the skill proposes a concrete revision for each, in revisit order -- **AND** it applies a revision only after the user confirms it +- **WHEN** the user invokes `/opsx:update` without a specific revision in mind ("make this change coherent") +- **THEN** the skill reads the change's existing artifacts and reviews them against each other for contradictions, gaps, and duplication +- **AND** it presents any findings for the user to confirm before editing #### Scenario: Coherent change yields no changes -- **WHEN** audit mode finds no drifted artifacts, no structural gaps, and no cross-artifact incoherence -- **THEN** the skill reports the change as coherent and makes no edits +- **WHEN** the skill finds the change's artifacts already coherent +- **THEN** it reports the change as coherent and makes no edits ### Requirement: User-Confirmed Incremental Application -The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation, re-checking coherence after each applied change. +The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation. #### Scenario: Confirm before writing @@ -99,13 +93,13 @@ The `/opsx:update` skill SHALL propose each artifact revision and apply it only - **THEN** it shows the user what it intends to change and why before writing - **AND** it writes only after the user confirms +#### Scenario: Rejected revision is not written + +- **WHEN** the user rejects a proposed revision for an artifact +- **THEN** the skill does not write that revision +- **AND** the artifact is left unchanged + #### Scenario: Intent change is redirected to a new change - **WHEN** the requested revision changes the intent of the change rather than refining it (per the "Update vs. Start Fresh" heuristic) - **THEN** the skill recommends starting a new change (`/opsx:new`) instead of mutating the existing proposal into different work - -#### Scenario: Records the drift baseline after an applied edit - -- **WHEN** the skill has applied and confirmed a revision to an artifact -- **THEN** it records that artifact's drift baseline via the CLI's dedicated record operation (not by computing digests itself, and not via read-only `status`) -- **AND** subsequent audits report that artifact as no longer drifted until an upstream changes again diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index e9d7cd2a93..2d6ed4e40d 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -1,55 +1,27 @@ -# Tasks: `/opsx:update` — graph-driven artifact update +# Tasks: `/opsx:update` — a thin update skill -> Sequenced so each layer is independently testable: deterministic graph/digest primitives → drift baseline → CLI surface → skill → docs/verification. Cross-platform (`path.join`, newline-normalized digests) and Windows CI are called out where relevant, per `openspec/config.yaml`. The digest ledger (section 7) is in scope (design Decision 3); it degrades gracefully to `unknown` for changes without a recorded baseline. +> The whole feature is one new skill template over the existing `openspec status` / `openspec list` commands. No changes to the graph engine, the `status` command, or the metadata schema. -## 1. Artifact graph: reverse traversal (deterministic) +## 1. The `/opsx:update` skill -- [ ] 1.1 Factor the reverse-adjacency `dependents` map (built and discarded inside `getBuildOrder()`, graph.ts:82-87; same relation `getUnlockedArtifacts` computes for one node) into a shared helper. -- [ ] 1.2 Add `getDependents(id)` — direct dependents; throw a descriptive error on unknown id. -- [ ] 1.3 Add `getDownstream(id)` — transitive dependents ordered by the existing `getBuildOrder()`, excluding `id`; throw on unknown id. -- [ ] 1.4 Unit tests: linear chain, diamond ordering, leaf (empty), unknown-id error, self-exclusion; assert results are identical across repeated calls (determinism). +- [ ] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. +- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time. Read artifact ids and resolved paths from the status JSON only. +- [ ] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing artifacts — defer not-yet-created ones to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). +- [ ] 1.4 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. -## 2. Artifact graph: content digest (deterministic, grounded) +## 2. Docs & supersede the stub -- [ ] 2.1 Add `artifactDigest(changeDir, generates)` in `outputs.ts`: from `resolveArtifactOutputs`, re-derive each file's path **relative to `changeDir` as a forward-slash string**, order files by that relative path (NOT by the absolute-path `.sort()` order `resolveArtifactOutputs` returns — that differs by OS), then SHA-256 over, per file, `relPath` + NUL + newline-normalized (CRLF→LF) content; return undefined when no output exists. Prefix the digest with a scheme tag (e.g. `sha256-relpath-v1:`) so future canonicalization changes are detectable, not silently mis-compared. (New helper — no content-hash utility exists in the repo; only `crypto.randomUUID` in telemetry.) Lives alongside #1098's `artifactOutputComplete`/`artifactOutputContentValid`; coordinate so structural-completeness reuses #1098 rather than duplicating. -- [ ] 2.2 Unit tests: identical content → identical digest; changed content → changed digest; **CRLF vs LF inputs → identical digest**; **multi-file glob digest identical under simulated Windows vs POSIX absolute paths** (the cross-OS ordering guard); renaming a file within a glob → different digest; missing output → undefined. +- [ ] 2.1 Add a `/opsx:update` row to the command table in `docs/opsx.md`, plus a short "Updating a change" usage note. +- [ ] 2.2 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single update proposal. +- [ ] 2.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. -## 3. CLI: surface edges, digest, and impact on `status` +## 3. Tests -- [ ] 3.1 Extend `formatChangeStatus` (instruction-loader.ts, the `ArtifactStatus` objects ~397-426) to add `requires`, `dependents`, and `digest` per artifact; the existing build-order sort (line 429) already yields revisit order. -- [ ] 3.2 Add `impact?: string` to `StatusOptions` and the `--impact ` option (`src/commands/workflow/status.ts`, registered at `src/cli/index.ts:488`); when set, output the ordered downstream set with each artifact's resolved paths, digest, and existence/status (so consumers can tell which exist to revise vs. which would need creating); error on unknown artifact id. -- [ ] 3.3a Add per-artifact `drift` (`drifted`/`clean`/`unknown` + changed-upstream ids) to status JSON, comparing current upstream digests to the recorded baseline (section 7). Keep `status` read-only; add a dedicated `openspec reconcile --artifact ` command to write/refresh that baseline. -- [ ] 3.3 Keep default (non-`--json`, non-`--impact`) human-readable output byte-for-byte unchanged; add a regression test asserting this. -- [ ] 3.4 Tests: JSON edge fields, per-artifact digest present/stable, `--impact` ordering + determinism (repeat-run equality), `--impact` leaf (empty), `--impact` unknown-id error. Verify on Windows CI. +- [ ] 3.1 Template generation snapshot for the skill and command templates. +- [ ] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. +- [ ] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. -## 4. The `/opsx:update` skill (thin wrapper over the deterministic spine) +## 4. End-to-end verification -- [ ] 4.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts` structure. -- [ ] 4.2 Instruction body: select change (infer/prompt via `openspec list --json`) → read `openspec status --change --json` → branch into targeted vs audit mode → obtain the impact set/order from `--impact` (never compute it) → propose/confirm/apply/`reconcile`/re-check, reading ids/paths/edges/digests/drift from JSON only. Targeted entry is baseline-aware: with a baseline, default to the drifted set and confirm; without one, ask which artifact changed. Audit mode additionally performs the cross-artifact semantic review of #783 (scope contradictions, spec gaps, duplication) that the deterministic signals cannot detect. -- [ ] 4.3 Encode the guardrails explicitly: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) graph-driven — no literal `proposal`/`specs`/`design`/`tasks` branching; ids and order come from the CLI; (c) audit without a baseline does not guess — it uses structural facts and asks; (d) revise only existing downstream artifacts — defer not-yet-created ones to `/opsx:continue`. -- [ ] 4.4 Encode the intent-change guard: recommend `/opsx:new` when the revision changes intent (reference the "Update vs. Start Fresh" heuristic). -- [ ] 4.5 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. -- [ ] 4.6 Test: template generation snapshot; assert the template contains NO hardcoded artifact-name branching and NO self-computed ordering (it must read ids/order from JSON) — the anti-#777 guard. - -## 5. Supersede the stub & docs - -- [ ] 5.1 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single, graph-driven update proposal; preserve its staleness idea (now via content digest) in this change's design. -- [ ] 5.2 Add a `/opsx:update` row to the command table in `docs/opsx.md`; add a short "Updating a change" usage section that uses targeted and audit modes. -- [ ] 5.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. - -## 6. End-to-end verification - -- [ ] 6.1 E2E: create a spec-driven change, edit `proposal.md`, run `openspec status --impact proposal --json`, assert the downstream set is exactly `[specs, design, tasks]` in build order with correct paths/digests, and that code is never touched by the skill flow. -- [ ] 6.2 E2E with a custom (non-default ids) schema fixture: confirm propagation works with no skill changes. -- [ ] 6.3 Full validation: `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. -- [ ] 6.4 Run the suite on macOS, Linux, and Windows CI. - -## 7. Deterministic drift baseline — the digest ledger - -> In scope (design Decision 3). Powers unattended/audit drift; consumed by 3.3a's drift signal and 4.x's record-after-edit. - -- [ ] 7.1 Extend `ChangeMetadataSchema` (`src/core/change-metadata/schema.ts`) with an optional `baselines` map: per artifact, a `scheme` tag and a `upstreams` map of direct-upstream id → recorded digest (or `null` for absent). Add a safe read-modify-write helper for `.openspec.yaml` that preserves existing fields, mirroring the store's `parseStoreMetadataState`/`serializeStoreMetadataState`/`writeStoreMetadataState` pattern (`src/core/store/foundation.ts`) — there is no central change-metadata writer today (`change-metadata/index.ts` only re-exports the schema). -- [ ] 7.1b Drift comparison compares only same-scheme digests; unrecognized/older scheme → `unknown` (re-`reconcile` restores it). -- [ ] 7.2 Record the baseline deterministically: on artifact (re)generation in `propose`/`continue`/`ff` and after each confirmed `/opsx:update` edit, write the current direct-upstream digests for the affected artifact ("reconciled"). -- [ ] 7.3 CLI drift report: an artifact is drifted iff a current direct-upstream digest differs from its recorded baseline; no baseline → `unknown` (never a false positive). Verify transitive drift emerges hop-by-hop: changing `proposal` drifts `specs`; reconciling `specs` then drifts `tasks`. -- [ ] 7.4 Tests: record→no-drift; modify upstream→drift on exactly the direct dependents; hop-by-hop transitive drift; absent baseline→`unknown`; ledger round-trips through metadata read/write. +- [ ] 4.1 `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. +- [ ] 4.2 Manual walk-through: on a `spec-driven` change, edit `design`, run `/opsx:update`, confirm it proposes coherence edits to other existing artifacts (including upstream where warranted) and never touches code. From 28a0af5b1ebc1b0fad65d6c891236c5243b711de Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 19:38:07 -0500 Subject: [PATCH 09/11] docs(update-workflow): pin the status path contract to existingOutputPaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address @alfred-openspec's review: the skill's write target was described loosely as "resolved paths." Make it precise across proposal/design/spec/tasks: - `openspec status --json` already returns everything the skill needs, in the top-level `artifactPaths` map — `resolvedOutputPath` and `existingOutputPaths` per artifact. No new CLI field is required. - The skill edits `existingOutputPaths` (the concrete, glob-expanded files) and never writes to `resolvedOutputPath`, which for a glob artifact like `specs/**/*.md` remains the glob pattern rather than a real file. - Add spec scenarios for editing a glob artifact's concrete files and for deferring a brand-new file under a glob artifact to `/opsx:continue`. - Tighten the cross-platform scenario and add a template test (3.4) asserting the write target is `existingOutputPaths`, not a glob `resolvedOutputPath`. Validates clean under `openspec validate add-update-workflow --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/add-update-workflow/design.md | 11 +++++++---- openspec/changes/add-update-workflow/proposal.md | 2 +- .../specs/opsx-update-skill/spec.md | 16 ++++++++++++++-- openspec/changes/add-update-workflow/tasks.md | 5 +++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 6aa2ece109..991f1bfc0e 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -2,7 +2,7 @@ ## Context -OPSX models a change as a small DAG of planning artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them, and `openspec status --change --json` already reports, per artifact: its `status` (`done`/`ready`/`blocked`), its `outputPath`, and — via `artifactPaths` — its resolved and existing on-disk paths, plus the change's `schemaName` and `isComplete`. `openspec list --json` lists changes by recency. +OPSX models a change as a small DAG of planning artifacts. Each schema declares artifacts with `requires` edges ([schemas/spec-driven/schema.yaml](../../../schemas/spec-driven/schema.yaml)); `ArtifactGraph` ([src/core/artifact-graph/graph.ts](../../../src/core/artifact-graph/graph.ts)) topologically sorts them, and `openspec status --change --json` already reports, per artifact: its `status` (`done`/`ready`/`blocked`), its `outputPath`, and — via the top-level `artifactPaths` map — its `resolvedOutputPath` and `existingOutputPaths`, plus the change's `schemaName` and `isComplete`. The two path fields differ in a way that matters for a write operation: `existingOutputPaths` is the concrete files that exist on disk (for a glob artifact such as `specs/**/*.md`, the glob already expanded to real files); `resolvedOutputPath` is the change-dir-joined declared path, which for a glob artifact is still the glob (`.../specs/**/*.md`) and is therefore **not** a write target. `/opsx:update` edits the files in `existingOutputPaths`. `openspec list --json` lists changes by recency. That is everything an update skill needs. The artifacts are a handful of markdown files on disk; the agent can read them. So `/opsx:update` is built as a thin skill over the **existing** CLI, in the same shape as `continue-change.ts` (select change → `openspec status --json` → act). @@ -36,8 +36,11 @@ Revise a change's planning artifacts and keep them coherent. Never edit code. 2. Get the artifacts. - Run `openspec status --change "" --json`. - - Read `artifacts[]` (ids + status) and `artifactPaths` (resolved paths). These come - from the active schema — do not assume the artifact ids or paths. + - Read `artifacts[]` (ids + status) and the `artifactPaths` map. These come from the + active schema — do not assume the artifact ids or paths. + - The files to edit are `artifactPaths..existingOutputPaths` (already glob-expanded + for artifacts like `specs/**/*.md`). Do not write to `resolvedOutputPath`: for a glob + artifact it is still the glob pattern, not a real file. 3. Understand the request. - If the user named a change ("the design now uses X"), that is the starting edit. @@ -70,7 +73,7 @@ The `spec-driven` artifact names may appear once, as a worked *example* of how t The artifact graph has a build *order*, but "what needs updating after an edit" is not strictly downstream. If `design` changes, the `proposal` it elaborates may need to change too; if `tasks` reveal a missing capability, the `specs` may need a new requirement. The skill therefore reads the change's artifacts and reconciles them in whatever direction the edit demands. Build order is still useful as a default *reading* order and for presenting fixes, but it is not a constraint on which artifacts may be revised. This is why the design does not add a one-directional `getDownstream` / `--impact` primitive: it would encode the wrong model. ### 2. Lean on the existing `status` command -`openspec status --change --json` already returns the artifact set, per-artifact status, and resolved paths (`artifactPaths..resolvedOutputPath` / `existingOutputPaths`). The skill needs nothing more to know what exists and where it lives. Picking the change reuses `openspec list --json`, exactly like `/opsx:continue`. No new CLI surface is introduced. +`openspec status --change --json` already returns the artifact set, per-artifact status, and, in the `artifactPaths` map, the on-disk paths. The skill writes to `artifactPaths..existingOutputPaths` — the concrete files, glob-expanded — and deliberately not to `resolvedOutputPath`, which for a glob artifact is the pattern itself and not a file. That is everything the skill needs to know what exists and where it lives; no new CLI field is required. Picking the change reuses `openspec list --json`, exactly like `/opsx:continue`. No new CLI surface is introduced. ### 3. Why not the heavier machinery (digests, ledger, reconcile, impact) The first draft proposed SHA-256 content digests, a per-change baseline ledger in `.openspec.yaml`, an `openspec reconcile` write op, a derived drift signal, and a `status --impact` selector — so the CLI could tell the agent *which* artifacts are stale without the agent reading them. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index 1ba7efd8fd..df6c6b593d 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -9,7 +9,7 @@ This is the most-requested missing capability in the tracker. It is one gap with The whole feature is a single new workflow skill, `/opsx:update`. Written by hand, its instruction set is short: 1. **Understand the request** — what the user wants to revise (or, with no specific ask, "review this change for coherence"). -2. **Get the artifacts** — run `openspec status --change --json` to learn which artifacts exist, their status, and their resolved paths. (`openspec list --json` to pick the change when it isn't given.) +2. **Get the artifacts** — run `openspec status --change --json`. Its `artifactPaths` map reports, per artifact, which files exist and where: `existingOutputPaths` is the concrete file list to edit — already expanded for glob artifacts like `specs/**/*.md`. (`openspec list --json` to pick the change when it isn't given.) 3. **Read and revise** — read the relevant artifacts, make the requested edit, then check the change's **other** artifacts against it and propose any follow-on edits needed to keep the plan coherent. 4. **Confirm and apply** — show each proposed revision, write only after the user confirms. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index 82865e043a..7c43f06219 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -35,7 +35,7 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b #### Scenario: Reads the artifact set from status - **WHEN** the skill needs to know which artifacts a change has and where they are -- **THEN** it runs `openspec status --change --json` and uses the reported artifact ids, statuses, and resolved paths +- **THEN** it runs `openspec status --change --json` and uses the reported artifact ids, statuses, and the `artifactPaths` map (`existingOutputPaths` for the files to edit) - **AND** it does not assume the artifact ids or output paths #### Scenario: Does not branch on hardcoded artifact names @@ -53,9 +53,21 @@ The `/opsx:update` skill SHALL learn which artifacts exist and where they live b #### Scenario: Resolve artifact paths cross-platform - **WHEN** the skill reads or writes an artifact on macOS, Linux, or Windows -- **THEN** it uses the resolved path provided by the CLI status output +- **THEN** it uses the `existingOutputPaths` provided by the CLI status output - **AND** it does not assume forward-slash separators +#### Scenario: Edit the concrete files of a glob artifact + +- **WHEN** an artifact's declared output path is a glob (for example `specs/**/*.md`) +- **THEN** the skill edits the concrete files reported in that artifact's `existingOutputPaths` +- **AND** it does not write to `resolvedOutputPath`, which for a glob artifact remains the glob pattern rather than a real file + +#### Scenario: A new file under a glob artifact is deferred to continue + +- **WHEN** keeping the change coherent would require a new file under a glob artifact that does not exist yet (for example a spec for a not-yet-captured capability) +- **THEN** the skill revises only the files already present in `existingOutputPaths` +- **AND** it points the user to `/opsx:continue`/`/opsx:propose` to create the new file rather than inventing a path from the glob + ### Requirement: Bidirectional Coherence Review The `/opsx:update` skill SHALL keep a change's existing planning artifacts coherent with one another after a revision, reviewing affected artifacts in any direction rather than assuming a fixed downstream flow. diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 2d6ed4e40d..308d14d86d 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -5,8 +5,8 @@ ## 1. The `/opsx:update` skill - [ ] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. -- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time. Read artifact ids and resolved paths from the status JSON only. -- [ ] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing artifacts — defer not-yet-created ones to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). +- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). +- [ ] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). - [ ] 1.4 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. ## 2. Docs & supersede the stub @@ -20,6 +20,7 @@ - [ ] 3.1 Template generation snapshot for the skill and command templates. - [ ] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. - [ ] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. +- [ ] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. ## 4. End-to-end verification From be92e8a420276a278b18ad46d0b7ad6bf87ffaab Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 17:16:53 -0500 Subject: [PATCH 10/11] =?UTF-8?q?docs(update-workflow):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20default=20profile,=20next-step=20guidance,=20cha?= =?UTF-8?q?nge-scoped=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register /opsx:update in the default core profile, not expanded-only (maintainer call on the PR) - Add next-step guidance: after updating, recommend /opsx:continue, /opsx:apply (esp. when the change was already implemented), or /opsx:archive — guidance only, never acted on - Pin naming scope: skill openspec-update-change, change proposals only; generalizing update to other graph types is an explicit non-goal Co-Authored-By: Claude Fable 5 --- .../changes/add-update-workflow/design.md | 13 +++++++++++- .../changes/add-update-workflow/proposal.md | 7 ++++--- .../specs/opsx-update-skill/spec.md | 21 +++++++++++++++++++ openspec/changes/add-update-workflow/tasks.md | 8 ++++--- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/openspec/changes/add-update-workflow/design.md b/openspec/changes/add-update-workflow/design.md index 991f1bfc0e..9ab208b735 100644 --- a/openspec/changes/add-update-workflow/design.md +++ b/openspec/changes/add-update-workflow/design.md @@ -22,6 +22,7 @@ This proposal began larger — a reverse-dependency graph API, content digests, - Content digests, a drift/staleness signal, a baseline ledger, a `reconcile` op, or a `status --impact` selector (see "Why not the heavier machinery"). - Regenerating *code* from updated artifacts — that is `/opsx:apply`'s job; `/opsx:update` stops at the plan and hands off. - Cross-change audit ([#247](https://github.com/Fission-AI/OpenSpec/issues/247) in full) — a later proposal; this change is intra-change. +- Updating anything other than a change's planning artifacts. v1 is specific to change proposals; generalizing "update" to other graph types is deferred until such a graph exists (see Naming). ## The skill, written by hand @@ -58,6 +59,11 @@ Revise a change's planning artifacts and keep them coherent. Never edit code. - When a substantial rewrite is needed, `openspec instructions --change "" --json` gives that artifact's rules/template to follow. +6. Point to the next step (guidance only — never act on it). + - Artifacts still missing → suggest `/opsx:continue`. Change already implemented (tasks + checked off / applied) → the code may no longer match the revised plan; suggest + `/opsx:apply` to carry the delta. Fully done and implemented → suggest `/opsx:archive`. + Guardrails: - Planning artifacts only. If the plan now implies code changes, stop and point to `/opsx:apply`. - Use artifact ids/paths from `openspec status`; never branch on literal proposal/specs/design/tasks names. @@ -87,12 +93,17 @@ So `/opsx:update` v1 has the agent read the change's artifacts and judge coheren ### 4. Naming: `/opsx:update` skill, not `openspec update` CLI `openspec update [path]` already regenerates AI tool/skill files ([src/cli/index.ts](../../../src/cli/index.ts)). Overloading it would give one verb two unrelated meanings. The artifact-update action is therefore the **skill** `/opsx:update`, with no new `openspec` verb at all. Considered and rejected: `openspec regen --from ` ([#705](https://github.com/Fission-AI/OpenSpec/issues/705)) — a mutating CLI verb that rewrites artifacts duplicates the skill's job and bypasses user confirmation; the value is in the agent's semantic revision, not a CLI rewrite. +Review feedback flagged that "update" alone is generic — could it apply to any graph? The resolution: the skill is scoped to **change proposals only**, and the specific name carries that scope. The skill is `openspec-update-change`, following the `openspec--change` naming of its siblings (`openspec-continue-change`, `openspec-new-change`, …). The command is `/opsx:update` because every verb in the `/opsx:` family operates on a change (`continue`, `apply`, `archive` — none says `-change`); a change-scoped meaning is what the namespace already promises. If a future graph type needs its own update action, it gets its own specific skill name then — nothing here blocks or breaks that. + ### 5. Guardrails (the part that makes it the requested command) - **Planning artifacts only.** The skill's write targets are the artifact paths from `status`; if a revision implies code changes it stops and points to `/opsx:apply`. This directly answers [#1188](https://github.com/Fission-AI/OpenSpec/issues/1188)'s complaint that the manual workaround edits code. - **Schema-driven.** Ids and paths come from `status`; no branching on literal `proposal`/`specs`/`design`/`tasks`. Works for custom schemas ([#777](https://github.com/Fission-AI/OpenSpec/issues/777), [#666](https://github.com/Fission-AI/OpenSpec/issues/666)). - **Confirm each edit.** One artifact at a time, shown before writing. - **Intent guard.** A revision that changes intent rather than refining it is redirected to `/opsx:new` (the "Update vs. Start Fresh" heuristic, [docs/opsx.md](../../../docs/opsx.md)). +### 6. Next-step guidance, especially for already-implemented changes +A change can be revised after it was built — tasks checked off, `/opsx:apply` already run. The update itself behaves identically (planning artifacts only), but stopping silently would strand the user: the code and the revised plan now disagree. So the skill ends by reporting where the change stands (from the status JSON and the tasks checklist) and recommending the next command — `/opsx:continue` if artifacts are missing, `/opsx:apply` to carry a revised plan into code, `/opsx:archive` when everything is done. Guidance only: the skill never implements, mirroring the "All artifacts created! You can now implement this change with `/opsx:apply`" hand-off that `continue-change.ts` already uses. + ## Risks / Trade-offs - **No deterministic staleness signal.** With no digest/ledger, the skill relies on the agent reading the artifacts to spot incoherence. Trade-off accepted: an agent that rewrites prose must read it anyway, and a content-blind signal earns its cost only for use cases this change excludes (Decision 3). @@ -101,4 +112,4 @@ So `/opsx:update` v1 has the agent read the change's artifacts and judge coheren ## Migration Plan -Additive and backward-compatible. One new skill template behind the expanded-workflow profile; one docs row. No existing command changes behavior; no schema or graph changes. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. +Additive and backward-compatible. One new skill template, installed with the default `core` profile (maintainer call on the PR: update is part of the default happy path, not expanded-only); one docs row. No existing command changes behavior; no schema or graph changes. The superseded stub (`add-artifact-regeneration-support`) is removed or folded in the same PR to avoid two competing proposals in the tree. diff --git a/openspec/changes/add-update-workflow/proposal.md b/openspec/changes/add-update-workflow/proposal.md index df6c6b593d..4bfc506bf8 100644 --- a/openspec/changes/add-update-workflow/proposal.md +++ b/openspec/changes/add-update-workflow/proposal.md @@ -6,12 +6,13 @@ This is the most-requested missing capability in the tracker. It is one gap with ## What Changes -The whole feature is a single new workflow skill, `/opsx:update`. Written by hand, its instruction set is short: +The whole feature is a single new workflow skill, `/opsx:update`. The skill is deliberately change-scoped — `openspec-update-change`, following the `openspec--change` naming of its siblings — and applies to change proposals only, not arbitrary artifact graphs (see design, Naming). Written by hand, its instruction set is short: 1. **Understand the request** — what the user wants to revise (or, with no specific ask, "review this change for coherence"). 2. **Get the artifacts** — run `openspec status --change --json`. Its `artifactPaths` map reports, per artifact, which files exist and where: `existingOutputPaths` is the concrete file list to edit — already expanded for glob artifacts like `specs/**/*.md`. (`openspec list --json` to pick the change when it isn't given.) 3. **Read and revise** — read the relevant artifacts, make the requested edit, then check the change's **other** artifacts against it and propose any follow-on edits needed to keep the plan coherent. 4. **Confirm and apply** — show each proposed revision, write only after the user confirms. +5. **Point to the next step** — report where the change now stands and recommend what comes next: artifacts still missing → `/opsx:continue`; plan revised after the change was already implemented → `/opsx:apply` to carry the delta into code; everything done and implemented → `/opsx:archive`. Guidance only — the skill never acts on it. Two guardrails make it the command the cluster asked for: @@ -28,12 +29,12 @@ Per the steer to introduce as little code as possible, and only when there is a ### New Capabilities -- `opsx-update-skill`: A new `/opsx:update` workflow skill that revises a change's existing planning artifacts and keeps them coherent with one another. It reads the artifact set and paths from `openspec status`, reviews related artifacts in any direction (not only downstream), edits planning artifacts only and never code, and confirms each edit with the user. +- `opsx-update-skill`: A new `/opsx:update` workflow skill that revises a change's existing planning artifacts and keeps them coherent with one another. It reads the artifact set and paths from `openspec status`, reviews related artifacts in any direction (not only downstream), edits planning artifacts only and never code, and confirms each edit with the user. It ends with next-step guidance — recommending `/opsx:continue`, `/opsx:apply`, or `/opsx:archive` based on the change's state — without acting on it. ## Impact - `src/core/templates/workflows/update-change.ts` (**new**) — the `openspec-update-change` skill template and the `/opsx:update` command template, mirroring the structure of `continue-change.ts`. Reads artifact ids and paths from `openspec status --json`; embeds no artifact-name patterns. -- Skill/command registration + the expanded-workflow profile that already lists `continue`, `ff`, `verify`, … so `/opsx:update` installs alongside its siblings. +- Skill/command registration + [src/core/profiles.ts](../../../src/core/profiles.ts) — add `update` to `ALL_WORKFLOWS` **and to the default `core` profile** (`propose`, `explore`, `apply`, `sync`, `archive`), so `/opsx:update` is part of the default install rather than expanded-only (maintainer call on the PR). - `docs/opsx.md` — add a `/opsx:update` row to the command table and a short "Updating a change" usage note. - `openspec/changes/add-artifact-regeneration-support/` — the in-repo proposal-only stub for this gap is superseded; retire it or fold its notes into design. - No changes to `src/core/artifact-graph/*`, `src/commands/workflow/status.ts`, or `ChangeMetadataSchema`. The skill uses `openspec status` / `openspec list` as they exist today. diff --git a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md index 7c43f06219..a8074d2c8a 100644 --- a/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -95,6 +95,27 @@ The `/opsx:update` skill SHALL keep a change's existing planning artifacts coher - **WHEN** the skill finds the change's artifacts already coherent - **THEN** it reports the change as coherent and makes no edits +### Requirement: Next-Step Guidance + +After applying confirmed revisions (or finding none needed), the `/opsx:update` skill SHALL report where the change stands and recommend the next command, without acting on the recommendation itself. + +#### Scenario: Updating an already-implemented change + +- **WHEN** the user updates a change whose implementation already happened (for example tasks are checked off or `/opsx:apply` was already run) +- **THEN** the skill still revises planning artifacts only +- **AND** it notes that the implementation may no longer match the revised plan and recommends `/opsx:apply` to carry the delta into code +- **AND** it does not implement anything itself + +#### Scenario: Next step when artifacts are incomplete + +- **WHEN** the update finishes and the change still has not-yet-created artifacts +- **THEN** the skill recommends `/opsx:continue` to create them + +#### Scenario: Next step when the change is fully done + +- **WHEN** the update finishes and the change's artifacts are complete and already implemented +- **THEN** the skill recommends `/opsx:archive` + ### Requirement: User-Confirmed Incremental Application The `/opsx:update` skill SHALL propose each artifact revision and apply it only after user confirmation. diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 308d14d86d..519bb21362 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -4,10 +4,10 @@ ## 1. The `/opsx:update` skill -- [ ] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. -- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). +- [ ] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. The skill name is `openspec-update-change` — change-scoped, per the `openspec--change` convention (see design, Naming). +- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time → end with next-step guidance (`/opsx:continue` / `/opsx:apply` / `/opsx:archive` based on the change's state; see design Decision 6), never acting on it. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). - [ ] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). -- [ ] 1.4 Register the skill/command and add it to the expanded-workflow profile alongside `continue`/`ff`/`verify`. +- [ ] 1.4 Register the skill/command and add `update` to `ALL_WORKFLOWS` **and the default `core` profile** in `src/core/profiles.ts` (maintainer call: default install, not expanded-only). ## 2. Docs & supersede the stub @@ -21,6 +21,8 @@ - [ ] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. - [ ] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. - [ ] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. +- [ ] 3.5 Assert the template ends with next-step guidance (`/opsx:continue`/`/opsx:apply`/`/opsx:archive`) and instructs the agent never to act on it. +- [ ] 3.6 Assert `update` is included in the `core` profile's workflows (profiles test). ## 4. End-to-end verification From f5ed1bb6d7fcca47f13c8c3a9ab8a942e9d662ba Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 3 Jul 2026 09:43:02 -0500 Subject: [PATCH 11/11] feat(skills): implement the /opsx:update skill (openspec-update-change) Implements the approved add-update-workflow change: one thin skill over the existing status/list commands, in the default core profile. - new update-change.ts template (skill + command), registered across init, profiles, skill-generation, tool-detection, profile-sync-drift - update joins CORE_WORKFLOWS and ALL_WORKFLOWS - docs: opsx.md command row + usage note, commands.md reference section, supported-tools.md skill list - retire the superseded add-artifact-regeneration-support stub - template tests pin the guardrails (schema-driven ids, planning-only, existingOutputPaths write contract, next-step guidance); parity hashes regenerated; profile/init/update/config tests cover the new core set - tasks.md checked off; validate --strict passes Co-Authored-By: Claude Fable 5 --- docs/commands.md | 45 +++++ docs/opsx.md | 7 + docs/supported-tools.md | 1 + .../proposal.md | 136 -------------- openspec/changes/add-update-workflow/tasks.md | 30 +-- src/core/init.ts | 1 + src/core/profile-sync-drift.ts | 1 + src/core/profiles.ts | 3 +- src/core/shared/skill-generation.ts | 4 + src/core/shared/tool-detection.ts | 2 + src/core/templates/skill-templates.ts | 1 + src/core/templates/workflows/update-change.ts | 175 ++++++++++++++++++ test/commands/config-profile.test.ts | 31 ++-- test/commands/config.test.ts | 2 +- test/core/init.test.ts | 6 +- test/core/profiles.test.ts | 12 +- test/core/shared/skill-generation.test.ts | 14 +- test/core/shared/tool-detection.test.ts | 3 +- .../templates/skill-templates-parity.test.ts | 8 + test/core/templates/update-change.test.ts | 88 +++++++++ test/core/update.test.ts | 7 +- test/utils/command-references.test.ts | 1 + 22 files changed, 394 insertions(+), 184 deletions(-) delete mode 100644 openspec/changes/add-artifact-regeneration-support/proposal.md create mode 100644 src/core/templates/workflows/update-change.ts create mode 100644 test/core/templates/update-change.test.ts diff --git a/docs/commands.md b/docs/commands.md index 5d52c056c9..57ede52aa2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,6 +13,7 @@ For workflow patterns and when to use each command, see [Workflows](workflows.md | `/opsx:propose` | Create a change and generate planning artifacts in one step | | `/opsx:explore` | Think through ideas before committing to a change | | `/opsx:apply` | Implement tasks from the change | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | | `/opsx:sync` | Merge delta specs into main specs | | `/opsx:archive` | Archive a completed change | @@ -317,6 +318,50 @@ AI: Implementing add-dark-mode... --- +### `/opsx:update` + +Revise a change's existing planning artifacts and keep them coherent with one another. Planning artifacts only - it never edits code. + +**Syntax:** +``` +/opsx:update [change-name] +``` + +**Arguments:** +| Argument | Required | Description | +|----------|----------|-------------| +| `change-name` | No | Which change to update (inferred from context if not provided) | + +**What it does:** +- Reads the change's artifacts via `openspec status --change --json` +- Applies your requested revision, or reviews the artifacts for contradictions if you didn't name one +- Reconciles the other existing artifacts in any direction (a design edit may ripple back to the proposal) +- Confirms every edit with you before writing, one artifact at a time +- Ends by recommending the next step: `/opsx:continue` (artifacts missing), `/opsx:apply` (carry a revised plan into code), or `/opsx:archive` (all done) + +**Example:** +``` +You: /opsx:update add-dark-mode - we're storing the theme in a cookie now, not localStorage + +AI: Reading add-dark-mode artifacts... + + The design references localStorage in two places; tasks 1.3 covers + localStorage persistence; the proposal doesn't mention storage. + + Proposed revisions: + 1. design.md - swap localStorage decision for cookie storage + 2. tasks.md - reword task 1.3 to cookie persistence + + Apply revision 1? (design.md) +``` + +**Tips:** +- It won't create missing artifacts - that's `/opsx:continue` +- If the change was already implemented, follow up with `/opsx:apply` so the code matches the revised plan +- If your revision changes the *intent* of the change, start fresh with a new change instead (see [When to Update vs. Start Fresh](opsx.md#when-to-update-vs-start-fresh)) + +--- + ### `/opsx:verify` Validate that implementation matches your change artifacts. Checks completeness, correctness, and coherence. diff --git a/docs/opsx.md b/docs/opsx.md index bebe0a51dd..e396890add 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -163,6 +163,7 @@ rules: | `/opsx:continue` | Create the next artifact (expanded workflow) | | `/opsx:ff` | Fast-forward planning artifacts (expanded workflow) | | `/opsx:apply` | Implement tasks, updating artifacts as needed | +| `/opsx:update` | Revise a change's planning artifacts and keep them coherent | | `/opsx:verify` | Validate implementation against artifacts (expanded workflow) | | `/opsx:sync` | Sync delta specs to main (default workflow, optional) | | `/opsx:archive` | Archive when done | @@ -208,6 +209,12 @@ Creates all planning artifacts at once. Use when you have a clear picture of wha ``` Works through tasks, checking them off as you go. If you're juggling multiple changes, you can run `/opsx:apply `; otherwise it should infer from the conversation and prompt you to choose if it can't tell. +### Updating a change +``` +/opsx:update add-dark-mode - we're storing the theme in a cookie now +``` +Revises the change's existing planning artifacts and keeps them coherent - in any direction (a design edit may ripple back to the proposal). Planning artifacts only: it never edits code, and it never creates missing artifacts (that's `/opsx:continue`). Every edit is confirmed with you first. If the change was already implemented, it recommends `/opsx:apply` so the code catches up with the revised plan. If your revision changes the change's *intent*, start fresh instead - see [When to Update vs. Start Fresh](#when-to-update-vs-start-fresh). + ### Finish up ``` /opsx:archive # Move to archive when done (prompts to sync specs if needed) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..85b3ce25a7 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -96,6 +96,7 @@ When selected by profile/workflow config, OpenSpec generates these skills: - `openspec-new-change` - `openspec-continue-change` - `openspec-apply-change` +- `openspec-update-change` - `openspec-ff-change` - `openspec-sync-specs` - `openspec-archive-change` diff --git a/openspec/changes/add-artifact-regeneration-support/proposal.md b/openspec/changes/add-artifact-regeneration-support/proposal.md deleted file mode 100644 index d855cdc971..0000000000 --- a/openspec/changes/add-artifact-regeneration-support/proposal.md +++ /dev/null @@ -1,136 +0,0 @@ -# Add Artifact Regeneration Support - -## Problem - -Currently, there is **no way to regenerate artifacts** in the OPSX workflow: - -- `/opsx:apply` just reads whatever's on disk -- `/opsx:continue` only creates the NEXT artifact - won't touch existing ones - -If you edit `design.md` after `tasks.md` exists, your only options are: -1. Delete tasks.md manually, then run `/opsx:continue` -2. Edit tasks.md manually - -The documentation claims you can "update artifacts mid-flight and continue" but there's no mechanism that actually supports this. - -## Proposed Solution - -Two parts: - -### Part 1: Staleness Detection -Add artifact staleness detection to `/opsx:apply`: - -1. **Track modification times**: When generating an artifact, record the mtime of its dependencies -2. **Detect staleness**: When `/opsx:apply` runs, check if upstream artifacts (design.md, specs) have been modified since tasks.md was generated -3. **Prompt user**: If stale, ask: "Design was modified after tasks were generated. Would you like to regenerate tasks with `/opsx:continue`?" - -## User Experience - -### Vision: Seamless Mid-Flight Correction - -This is the workflow we want to enable (currently documented but not supported): - -``` -You: /opsx:apply - -AI: Working through tasks... - ✓ Task 1.1: Created caching layer - ✓ Task 1.2: Added cache invalidation - - Working on 1.3: Implement TTL... - I noticed the design assumes Redis, but your project uses - in-memory caching. Should I update the design? - -You: Yes, update it to use the existing cache module. - -AI: Updated design.md to use CacheManager from src/cache/ - Updated tasks.md with revised implementation steps - Continuing implementation... - ✓ Task 1.3: Implemented TTL using CacheManager - ... -``` - -**No restart needed.** Just update the artifact and continue. - -### Staleness Warning UX - -When user manually edits an upstream artifact: - -``` -$ /opsx:apply - -⚠️ Detected changes to upstream artifacts: - - design.md modified 5 minutes ago (after tasks.md was generated) - -Options: -1. Regenerate tasks (recommended) -2. Continue anyway with current tasks -3. Cancel - -> -``` - -### Part 2: Regeneration Capability - -Add a way to regenerate specific artifacts: - -```bash -# Option A: Flag on continue -/opsx:continue --regenerate tasks - -# Option B: Separate command -/opsx:regenerate tasks - -# Option C: Interactive prompt when staleness detected -/opsx:apply -# "Design changed. Regenerate tasks? [y/N]" -``` - -## Technical Approach - -### Option A: Metadata File -Store `.openspec-meta.json` in change directory: -```json -{ - "tasks.md": { - "generated_at": "2025-01-24T10:00:00Z", - "dependencies": { - "design.md": "2025-01-24T09:55:00Z", - "specs/feature/spec.md": "2025-01-24T09:50:00Z" - } - } -} -``` - -### Option B: Frontmatter -Add YAML frontmatter to generated artifacts: -```markdown ---- -generated_at: 2025-01-24T10:00:00Z -depends_on: - - design.md@2025-01-24T09:55:00Z ---- -# Tasks -... -``` - -### Option C: Git-based -Use git to detect if upstream files changed since downstream was last modified. No extra metadata needed but requires git. - -## Non-Goals - -- Automatic regeneration (user should always choose) -- Blocking apply entirely (just warn) -- Tracking code file changes (only artifact dependencies) - -## Dependencies - -- Should be implemented after `fix-midflight-update-docs` so docs are accurate first -- Could be combined with that change if desired - -## Success Criteria - -- User is warned when applying with stale artifacts -- Clear path to regenerate if needed -- No false positives (only warn when genuinely stale) -- Documentation claims become actually true diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md index 519bb21362..c56308f0c7 100644 --- a/openspec/changes/add-update-workflow/tasks.md +++ b/openspec/changes/add-update-workflow/tasks.md @@ -4,27 +4,27 @@ ## 1. The `/opsx:update` skill -- [ ] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. The skill name is `openspec-update-change` — change-scoped, per the `openspec--change` convention (see design, Naming). -- [ ] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time → end with next-step guidance (`/opsx:continue` / `/opsx:apply` / `/opsx:archive` based on the change's state; see design Decision 6), never acting on it. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). -- [ ] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). -- [ ] 1.4 Register the skill/command and add `update` to `ALL_WORKFLOWS` **and the default `core` profile** in `src/core/profiles.ts` (maintainer call: default install, not expanded-only). +- [x] 1.1 Create `src/core/templates/workflows/update-change.ts` with `getUpdateChangeSkillTemplate()` (skill) and `getOpsxUpdateCommandTemplate()` (command), mirroring `continue-change.ts`. The skill name is `openspec-update-change` — change-scoped, per the `openspec--change` convention (see design, Naming). +- [x] 1.2 Instruction body (see design "The skill, written by hand"): resolve the change (infer / `openspec list --json` / ask) → `openspec status --change --json` → read the relevant artifacts → apply the requested edit → reconcile the change's other existing artifacts in any direction → confirm and apply one artifact at a time → end with next-step guidance (`/opsx:continue` / `/opsx:apply` / `/opsx:archive` based on the change's state; see design Decision 6), never acting on it. Read artifact ids from the status JSON only, and write to `artifactPaths..existingOutputPaths` (never to a glob `resolvedOutputPath`). +- [x] 1.3 Encode the guardrails: (a) planning artifacts only — never edit code, hand off to `/opsx:apply`; (b) schema-driven — no branching on literal `proposal`/`specs`/`design`/`tasks`; ids/paths come from `openspec status`; (c) revise only existing files (`existingOutputPaths`) — defer not-yet-created artifacts, and new files under a glob artifact, to `/opsx:continue`; (d) intent change → recommend `/opsx:new` (the "Update vs. Start Fresh" heuristic in `docs/opsx.md`). +- [x] 1.4 Register the skill/command and add `update` to `ALL_WORKFLOWS` **and the default `core` profile** in `src/core/profiles.ts` (maintainer call: default install, not expanded-only). ## 2. Docs & supersede the stub -- [ ] 2.1 Add a `/opsx:update` row to the command table in `docs/opsx.md`, plus a short "Updating a change" usage note. -- [ ] 2.2 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single update proposal. -- [ ] 2.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. +- [x] 2.1 Add a `/opsx:update` row to the command table in `docs/opsx.md`, plus a short "Updating a change" usage note. +- [x] 2.2 Remove (or fold) `openspec/changes/add-artifact-regeneration-support/` so the tree has a single update proposal. +- [x] 2.3 Update any generated-skill manifests/fixtures that enumerate workflow skills so `openspec-update-change` is included. ## 3. Tests -- [ ] 3.1 Template generation snapshot for the skill and command templates. -- [ ] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. -- [ ] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. -- [ ] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. -- [ ] 3.5 Assert the template ends with next-step guidance (`/opsx:continue`/`/opsx:apply`/`/opsx:archive`) and instructs the agent never to act on it. -- [ ] 3.6 Assert `update` is included in the `core` profile's workflows (profiles test). +- [x] 3.1 Template generation snapshot for the skill and command templates. +- [x] 3.2 Assert the template's control flow contains NO hardcoded artifact-name branching (the anti-#777 guard): artifact ids must be read from `openspec status` JSON. +- [x] 3.3 Assert the template instructs planning-artifacts-only with a hand-off to `/opsx:apply` for code, and never advances the build frontier. +- [x] 3.4 Assert the template instructs writing to `existingOutputPaths` (the glob-expanded concrete files) and not to a glob `resolvedOutputPath`. +- [x] 3.5 Assert the template ends with next-step guidance (`/opsx:continue`/`/opsx:apply`/`/opsx:archive`) and instructs the agent never to act on it. +- [x] 3.6 Assert `update` is included in the `core` profile's workflows (profiles test). ## 4. End-to-end verification -- [ ] 4.1 `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. -- [ ] 4.2 Manual walk-through: on a `spec-driven` change, edit `design`, run `/opsx:update`, confirm it proposes coherence edits to other existing artifacts (including upstream where warranted) and never touches code. +- [x] 4.1 `openspec validate add-update-workflow --strict` passes; `openspec status --change add-update-workflow` shows all artifacts complete. +- [x] 4.2 Manual walk-through: on a `spec-driven` change, edit `design`, run `/opsx:update`, confirm it proposes coherence edits to other existing artifacts (including upstream where warranted) and never touches code. diff --git a/src/core/init.ts b/src/core/init.ts index 7f5149dd46..fba6d80733 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -67,6 +67,7 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..488d16cfdc 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -16,6 +16,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record = { 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', + 'update': 'openspec-update-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', diff --git a/src/core/profiles.ts b/src/core/profiles.ts index 29d4927468..acdc3ec953 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,7 +11,7 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'sync', 'archive'] as const; +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; /** * All available workflows in the system. @@ -22,6 +22,7 @@ export const ALL_WORKFLOWS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 898e7a25e8..2570a95a3e 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -9,6 +9,7 @@ import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, + getUpdateChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, @@ -20,6 +21,7 @@ import { getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, + getOpsxUpdateCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, @@ -59,6 +61,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' }, { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' }, { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' }, + { template: getUpdateChangeSkillTemplate(), dirName: 'openspec-update-change', workflowId: 'update' }, { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' }, { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' }, { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' }, @@ -85,6 +88,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxNewCommandTemplate(), id: 'new' }, { template: getOpsxContinueCommandTemplate(), id: 'continue' }, { template: getOpsxApplyCommandTemplate(), id: 'apply' }, + { template: getOpsxUpdateCommandTemplate(), id: 'update' }, { template: getOpsxFfCommandTemplate(), id: 'ff' }, { template: getOpsxSyncCommandTemplate(), id: 'sync' }, { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a3..30622209dc 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -16,6 +16,7 @@ export const SKILL_NAMES = [ 'openspec-new-change', 'openspec-continue-change', 'openspec-apply-change', + 'openspec-update-change', 'openspec-ff-change', 'openspec-sync-specs', 'openspec-archive-change', @@ -35,6 +36,7 @@ export const COMMAND_IDS = [ 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive', diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index ff687d900a..598fcc4465 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -10,6 +10,7 @@ export { getExploreSkillTemplate, getOpsxExploreCommandTemplate } from './workfl export { getNewChangeSkillTemplate, getOpsxNewCommandTemplate } from './workflows/new-change.js'; export { getContinueChangeSkillTemplate, getOpsxContinueCommandTemplate } from './workflows/continue-change.js'; export { getApplyChangeSkillTemplate, getOpsxApplyCommandTemplate } from './workflows/apply-change.js'; +export { getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate } from './workflows/update-change.js'; export { getFfChangeSkillTemplate, getOpsxFfCommandTemplate } from './workflows/ff-change.js'; export { getSyncSpecsSkillTemplate, getOpsxSyncCommandTemplate } from './workflows/sync-specs.js'; export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './workflows/archive-change.js'; diff --git a/src/core/templates/workflows/update-change.ts b/src/core/templates/workflows/update-change.ts new file mode 100644 index 0000000000..cf5475a6fe --- /dev/null +++ b/src/core/templates/workflows/update-change.ts @@ -0,0 +1,175 @@ +/** + * Skill Template Workflow Modules + * + * This file is generated by splitting the legacy monolithic + * templates file into workflow-focused modules. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +export function getUpdateChangeSkillTemplate(): SkillTemplate { + return { + name: 'openspec-update-change', + description: "Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code.", + instructions: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update. + + Present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths..existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).`, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxUpdateCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Update', + description: "Update a change - revise existing planning artifacts and keep them coherent (Experimental)", + category: 'Workflow', + tags: ['workflow', 'artifacts', 'experimental'], + content: `Revise a change's existing planning artifacts and keep them coherent. Never edit code. + +${STORE_SELECTION_GUIDANCE} + +**Input**: Optionally specify a change name after \`/opsx:update\` (e.g., \`/opsx:update add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **If no change name provided, prompt for selection** + + Run \`openspec list --json\` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to update. + + Present the top 3-4 most recently modified changes as options, showing: + - Change name + - Schema (from \`schema\` field if present, otherwise "spec-driven") + - Status (e.g., "0/5 tasks", "complete", "no tasks") + - How recently it was modified (from \`lastModified\` field) + + Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to update. + + **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose. + +2. **Get the change's artifacts** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to understand current state. The response includes: + - \`schemaName\`: The workflow schema being used (e.g., "spec-driven") + - \`artifacts\`: Array of artifacts with their status ("done", "ready", "blocked") + - \`isComplete\`: Boolean indicating if all artifacts are complete + - \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`: path and scope context. Use these instead of assuming repo-local paths. + + The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged. + + The files to edit are \`artifactPaths..existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file. + +3. **Understand the request** + - If the user asked for a specific revision ("the design now uses X"), that is the starting edit. + - If they only said "update" / "make this coherent", treat it as a coherence review: read the existing artifacts and check them against each other for contradictions, gaps, and duplication. + +4. **Read and reconcile** + - Read the artifact(s) the request touches and the change's other existing artifacts. + - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Note everything that is now inconsistent, missing, or contradictory. + - Revise only files that already exist (\`existingOutputPaths\`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to \`/opsx:continue\` to create them. + - If the change is already coherent, say so and make no edits. + +5. **Confirm and apply, one artifact at a time** + - Show each proposed revision and why. Write only after the user confirms. + - If the user rejects a revision, do not write it - leave that artifact unchanged. + - When a substantial rewrite is needed, get that artifact's rules and template first: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + +6. **Point to the next step (guidance only - NEVER act on it)** + - Artifacts still missing -> suggest \`/opsx:continue\` to create them. + - Change already implemented (tasks checked off / already applied) -> the code may no longer match the revised plan; suggest \`/opsx:apply\` to carry the delta into code. + - Everything done and implemented -> suggest \`/opsx:archive\`. + +**Output** + +After each invocation, show: +- Which artifacts were revised (and which proposed revisions were rejected) +- Anything deferred to \`/opsx:continue\` (not-yet-created artifacts or files) +- Where the change stands and the recommended next command + +**Guardrails** +- Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, stop and point to \`/opsx:apply\`. +- Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names. +- Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`. +- Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is \`/opsx:continue\`'s job. +- Confirm every edit with the user before writing. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with \`/opsx:new\` (the "Update vs. Start Fresh" heuristic).` + }; +} diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index d1b60002ac..679e89a547 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -73,12 +73,12 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns custom when selection is a superset of core workflows', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'sync', 'archive', 'new'])).toBe('custom'); + expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'new'])).toBe('custom'); }); it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -104,6 +104,7 @@ describe('config profile interactive flow', () => { 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -113,7 +114,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'sync', 'archive']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -168,7 +169,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('skills'); @@ -183,7 +184,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('keep'); await runConfigCommand(['profile']); @@ -211,7 +212,7 @@ describe('config profile interactive flow', () => { const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); checkbox.mockResolvedValueOnce(['propose', 'explore']); @@ -255,7 +256,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, checkbox } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); select.mockResolvedValueOnce('workflows'); checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'sync', 'archive']); @@ -281,7 +282,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); const configPath = getGlobalConfigPath(); const beforeContent = fs.readFileSync(configPath, 'utf-8'); @@ -301,7 +302,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -315,7 +316,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -329,7 +330,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupDriftedProjectArtifacts(tempDir); select.mockResolvedValueOnce('delivery'); select.mockResolvedValueOnce('both'); @@ -345,7 +346,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig } = await import('../../src/core/global-config.js'); const { select } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); setupSyncedCoreBothArtifacts(tempDir); addExtraVerifyWorkflowArtifacts(tempDir); select.mockResolvedValueOnce('keep'); @@ -360,7 +361,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); select.mockResolvedValueOnce('delivery'); @@ -380,7 +381,7 @@ describe('config profile interactive flow', () => { const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); const { select, confirm } = await getPromptMocks(); - saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] }); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); select.mockResolvedValueOnce('delivery'); @@ -407,7 +408,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index d6ac830d3d..9d3541b686 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -250,7 +250,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed1..39ae092a0a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -82,11 +82,12 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, sync, archive + // Core profile: propose, explore, apply, update, sync, archive const coreSkillNames = [ 'openspec-propose', 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', ]; @@ -121,11 +122,12 @@ describe('InitCommand', () => { await initCommand.execute(testDir); - // Core profile: propose, explore, apply, sync, archive + // Core profile: propose, explore, apply, update, sync, archive const coreCommandNames = [ 'opsx/propose.md', 'opsx/explore.md', 'opsx/apply.md', + 'opsx/update.md', 'opsx/sync.md', 'opsx/archive.md', ]; diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index b46901fa2e..b06456e016 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -9,7 +9,11 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { it('should contain the default core workflows', () => { - expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + }); + + it('should include update in the core profile (default install, not expanded-only)', () => { + expect(CORE_WORKFLOWS).toContain('update'); }); it('should be a subset of ALL_WORKFLOWS', () => { @@ -20,13 +24,13 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 11 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(11); + it('should contain all 12 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(12); }); it('should contain expected workflow IDs', () => { const expected = [ - 'propose', 'explore', 'new', 'continue', 'apply', + 'propose', 'explore', 'new', 'continue', 'apply', 'update', 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 6c755f51d2..5f4bba9d55 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 11 skill templates', () => { + it('should return all 12 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique directory names', () => { @@ -28,6 +28,7 @@ describe('skill-generation', () => { expect(dirNames).toContain('openspec-new-change'); expect(dirNames).toContain('openspec-continue-change'); expect(dirNames).toContain('openspec-apply-change'); + expect(dirNames).toContain('openspec-update-change'); expect(dirNames).toContain('openspec-ff-change'); expect(dirNames).toContain('openspec-sync-specs'); expect(dirNames).toContain('openspec-archive-change'); @@ -88,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 11 command templates', () => { + it('should return all 12 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique IDs', () => { @@ -108,6 +109,7 @@ describe('skill-generation', () => { expect(ids).toContain('new'); expect(ids).toContain('continue'); expect(ids).toContain('apply'); + expect(ids).toContain('update'); expect(ids).toContain('ff'); expect(ids).toContain('sync'); expect(ids).toContain('archive'); @@ -142,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 11 command contents', () => { + it('should return all 12 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(11); + expect(contents).toHaveLength(12); }); it('should have valid content structure', () => { diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index 5a66ff3cd5..eb3c04f97d 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -28,11 +28,12 @@ describe('tool-detection', () => { describe('SKILL_NAMES', () => { it('should contain all skill names matching COMMAND_IDS', () => { - expect(SKILL_NAMES).toHaveLength(11); + expect(SKILL_NAMES).toHaveLength(12); expect(SKILL_NAMES).toContain('openspec-explore'); expect(SKILL_NAMES).toContain('openspec-new-change'); expect(SKILL_NAMES).toContain('openspec-continue-change'); expect(SKILL_NAMES).toContain('openspec-apply-change'); + expect(SKILL_NAMES).toContain('openspec-update-change'); expect(SKILL_NAMES).toContain('openspec-ff-change'); expect(SKILL_NAMES).toContain('openspec-sync-specs'); expect(SKILL_NAMES).toContain('openspec-archive-change'); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index cc6ec7bc12..298d627c49 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -23,8 +23,10 @@ import { getOpsxSyncCommandTemplate, getOpsxProposeCommandTemplate, getOpsxProposeSkillTemplate, + getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, getSyncSpecsSkillTemplate, + getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, } from '../../../src/core/templates/skill-templates.js'; import { @@ -58,6 +60,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxProposeSkillTemplate: '8dfb5e9c719d5ba547aff0d3953c076dca6b33d7223be98cbffc396b8f1e0048', getOpsxProposeCommandTemplate: '7cd569beb32d99cdabd0b49615a8245160a8e152b6ea67a99fc4dd71e3f39f50', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', + getUpdateChangeSkillTemplate: 'fe2e8edaf973d42dc7fc7dfd846105c4c3cfec0437606e582ec644985cd4e81d', + getOpsxUpdateCommandTemplate: 'e55ac5774203a7d9037d2d588889c97c53f3f930da49497cc79e865375920da7', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { @@ -72,6 +76,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': '97d1eed5b900788706c28339e27c1d2d9c548626316253f43ebd00d8d52d02d6', 'openspec-onboard': 'd136b6ab7134d6bceeca73bc2f6037624506587e8df99059f77fe88874256ed1', 'openspec-propose': '5c350d80247722489374a49ec9853d5fda55a827f421fbb32b6b6a078fcb69ee', + 'openspec-update-change': 'c755a35c44245326780a3df1342df15103ed6a9de7af864581844a10de4f554d', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -88,6 +93,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], + ['openspec-update-change', getUpdateChangeSkillTemplate], ]; function stableStringify(value: unknown): string { @@ -136,6 +142,8 @@ describe('skill templates split parity', () => { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, getFeedbackSkillTemplate, + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, }; const actualHashes = Object.fromEntries( diff --git a/test/core/templates/update-change.test.ts b/test/core/templates/update-change.test.ts new file mode 100644 index 0000000000..d0f5202c52 --- /dev/null +++ b/test/core/templates/update-change.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { + getUpdateChangeSkillTemplate, + getOpsxUpdateCommandTemplate, +} from '../../../src/core/templates/skill-templates.js'; +import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; + +const skill = getUpdateChangeSkillTemplate(); +const command = getOpsxUpdateCommandTemplate(); + +// Both delivery surfaces must carry the same contract; every behavioral +// assertion below runs against each body. +const bodies: Array<[string, string]> = [ + ['skill', skill.instructions], + ['command', command.content], +]; + +describe('update-change templates', () => { + it('generates the expected skill and command shape (3.1)', () => { + expect(skill.name).toBe('openspec-update-change'); + expect(skill.description).toContain('Never edits code'); + expect(skill.license).toBe('MIT'); + expect(skill.compatibility).toBe('Requires openspec CLI.'); + expect(skill.metadata).toEqual({ author: 'openspec', version: '1.0' }); + + expect(command.name).toBe('OPSX: Update'); + expect(command.category).toBe('Workflow'); + expect(command.tags).toEqual(['workflow', 'artifacts', 'experimental']); + expect(command.content).toContain('/opsx:update add-auth'); + + for (const [label, body] of bodies) { + expect(body, label).toContain(STORE_SELECTION_GUIDANCE); + expect(body, label).toContain('openspec list --json'); + expect(body, label).toContain('openspec status --change "" --json'); + expect(body, label).toContain('openspec instructions --change "" --json'); + } + }); + + it('reads artifact ids from status JSON and never branches on hardcoded artifact names (3.2)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('do NOT assume them, and do NOT branch on hardcoded artifact names'); + expect(body, label).toContain('never branch on hardcoded artifact names'); + expect(body, label).toContain('Custom schemas must work unchanged'); + // No literal artifact filenames anywhere: no proposal.md/design.md/tasks.md + // branching, and no worked example that names them. The only .md literal + // allowed is the specs/**/*.md glob illustration. + expect(body.replace(/specs\/\*\*\/\*\.md/g, ''), label).not.toMatch(/\b[\w-]+\.md\b/); + } + }); + + it('edits planning artifacts only, hands code off to /opsx:apply, never advances the frontier (3.3)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Never edit code'); + expect(body, label).toContain('NEVER edit implementation code'); + expect(body, label).toContain('stop and point to `/opsx:apply`'); + expect(body, label).toContain('Do not advance the build frontier'); + expect(body, label).toContain('Do NOT create artifacts that don\'t exist yet'); + } + }); + + it('writes to existingOutputPaths, never to a glob resolvedOutputPath (3.4)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('artifactPaths..existingOutputPaths'); + expect(body, label).toContain('Do NOT write to `resolvedOutputPath`'); + expect(body, label).toContain('still the glob pattern, not a real file'); + } + }); + + it('ends with next-step guidance and never acts on it (3.5)', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('guidance only - NEVER act on it'); + expect(body, label).toContain('suggest `/opsx:continue`'); + expect(body, label).toContain('suggest `/opsx:apply`'); + expect(body, label).toContain('suggest `/opsx:archive`'); + expect(body, label).toContain('the code may no longer match the revised plan'); + } + }); + + it('confirms every edit and redirects intent changes to /opsx:new', () => { + for (const [label, body] of bodies) { + expect(body, label).toContain('Write only after the user confirms'); + expect(body, label).toContain('If the user rejects a revision, do not write it'); + expect(body, label).toContain('recommend starting fresh with `/opsx:new`'); + expect(body, label).toContain('Update vs. Start Fresh'); + } + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7ed..dfdadfb58f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -155,10 +155,11 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile skill files were created/updated (propose, explore, apply, sync, archive) + // Verify core profile skill files were created/updated (propose, explore, apply, update, sync, archive) const coreSkillNames = [ 'openspec-explore', 'openspec-apply-change', + 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', 'openspec-propose', @@ -233,8 +234,8 @@ Old instructions content await updateCommand.execute(testDir); - // Verify core profile commands were created (propose, explore, apply, sync, archive) - const coreCommandIds = ['explore', 'apply', 'sync', 'archive', 'propose']; + // Verify core profile commands were created (propose, explore, apply, update, sync, archive) + const coreCommandIds = ['explore', 'apply', 'update', 'sync', 'archive', 'propose']; const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); for (const cmdId of coreCommandIds) { const cmdFile = path.join(commandsDir, `${cmdId}.md`); diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85b..dcd805f9f9 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -65,6 +65,7 @@ Finally /opsx-apply to implement`; 'new', 'continue', 'apply', + 'update', 'ff', 'sync', 'archive',