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/.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..9ab208b735 --- /dev/null +++ b/openspec/changes/add-update-workflow/design.md @@ -0,0 +1,115 @@ +# Design: `/opsx:update` — a thin update skill + +## 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 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). + +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 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. +- Add as little code as possible: one skill template, no changes to the graph engine, the `status` command, or the metadata schema. + +**Non-Goals** +- 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. +- 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 + +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: + +``` +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 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. + - 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. + +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. +- If the request changes the change's *intent* rather than refining it, recommend `/opsx:new` + (the "Update vs. Start Fresh" heuristic, docs/opsx.md). +``` + +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). + +## Decisions + +### 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. + +### 2. Lean on the existing `status` command +`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. + +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. + +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. + +### 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). +- **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 + +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 new file mode 100644 index 0000000000..4bfc506bf8 --- /dev/null +++ b/openspec/changes/add-update-workflow/proposal.md @@ -0,0 +1,66 @@ +## 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 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)). + +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 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: + +- **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)). + +**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. + +### Deliberately not built (yet) + +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 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 + [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. + +## Issues addressed + +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" (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 (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) — the official answer becomes `/opsx:update`. + +Supersedes: + +- `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. + +Delineated from adjacent commands (distinct surfaces — coordinate, don't collide): + +- [#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/opsx-update-skill/spec.md b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md new file mode 100644 index 0000000000..a8074d2c8a --- /dev/null +++ b/openspec/changes/add-update-workflow/specs/opsx-update-skill/spec.md @@ -0,0 +1,138 @@ +## 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) 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 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: Missing artifacts are deferred to continue + +- **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 + +- **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: Schema-Driven Artifact Resolution + +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: 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 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 + +- **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 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 `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. + +#### Scenario: Reconcile related artifacts after an edit + +- **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: Upstream artifact may be revised + +- **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: Coherence review with no specific edit + +- **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** 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. + +#### 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: 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 diff --git a/openspec/changes/add-update-workflow/tasks.md b/openspec/changes/add-update-workflow/tasks.md new file mode 100644 index 0000000000..c56308f0c7 --- /dev/null +++ b/openspec/changes/add-update-workflow/tasks.md @@ -0,0 +1,30 @@ +# Tasks: `/opsx:update` — a thin update skill + +> 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. The `/opsx:update` skill + +- [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 + +- [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 + +- [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 + +- [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',