| name | opsx-diff |
| description | Show a before/after diff of OpenSpec spec changes for a given change. Highlights additions, removals, and behavioral modifications across acceptance criteria, flows, and constraints. Supports markdown and JSON output. |
| license | MIT |
| compatibility | Requires git. |
| metadata | {"author":"specrails","version":"1.0"} |
OpenSpec project root. The openspec/ directory lives in the REPO, not
the workspace this skill is driven from. The repo root is ${SPECRAILS_REPO_DIR:-.}
(a default-unset env var: when unset it resolves to ., i.e. the current
directory, so a classic in-repo run is unchanged). Run EVERY openspec
command against that root — wrap each invocation as
(cd "${SPECRAILS_REPO_DIR:-.}" && openspec …). Treat ${SPECRAILS_REPO_DIR:-.}
as the OpenSpec project root for any path the CLI prints or you read/write.
Visualize spec changes for this project (read name from CLAUDE.md or package.json): compare the current specs against a named OpenSpec change to show exactly what behavioral requirements are being added, modified, or removed.
Input: $ARGUMENTS — accepts:
<change-name> — the kebab-case name of the change to diff (required). If omitted, interactive selection is offered.
--format json — emit structured JSON instead of markdown (default: markdown).
--summary-only — skip inline line-level diff; show only the file-level and behavioral summary.
Phase 0: Argument Parsing
Parse $ARGUMENTS to set runtime variables.
Variables to set:
CHANGE_NAME — string. Required. If not provided, prompt the user.
FORMAT — "markdown" or "json". Default: "markdown".
SUMMARY_ONLY — boolean. Default: false.
Parsing rules:
- Scan
$ARGUMENTS for --format <value>. If found and value is json, set FORMAT="json". Any other value: print Error: unknown format "<value>". Valid: markdown, json and stop. Strip from arguments.
- Scan for
--summary-only. If found, set SUMMARY_ONLY=true. Strip from arguments.
- Treat the remaining token (if any) as
CHANGE_NAME. Strip leading/trailing whitespace.
- If
CHANGE_NAME is empty after parsing:
Print active configuration:
Diffing change: <CHANGE_NAME>
Format: <markdown|json>
Summary only: <yes|no>
Phase 1: Locate the Change
Find the change directory and enumerate its spec artifacts.
Step 1a: Resolve the change path
Check these locations in order:
openspec/changes/<CHANGE_NAME>/ — active change.
openspec/changes/archive/ — glob for directories matching *<CHANGE_NAME> or exact <CHANGE_NAME>. Take the most recent match (latest date prefix).
If neither location yields a directory, print:
Error: change "<CHANGE_NAME>" not found.
Searched:
- openspec/changes/<CHANGE_NAME>/
- openspec/changes/archive/*<CHANGE_NAME>*/
Run `openspec list` to see available changes.
Stop.
Set CHANGE_DIR to the resolved path.
Print: Found change at: <CHANGE_DIR>
Step 1b: Enumerate spec artifacts in the change
Collect spec content from the change directory using this priority:
- Delta-spec file —
<CHANGE_DIR>/delta-spec.md. If present, read it. Set HAS_DELTA_SPEC=true.
- Inline specs subdirectory —
<CHANGE_DIR>/specs/. If present, glob **/*.md within it. Each file is an individual spec. Set HAS_SPEC_DIR=true.
- Proposal file —
<CHANGE_DIR>/proposal.md. Always read if present. Used for context but not as a primary diff source. Set HAS_PROPOSAL=true.
At least one of HAS_DELTA_SPEC or HAS_SPEC_DIR must be true to proceed.
If neither exists:
Warning: no spec artifacts found in <CHANGE_DIR>.
Expected: delta-spec.md or specs/*.md
The change may not have reached the spec authoring phase yet.
Run /opsx:continue to create specs first.
Stop.
Store the collected spec content:
DELTA_SPEC — string content of delta-spec.md (or "" if absent).
CHANGE_SPECS — array of { path, content } objects from the specs/ subdirectory (or [] if absent).
PROPOSAL — string content of proposal.md (or "" if absent).
Print: Spec artifacts: <delta-spec.md present|absent>, <N spec files in specs/>, proposal <present|absent>
Phase 2: Load Baseline Specs
Find the current baseline specs to compare against.
Step 2a: Discover baseline spec files
Glob for markdown files in openspec/specs/ (recursively: openspec/specs/**/*.md).
Store as BASELINE_SPECS — array of { path, content } objects.
If openspec/specs/ does not exist or contains no files:
Note: no baseline specs found in openspec/specs/.
Diff will show all change specs as net-new additions (no removals or modifications).
Set BASELINE_SPECS=[].
Print: Baseline: <N> spec file(s) found in openspec/specs/
Step 2b: Match change specs to baseline specs
For each entry in CHANGE_SPECS (from the change's specs/ subdirectory), attempt to find its counterpart in BASELINE_SPECS.
Matching rule: A change spec at <CHANGE_DIR>/specs/<spec-name>/spec.md matches a baseline spec at openspec/specs/<spec-name>/spec.md or openspec/specs/<spec-name>.md. Match on the spec name segment only.
Build SPEC_PAIRS — array of:
{
specName: string,
changePath: string | null, // path in change (null if baseline-only)
baselinePath: string | null, // path in baseline (null if change-only)
changeContent: string | null,
baselineContent: string | null,
matchType: "new" | "modified" | "deleted" | "unchanged"
}
Match type rules:
changePath present, baselinePath absent → "new".
changePath absent, baselinePath present (and baseline spec is referenced/affected by delta-spec) → "deleted".
- Both present and content differs →
"modified".
- Both present and content identical →
"unchanged".
For the delta-spec flow (when HAS_DELTA_SPEC=true and HAS_SPEC_DIR=false):
- The delta-spec itself is the primary artifact. There is no per-file pairing.
- Set
SPEC_PAIRS=[] and use the delta-spec content directly in Phase 3.
Phase 3: Compute the Diff
Produce a structured diff comparing baseline vs. change specs.
Step 3a: Delta-spec analysis (when HAS_DELTA_SPEC=true)
Parse the delta-spec sections to extract behavioral elements:
Acceptance criteria / SHALL statements:
- Glob for lines matching the pattern:
^\*\*\d+\.\d+\*\* (numbered normative statements like **1.1**).
- Collect all
{ id, text } pairs into DELTA_STATEMENTS.
Surface impact table:
- Find the
## Surface Impact section (or ### Surface Impact of This Change).
- Parse all table rows into
SURFACE_CHANGES: [ { category, element, change, severity } ].
REST/API contract changes:
- Find any section with "REST API", "API Contract", or "Endpoints" in the heading.
- Extract endpoint definitions: method + path + response shape changes.
- Store as
API_CHANGES.
Since there is no true baseline for a delta-spec (it defines net-new behavior), classify every DELTA_STATEMENTS entry as a "new" addition. If the "Surface Impact" table includes rows with change type "Removal" or "BREAKING", classify those as "modified" or "deleted" accordingly.
Build DIFF_RESULT:
{
addedStatements: [{ id, text }],
modifiedStatements: [{ id, text, changeDescription }],
removedStatements: [{ id, text }],
surfaceChanges: [{ category, element, change, severity }],
apiChanges: [{ method, path, description }],
specPairs: []
}
Step 3b: Spec-file-pair analysis (when HAS_SPEC_DIR=true)
For each SPEC_PAIR in SPEC_PAIRS where matchType !== "unchanged":
Line-level diff:
Compare baselineContent and changeContent line by line:
- Split each content string into lines.
- Identify added lines (present in change, absent in baseline) and removed lines (present in baseline, absent in change).
- For each changed block, compute a ±3-line context window.
- Mark heading lines (starting with
#) separately — heading changes indicate structural reorganization.
- Mark lines containing normative language (
SHALL, MUST, SHOULD, MAY, SHALL NOT, MUST NOT) as behavioral.
Produce { specName, matchType, addedLines, removedLines, behavioralChanges, headingChanges }.
Behavioral change classification:
- Line added + contains
SHALL/MUST → new requirement.
- Line removed + contains
SHALL/MUST → removed requirement.
- Line modified (matched by proximity) + normative keyword → modified requirement.
- Other additions/removals → structural/non-normative.
Build DIFF_RESULT from all SPEC_PAIRS.
Step 3c: Compute summary statistics
ADDED_COUNT = count(addedStatements) + count(added spec files)
MODIFIED_COUNT = count(modifiedStatements) + count(modified spec files)
REMOVED_COUNT = count(removedStatements) + count(deleted spec files)
TOTAL_CHANGES = ADDED_COUNT + MODIFIED_COUNT + REMOVED_COUNT
Phase 4: Render Output
If FORMAT = "json"
Emit a single JSON object:
{
"schema_version": "1",
"project": "this project",
"change": "<CHANGE_NAME>",
"generated_at": "<ISO 8601 timestamp>",
"source": "<delta-spec | spec-files>",
"summary": {
"total_changes": <N>,
"added": <N>,
"modified": <N>,
"removed": <N>
},
"added_statements": [
{ "id": "1.1", "text": "..." }
],
"modified_statements": [
{ "id": "2.3", "text": "...", "change_description": "..." }
],
"removed_statements": [
{ "id": "3.1", "text": "..." }
],
"surface_changes": [
{ "category": "...", "element": "...", "change": "...", "severity": "..." }
],
"api_changes": [
{ "method": "POST", "path": "/api/spawn", "description": "..." }
],
"spec_pairs": [
{
"spec_name": "...",
"match_type": "new|modified|deleted",
"added_lines": ["..."],
"removed_lines": ["..."],
"behavioral_changes": ["..."],
"heading_changes": ["..."]
}
]
}
Stop after emitting JSON.
If FORMAT = "markdown"
Render the full diff report:
## OpenSpec Change Diff — <CHANGE_NAME>
Project: this project | Generated: <YYYY-MM-DD HH:MM>
### Summary
| Metric | Count |
|--------|-------|
| ➕ Added requirements | <N> |
| ✏️ Modified requirements | <N> |
| ➖ Removed requirements | <N> |
| **Total changes** | **<N>** |
If TOTAL_CHANGES = 0:
✅ No behavioral differences detected between the change specs and the baseline.
Stop.
Then render sections:
Added Requirements
### ➕ Added Requirements (<N>)
<for each added statement:>
> **<id>** <text>
<if none:>
_No new requirements._
Modified Requirements
### ✏️ Modified Requirements (<N>)
<for each modified statement or spec pair with matchType="modified":>
#### <spec-name or section heading>
\`\`\`diff
- <removed line>
+ <added line>
\`\`\`
**Change:** <change_description or inferred summary>
<if SUMMARY_ONLY=true: skip inline diff blocks, show only change description>
Removed Requirements
### ➖ Removed Requirements (<N>)
<for each removed statement:>
> ~~**<id>** <text>~~
<if none:>
_No requirements removed._
Surface Impact (when HAS_DELTA_SPEC=true and SURFACE_CHANGES is non-empty)
### 🗺️ Surface Impact
| # | Category | Element | Change | Severity |
|---|----------|---------|--------|----------|
<rows from SURFACE_CHANGES>
API Contract Changes (when API_CHANGES is non-empty)
### 🔌 API Contract Changes
<for each API_CHANGES entry:>
- **<METHOD> <path>** — <description>
New Spec Files (when added spec files exist)
### 📄 New Spec Files
<for each specPair where matchType="new":>
- `<changePath>` — new spec, no baseline counterpart
Deleted Spec Files (when deleted spec files exist)
### 🗑️ Deleted Spec Files
<for each specPair where matchType="deleted":>
- `<baselinePath>` — removed by this change
Close the report:
---
_Generated by `/specrails:opsx-diff` in this project_
_Change source: <CHANGE_DIR>_
**Next steps:**
- Run `/opsx:apply <CHANGE_NAME>` to implement these changes.
- Run `/opsx:archive <CHANGE_NAME>` after implementation to merge specs into baseline.
Phase 5: Save Snapshot (optional)
After rendering, write a diff snapshot to .claude/opsx-diff-history/:
- Filename:
<CHANGE_NAME>-<YYYY-MM-DD>.json
- Directory:
.claude/opsx-diff-history/ (create if absent, idempotent).
- Content: the JSON object from Phase 4 (regardless of FORMAT setting).
Print: Snapshot saved: .claude/opsx-diff-history/<CHANGE_NAME>-<YYYY-MM-DD>.json
If the write fails: print Warning: could not write diff snapshot. Continuing. Do not abort.
Housekeeping: If .claude/opsx-diff-history/ has more than 50 .json files, print:
Note: .claude/opsx-diff-history/ has <N> snapshots. Prune old ones with:
ls -t .claude/opsx-diff-history/ | tail -n +51 | xargs -I{} rm .claude/opsx-diff-history/{}