Three OpenSpec changes implemented:
1. semantic-spec-diff-tool (foundation):
- spec_parser.py: shared Python spec parser (parse_spec) and
semantic diff function (semantic_spec_diff) mirroring the
frontend parseSpec. Requirement/scenario-level delta:
added/modified/removed/unchanged with scenario-level diffs.
- openspec_spec_diff agent tool (filesystem-backed, always
available). Diffs change spec vs baseline, or worktree vs HEAD.
- Schema, registration, plugin.yaml updated.
- 19 tests covering parser, diff, and tool handler.
2. change-specs-semantic-diff-view:
- Backend: _change_detail computes semantic_diff per spec.
- Frontend: SpecDeltaView component replaces side-by-side full
specs. Summary-first view: added/modified/removed sections with
scenario-level deltas, unchanged collapsed, raw diff collapsible.
3. spec-browser-structured-rendering:
- Switch dirty/refs modes from raw Markdown to SpecContentView.
- Add compact semantic summary (+N ~M -K) to spec list items.
- Backend: _spec_browser computes semantic_summary per entry.
All 31 tests pass. OpenSpec changes validate --strict.- commit
- ed2fb359336e766b8182c44b9fc571555d12de64
- parent
- db2ff137c2fae78c89f1b9992927caf37d31c71e
- author
- dami etoile <dami.etoile@gmail.com>
| M | README.md | 5 | +- |
| M | __init__.py | 1 | + |
| M | dashboard/ | 135 | ++++++++++++- |
| M | dashboard/ | 28 | +++ |
| M | dashboard/ | 40 | ++++ |
| M | docs/ | 2 | + |
| M | openspec/ | 14 | +- |
| M | openspec/ | 14 | +- |
| M | openspec/ | 8 | +- |
| M | plugin.yaml | 1 | + |
| M | schemas.py | 27 | ++ |
| A | spec_parser.py | 246 | ++++++++++++++++++++++ |
| A | tests/ | 331 | ++++++++++++++++++++++++++++++ |
| M | tools.py | 112 | ++++++++++ |
@@ -13,7 +13,7 @@ This plugin closes both gaps:
1313 1414 ## What you get1515 16-**Agent tools** — 15 tools covering the full OpenSpec lifecycle: idea capture and enrichment, change creation/promotion/archival, task tracking, spec validation, and context resolution.16+**Agent tools** — 16 tools covering the full OpenSpec lifecycle: idea capture and enrichment, change creation/promotion/archival, task tracking, spec validation, semantic spec diffing, and context resolution.1717 1818 | Group | Tools |1919 |---|---|@@ -21,8 +21,9 @@ This plugin closes both gaps:
2121 | Ideas | `openspec_idea_create`, `openspec_idea_enrich`, `openspec_idea_promote` |2222 | Changes | `openspec_change_create`, `openspec_change_promote`, `openspec_change_archive`, `openspec_change_unarchive` |2323 | Tasks | `openspec_task_list`, `openspec_task_set_status` |24+| Specs | `openspec_spec_diff` |2425 25-`openspec_context` and the lifecycle write tools (idea/change/task) are filesystem-backed and always available. `openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, and `openspec_instructions` require the `openspec` CLI binary.26+`openspec_context`, `openspec_spec_diff`, and the lifecycle write tools (idea/change/task) are filesystem-backed and always available. `openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, and `openspec_instructions` require the `openspec` CLI binary.2627 2728 For the full tool reference, lifecycle states, and delegation patterns, see [docs/index.md](docs/index.md).2829 @@ -27,6 +27,7 @@ _WRITE_TOOLS = (
2727 ("openspec_change_promote", schemas.OPENSPEC_CHANGE_PROMOTE, tools.openspec_change_promote, "➡️"),2828 ("openspec_change_archive", schemas.OPENSPEC_CHANGE_ARCHIVE, tools.openspec_change_archive, "📦"),2929 ("openspec_change_unarchive", schemas.OPENSPEC_CHANGE_UNARCHIVE, tools.openspec_change_unarchive, "♻️"),30+ ("openspec_spec_diff", schemas.OPENSPEC_SPEC_DIFF, tools.openspec_spec_diff, "🔍"),3031 )3132 3233 @@ -365,11 +365,7 @@
365365 ),366366 mode === "diff" && s.diff367367 ? h("div", { className: "os-spec-diff" },368- h("div", { className: "os-diff-cols" },369- h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "current"), h(SpecContentView, { md: s.before })),370- h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "proposed"), h(SpecContentView, { md: s.content }))371- ),372- h("pre", { className: "os-diff-unified" }, s.diff)368+ h(SpecDeltaView, { semanticDiff: s.semantic_diff, lineDiff: s.diff, specPath: s.path })373369 )374370 : h(SpecContentView, { md: s.content })375371 );@@ -528,6 +524,122 @@
528524 );529525 }530526 527+ // ── Semantic spec delta view (requirement-level diff) ──────────────528+ function SpecDeltaView(_ref2) {529+ var semanticDiff = _ref2.semanticDiff, lineDiff = _ref2.lineDiff, specPath = _ref2.specPath;530+ var _rs = useState(true);531+ var rawCollapsed = _rs[0], setRawCollapsed = _rs[1];532+533+ if (!semanticDiff || !semanticDiff.requirements) {534+ // No semantic diff available — fall back to SpecContentView535+ return h("p", { className: "os-empty" }, "No semantic diff available.");536+ }537+538+ var reqs = semanticDiff.requirements;539+ var added = reqs.added || [], modified = reqs.modified || [], removed = reqs.removed || [], unchanged = reqs.unchanged || [];540+ var summaryParts = [];541+ if (added.length) summaryParts.push("+" + added.length + " added");542+ if (modified.length) summaryParts.push("~" + modified.length + " modified");543+ if (removed.length) summaryParts.push("-" + removed.length + " removed");544+545+ function renderScenario(scn, key, suffix) {546+ return h("div", { key: key, className: "os-spec-scn" + (suffix ? " os-spec-scn-" + suffix : "") },547+ h("div", { className: "os-spec-scn-name" }, scn.name),548+ scn.steps ? scn.steps.map(function (st, sti) {549+ return h("div", { key: sti, className: cn("os-spec-step", "os-spec-step-" + st.type.toLowerCase()) },550+ h("span", { className: "os-spec-step-label" }, st.type),551+ h("span", { className: "os-spec-step-text" }, st.text)552+ );553+ }) : null554+ );555+ }556+557+ function renderRequirement(req, key, badge) {558+ return h("div", { key: key, className: "os-spec-req os-spec-req-delta" },559+ h("div", { className: "os-spec-req-head" },560+ badge ? h(Badge, { className: "os-spec-status os-status-" + badge }, badge) : null,561+ h("span", { className: "os-spec-req-name" }, req.name)562+ ),563+ req.description ? h("p", { className: "os-spec-req-desc" }, req.description) : null,564+ req.scenarios ? req.scenarios.map(function (scn, si) {565+ return renderScenario(scn, si, badge);566+ }) : null567+ );568+ }569+570+ return h("div", { className: "os-spec-delta" },571+ // Summary572+ h("div", { className: "os-delta-summary" },573+ summaryParts.length574+ ? h("span", { className: "os-delta-summary-text" }, summaryParts.join(", "))575+ : h("span", { className: "os-delta-summary-text os-delta-unchanged" }, "No changes")576+ ),577+578+ // Added requirements579+ added.length ? h("div", { className: "os-delta-section" },580+ h("div", { className: "os-delta-section-head" }, "Added"),581+ added.map(function (req, i) { return renderRequirement(req, "a" + i, "added"); })582+ ) : null,583+584+ // Modified requirements585+ modified.length ? h("div", { className: "os-delta-section" },586+ h("div", { className: "os-delta-section-head" }, "Modified"),587+ modified.map(function (mod, i) {588+ return h("div", { key: "m" + i, className: "os-spec-req os-spec-req-delta" },589+ h("div", { className: "os-spec-req-head" },590+ h(Badge, { className: "os-spec-status os-status-modified" }, "modified"),591+ h("span", { className: "os-spec-req-name" }, mod.name)592+ ),593+ // Description change594+ mod.before.description !== mod.after.description ? h("div", { className: "os-delta-desc-change" },595+ h("div", { className: "os-delta-before" }, h("span", { className: "os-delta-label" }, "Before:"), " ", mod.before.description),596+ h("div", { className: "os-delta-after" }, h("span", { className: "os-delta-label" }, "After:"), " ", mod.after.description)597+ ) : null,598+ // Scenario-level deltas599+ (mod.scenarios_added && mod.scenarios_added.length) || (mod.scenarios_modified && mod.scenarios_modified.length) || (mod.scenarios_removed && mod.scenarios_removed.length)600+ ? h("div", { className: "os-delta-scenarios" },601+ (mod.scenarios_added || []).map(function (scn, si) { return renderScenario(scn, "sa" + si, "added"); }),602+ (mod.scenarios_modified || []).map(function (sm, si) {603+ return h("div", { key: "sm" + si, className: "os-spec-scn os-spec-scn-modified" },604+ h("div", { className: "os-spec-scn-name" }, sm.name, h(Badge, { className: "os-spec-status os-status-modified", style: { marginLeft: "0.5rem" } }, "modified")),605+ h("div", { className: "os-delta-scenario-change" },606+ sm.before.steps ? h("div", { className: "os-delta-before" }, sm.before.steps.map(function (st, sti) {607+ return h("div", { key: sti, className: "os-spec-step os-spec-step-" + st.type.toLowerCase() }, h("span", { className: "os-spec-step-label" }, st.type), h("span", { className: "os-spec-step-text" }, st.text));608+ })) : null,609+ sm.after.steps ? h("div", { className: "os-delta-after" }, sm.after.steps.map(function (st, sti) {610+ return h("div", { key: sti, className: "os-spec-step os-spec-step-" + st.type.toLowerCase() }, h("span", { className: "os-spec-step-label" }, st.type), h("span", { className: "os-spec-step-text" }, st.text));611+ })) : null612+ )613+ );614+ }),615+ (mod.scenarios_removed || []).map(function (scn, si) { return renderScenario(scn, "sr" + si, "removed"); })616+ ) : null617+ );618+ })619+ ) : null,620+621+ // Removed requirements622+ removed.length ? h("div", { className: "os-delta-section" },623+ h("div", { className: "os-delta-section-head" }, "Removed"),624+ removed.map(function (req, i) { return renderRequirement(req, "r" + i, "removed"); })625+ ) : null,626+627+ // Unchanged requirements (collapsed)628+ unchanged.length ? h("details", { className: "os-delta-unchanged-section" },629+ h("summary", null, unchanged.length + " unchanged requirement" + (unchanged.length === 1 ? "" : "s")),630+ h("div", { className: "os-delta-unchanged-list" },631+ unchanged.map(function (name, i) { return h("div", { key: i, className: "os-delta-unchanged-req" }, name); })632+ )633+ ) : null,634+635+ // Raw diff (collapsible)636+ lineDiff ? h("details", { className: "os-delta-raw-section", open: !rawCollapsed ? true : false },637+ h("summary", { onClick: function (e) { e.preventDefault(); setRawCollapsed(!rawCollapsed); } }, "Raw diff"),638+ rawCollapsed ? null : h("pre", { className: "os-diff-unified" }, lineDiff)639+ ) : null640+ );641+ }642+531643 // ── Specs view (spec browser: current / dirty / refs) ────────────────532644 var SORT_MODES = [533645 { value: "alpha", label: "A→Z" },@@ -628,13 +740,20 @@
628740 return h("div", { className: "os-specs-grid" },629741 h("div", { className: "os-specs-list" },630742 sorted.map(function (f) {743+ var summaryParts = [];744+ if (f.semantic_summary) {745+ if (f.semantic_summary.added) summaryParts.push("+" + f.semantic_summary.added);746+ if (f.semantic_summary.modified) summaryParts.push("~" + f.semantic_summary.modified);747+ if (f.semantic_summary.removed) summaryParts.push("-" + f.semantic_summary.removed);748+ }631749 return h("button", {632750 key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),633751 onClick: function () { setSelPath(f.path); if (onSelectToken && f.token) onSelectToken(f.token); },634752 },635753 h(Icon, { d: ICO.file, size: 13 }),636754 h("span", { className: "os-spec-item-path" }, f.path),637- f.changed ? h(Badge, { className: "os-spec-status os-status-" + f.status }, f.status) : null755+ f.changed ? h(Badge, { className: "os-spec-status os-status-" + f.status }, f.status) : null,756+ summaryParts.length ? h("span", { className: "os-spec-item-summary" }, summaryParts.join(" ")) : null638757 );639758 })640759 ),@@ -650,8 +769,8 @@
650769 mode === "current" ? h(SpecContentView, { md: selFile.after }) :651770 h("div", { className: "os-spec-diff" },652771 h("div", { className: "os-diff-cols" },653- h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "before"), h(Markdown, { md: selFile.before })),654- h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "after"), h(Markdown, { md: selFile.after }))772+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "before"), h(SpecContentView, { md: selFile.before })),773+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "after"), h(SpecContentView, { md: selFile.after }))655774 ),656775 selFile.diff ? h("pre", { className: "os-diff-unified" }, selFile.diff) : null657776 )@@ -172,3 +172,31 @@
172172 .os-diff-col { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.5rem; overflow-x: auto; }173173 .os-diff-col-head { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-muted-foreground, #777); margin-bottom: 0.375rem; }174174 .os-diff-unified { background: var(--color-secondary, #0e0e0e); border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.625rem; font-family: var(--theme-font-mono, monospace); font-size: 0.72rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }175+176+/* Semantic spec delta view */177+.os-spec-delta { display: flex; flex-direction: column; gap: 0.75rem; }178+.os-delta-summary { padding: 0.5rem 0.625rem; background: var(--color-secondary, #0e0e0e); border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); }179+.os-delta-summary-text { font-size: 0.8rem; font-weight: 600; }180+.os-delta-unchanged { color: var(--color-muted-foreground, #777); }181+.os-delta-section { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.5rem; }182+.os-delta-section-head { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-muted-foreground, #777); margin-bottom: 0.375rem; }183+.os-spec-req-delta { margin-bottom: 0.5rem; }184+.os-spec-req-delta:last-child { margin-bottom: 0; }185+.os-spec-scn-added { border-left: 2px solid #22c55e; padding-left: 0.5rem; }186+.os-spec-scn-removed { border-left: 2px solid #ef4444; padding-left: 0.5rem; opacity: 0.7; }187+.os-spec-scn-modified { border-left: 2px solid #f59e0b; padding-left: 0.5rem; }188+.os-delta-desc-change { margin: 0.25rem 0; padding: 0.375rem; background: var(--color-secondary, #0e0e0e); border-radius: var(--radius-sm, 0.375rem); font-size: 0.8rem; }189+.os-delta-before { color: var(--color-muted-foreground, #999); text-decoration: line-through; }190+.os-delta-after { color: var(--color-foreground, #e0e0e0); }191+.os-delta-label { font-weight: 600; font-size: 0.7rem; text-transform: uppercase; }192+.os-delta-scenarios { margin-top: 0.25rem; }193+.os-delta-scenario-change { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 0.25rem; }194+.os-delta-unchanged-section { margin-top: 0.25rem; }195+.os-delta-unchanged-section summary { cursor: pointer; font-size: 0.78rem; color: var(--color-muted-foreground, #777); }196+.os-delta-unchanged-list { padding: 0.375rem 0 0 1rem; }197+.os-delta-unchanged-req { font-size: 0.78rem; color: var(--color-muted-foreground, #777); }198+.os-delta-raw-section { margin-top: 0.5rem; }199+.os-delta-raw-section summary { cursor: pointer; font-size: 0.78rem; color: var(--color-muted-foreground, #777); }200+201+/* Spec list semantic summary */202+.os-spec-item-summary { font-size: 0.65rem; font-family: var(--theme-font-mono, monospace); color: var(--color-muted-foreground, #999); margin-left: auto; white-space: nowrap; }@@ -400,12 +400,14 @@ def _change_detail(root: Path, change_name: str) -> Optional[dict[str, Any]]:
400400 after = _read_doc(child) or ""401401 before = _read_worktree_spec(root, rel)402402 status = _spec_status(before, after)403+ semantic = _compute_semantic_diff(before, after)403404 specs.append({404405 "path": rel,405406 "content": after,406407 "before": before,407408 "status": status,408409 "diff": _spec_diff(before, after, rel) if status not in ("unchanged", "missing") else "",410+ "semantic_diff": semantic,409411 })410412 except OSError:411413 pass@@ -592,6 +594,43 @@ def _spec_diff(before: Optional[str], after: Optional[str], path: str) -> str:
592594 )593595 594596 597+def _compute_semantic_diff(before: Optional[str], after: Optional[str]) -> dict[str, Any]:598+ """Compute a semantic requirement-level delta using the shared spec parser."""599+ try:600+ spec_parser_mod = _load_spec_parser()601+ if spec_parser_mod is None:602+ return {}603+ return spec_parser_mod.semantic_spec_diff(before, after)604+ except Exception:605+ return {}606+607+608+def _compute_semantic_summary(before: Optional[str], after: Optional[str]) -> Optional[dict[str, int]]:609+ """Compute compact requirement-level counts for spec list display."""610+ try:611+ spec_parser_mod = _load_spec_parser()612+ if spec_parser_mod is None:613+ return None614+ diff = spec_parser_mod.semantic_spec_diff(before, after)615+ return spec_parser_mod.semantic_summary(diff)616+ except Exception:617+ return None618+619+620+def _load_spec_parser():621+ """Import the shared spec_parser module from the plugin root."""622+ import importlib.util623+ candidate = Path(__file__).resolve().parent.parent / "spec_parser.py"624+ if not candidate.is_file():625+ return None626+ spec = importlib.util.spec_from_file_location("openspec_spec_parser", candidate)627+ if spec is None or spec.loader is None:628+ return None629+ mod = importlib.util.module_from_spec(spec)630+ spec.loader.exec_module(mod)631+ return mod632+633+595634 def _spec_status(before: Optional[str], after: Optional[str]) -> str:596635 if before is None and after is None:597636 return "missing"@@ -675,6 +714,7 @@ def _spec_browser(root: Path, before: str = "", after: str = "", dirty: bool = F
675714 "before": before_content,676715 "after": after_content,677716 "diff": _spec_diff(before_content, after_content, rel) if status not in {"unchanged", "missing"} else "",717+ "semantic_summary": _compute_semantic_summary(before_content, after_content) if status not in {"unchanged", "missing"} else None,678718 })679719 680720 return {@@ -27,6 +27,7 @@ that performs it.
2727 | Validate before archiving | All artifacts | `openspec_validate` | Returns validation errors |2828 | Archive a completed change | `openspec/changes/archive/` | `openspec_change_archive` | Moves the change dir to archive |2929 | Reopen an archived change | `openspec/changes/` (restore) | `openspec_change_unarchive` | Moves the change dir back to active |30+| Compare a change spec against its baseline | Spec files (read-only) | `openspec_spec_diff` | Returns a structured semantic delta at the requirement/scenario level plus a unified line diff fallback. Filesystem-backed. |3031 3132 ---3233 @@ -47,6 +48,7 @@ the reference.
4748 | `openspec_task_set_status` | Toggles checkboxes in `openspec/changes/<change>/tasks.md` | Todo → In progress → Done |4849 | `openspec_change_archive` | Moves `openspec/changes/<change>/` → `openspec/changes/archive/<change>/` | Done → Archived |4950 | `openspec_change_unarchive` | Moves archive dir back to active changes | Archived → Done |51+| `openspec_spec_diff` | Returns structured semantic delta (requirement/scenario level) between change spec and baseline, or worktree spec and HEAD. Read-only. | (no transition — read tool) |5052 5153 ### Read tools5254 @@ -1,9 +1,9 @@
11 ## 1. Tasks22 3-- [ ] 1.1 Add _semantic_spec_diff call to _change_detail in plugin_api.py — compute semantic delta per change spec and include as semantic_diff field in each spec entry4-- [ ] 1.2 Add SpecDeltaView component to index.js — renders summary ('N requirements added, M modified, K removed'), expandable sections for added/modified/removed requirements with scenario-level deltas5-- [ ] 1.3 Collapse unchanged requirements by default in SpecDeltaView (noise reduction)6-- [ ] 1.4 Keep unified line diff as collapsible 'raw diff' section at the bottom of the delta view7-- [ ] 1.5 Replace current 'Diff vs current' toggle in ChangeSpecsView to use SpecDeltaView instead of side-by-side full specs8-- [ ] 1.6 Update specs/change-board spec: modify 'Delta spec comparison' requirement to include semantic diff alongside unified diff9-- [ ] 1.7 Write tests for _change_detail semantic_diff field computation3+- [x] 1.1 Add _semantic_spec_diff call to _change_detail in plugin_api.py — compute semantic delta per change spec and include as semantic_diff field in each spec entry4+- [x] 1.2 Add SpecDeltaView component to index.js — renders summary ('N requirements added, M modified, K removed'), expandable sections for added/modified/removed requirements with scenario-level deltas5+- [x] 1.3 Collapse unchanged requirements by default in SpecDeltaView (noise reduction)6+- [x] 1.4 Keep unified line diff as collapsible 'raw diff' section at the bottom of the delta view7+- [x] 1.5 Replace current 'Diff vs current' toggle in ChangeSpecsView to use SpecDeltaView instead of side-by-side full specs8+- [x] 1.6 Update specs/change-board spec: modify 'Delta spec comparison' requirement to include semantic diff alongside unified diff9+- [x] 1.7 Write tests for _change_detail semantic_diff field computation@@ -1,9 +1,9 @@
11 ## 1. Tasks22 3-- [ ] 1.1 Add _parse_spec(md) Python function to tools.py mirroring the frontend parseSpec parser (title, purpose, requirements with name/description/scenarios, scenarios with name/steps)4-- [ ] 1.2 Add _semantic_spec_diff(before_md, after_md) function returning structured delta: requirements.added/modified/removed/unchanged, with scenario-level diffs inside modified requirements5-- [ ] 1.3 Add openspec_spec_diff tool handler: takes workdir, spec, optional change; diffs change spec against baseline spec when change is provided, diffs worktree spec against HEAD when change is omitted6-- [ ] 1.4 Add OPENSPEC_SPEC_DIFF schema to schemas.py7-- [ ] 1.5 Register openspec_spec_diff in __init__.py (filesystem-backed, always available — no check_fn gating)8-- [ ] 1.6 Add openspec_spec_diff to provides_tools in plugin.yaml9-- [ ] 1.7 Write tests for parser, semantic diff (added/modified/removed/unchanged requirements, scenario-level deltas), and tool handler (change vs baseline, worktree vs HEAD, missing spec handling)3+- [x] 1.1 Add _parse_spec(md) Python function to tools.py mirroring the frontend parseSpec parser (title, purpose, requirements with name/description/scenarios, scenarios with name/steps)4+- [x] 1.2 Add _semantic_spec_diff(before_md, after_md) function returning structured delta: requirements.added/modified/removed/unchanged, with scenario-level diffs inside modified requirements5+- [x] 1.3 Add openspec_spec_diff tool handler: takes workdir, spec, optional change; diffs change spec against baseline spec when change is provided, diffs worktree spec against HEAD when change is omitted6+- [x] 1.4 Add OPENSPEC_SPEC_DIFF schema to schemas.py7+- [x] 1.5 Register openspec_spec_diff in __init__.py (filesystem-backed, always available — no check_fn gating)8+- [x] 1.6 Add openspec_spec_diff to provides_tools in plugin.yaml9+- [x] 1.7 Write tests for parser, semantic diff (added/modified/removed/unchanged requirements, scenario-level deltas), and tool handler (change vs baseline, worktree vs HEAD, missing spec handling)@@ -1,6 +1,6 @@
11 ## 1. Tasks22 3-- [ ] 1.1 Replace Markdown with SpecContentView for before/after rendering in SpecsView dirty and refs modes (index.js)4-- [ ] 1.2 Add compact semantic summary to spec list items in dirty/refs mode — compute requirement-level added/modified/removed counts from before/after content and display as a badge or inline text5-- [ ] 1.3 Backend: add a semantic_summary field (added/modified/removed counts) to spec-browser entries in dirty and refs modes (plugin_api.py _spec_browser)6-- [ ] 1.4 Update specs/spec-browser spec: add requirement for structured rendering in diff modes and semantic summary in spec list3+- [x] 1.1 Replace Markdown with SpecContentView for before/after rendering in SpecsView dirty and refs modes (index.js)4+- [x] 1.2 Add compact semantic summary to spec list items in dirty/refs mode — compute requirement-level added/modified/removed counts from before/after content and display as a badge or inline text5+- [x] 1.3 Backend: add a semantic_summary field (added/modified/removed counts) to spec-browser entries in dirty and refs modes (plugin_api.py _spec_browser)6+- [x] 1.4 Update specs/spec-browser spec: add requirement for structured rendering in diff modes and semantic summary in spec list@@ -17,3 +17,4 @@ provides_tools:
1717 - openspec_change_promote1818 - openspec_change_archive1919 - openspec_change_unarchive20+ - openspec_spec_diff@@ -296,3 +296,30 @@ OPENSPEC_CHANGE_UNARCHIVE = {
296296 "description": "Move an archived OpenSpec change back to the active changes directory.",297297 "parameters": {"type": "object", "properties": {**PROJECT_PROPS, "change": {"type": "string"}, "change_id": {"type": "string"}, "force": {"type": "boolean"}}, "required": ["change"]},298298 }299+300+OPENSPEC_SPEC_DIFF = {301+ "name": "openspec_spec_diff",302+ "description": (303+ "Compare a change's delta spec against its baseline, or a worktree spec "304+ "against its HEAD version, and return a structured semantic delta at the "305+ "requirement and scenario level plus a unified line diff fallback. "306+ "Filesystem-backed — does not require the OpenSpec CLI binary. "307+ "Pass 'change' to diff a change's delta spec against the baseline spec; "308+ "omit 'change' to diff the current worktree spec against its HEAD version."309+ ),310+ "parameters": {311+ "type": "object",312+ "properties": {313+ **PROJECT_PROPS,314+ "spec": {315+ "type": "string",316+ "description": "Spec name (e.g. 'agent-tools') or relative path under openspec/specs/.",317+ },318+ "change": {319+ "type": "string",320+ "description": "Change id — when provided, diff the change's delta spec against the baseline. Omit for worktree-vs-HEAD comparison.",321+ },322+ },323+ "required": ["spec"],324+ },325+}@@ -0,0 +1,246 @@
1+"""Shared OpenSpec spec parser and semantic diff.2+3+Importable by both ``tools.py`` (agent tool handlers) and4+``dashboard/plugin_api.py`` (dashboard backend) so the spec parsing5+and diffing logic lives in one place.6+7+The parser mirrors the frontend ``parseSpec`` function in8+``dashboard/dist/index.js`` — same state machine, same output structure.9+"""10+11+from __future__ import annotations12+13+import difflib14+from typing import Any15+16+17+def parse_spec(md: str | None) -> dict[str, Any]:18+ """Parse OpenSpec spec markdown into structured data.19+20+ Returns ``{ title, purpose, requirements: [...] }`` where each21+ requirement is ``{ name, description, scenarios: [...] }`` and each22+ scenario is ``{ name, steps: [{ type, text }] }``.23+ """24+ if not md:25+ return {"title": "", "purpose": "", "requirements": []}26+27+ lines = md.split("\n")28+ title = ""29+ purpose = ""30+ reqs: list[dict[str, Any]] = []31+ state = "top" # top | purpose | reqDesc | scenario32+ current_req: dict[str, Any] | None = None33+ current_scn: dict[str, Any] | None = None34+35+ for line in lines:36+ trim = line.strip()37+38+ # Title: "# Title" but not "## Title"39+ if trim.startswith("# ") and not trim.startswith("## "):40+ title = trim[2:].strip().removesuffix("Specification").strip()41+ state = "top"42+ continue43+44+ if trim.lower().startswith("## purpose"):45+ state = "purpose"46+ continue47+48+ if trim.lower().startswith("## requirements"):49+ state = "top"50+ continue51+52+ m_req = re_match(r"^###\s+Requirement:\s*(.+)$", trim)53+ if m_req:54+ current_req = {"name": m_req.group(1).strip(), "description": "", "scenarios": []}55+ reqs.append(current_req)56+ current_scn = None57+ state = "reqDesc"58+ continue59+60+ m_scn = re_match(r"^####\s+Scenario:\s*(.+)$", trim)61+ if m_scn:62+ current_scn = {"name": m_scn.group(1).strip(), "steps": []}63+ if current_req is not None:64+ current_req["scenarios"].append(current_scn)65+ state = "scenario"66+ continue67+68+ # Accumulate text based on state69+ if not trim:70+ continue71+72+ if state == "purpose":73+ purpose = f"{purpose}\n{trim}" if purpose else trim74+ elif state == "reqDesc":75+ if current_req is not None:76+ current_req["description"] = (77+ f"{current_req['description']}\n{trim}"78+ if current_req["description"]79+ else trim80+ )81+ elif state == "scenario":82+ if current_scn is not None:83+ step = re_match(r"^[-*]\s+\*\*(\w+)\*\*\s*(.+)$", trim)84+ if step:85+ current_scn["steps"].append({86+ "type": step.group(1).upper(),87+ "text": step.group(2).strip(),88+ })89+ elif current_scn["steps"]:90+ # Non-bullet text inside scenario — append to last step91+ current_scn["steps"][-1]["text"] += f" {trim}"92+93+ return {"title": title, "purpose": purpose, "requirements": reqs}94+95+96+def _scenario_key(scn: dict[str, Any]) -> str:97+ """Normalize a scenario for comparison by name."""98+ return scn["name"]99+100+101+def _requirement_key(req: dict[str, Any]) -> str:102+ """Normalize a requirement for comparison by name."""103+ return req["name"]104+105+106+def _scenarios_equal(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:107+ if a is None or b is None:108+ return a is b109+ return a["name"] == b["name"] and a["steps"] == b["steps"]110+111+112+def _requirements_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:113+ return (114+ a["name"] == b["name"]115+ and a["description"] == b["description"]116+ and a["scenarios"] == b["scenarios"]117+ )118+119+120+def semantic_spec_diff(before_md: str | None, after_md: str | None) -> dict[str, Any]:121+ """Compare two spec markdown strings at the requirement/scenario level.122+123+ Returns a structured delta::124+125+ {126+ "status": "added" | "modified" | "deleted" | "unchanged",127+ "requirements": {128+ "added": [...],129+ "modified": [{ name, before, after, scenarios_added, ... }],130+ "removed": [...],131+ "unchanged": ["name", ...],132+ },133+ }134+ """135+ before = parse_spec(before_md)136+ after = parse_spec(after_md)137+138+ before_reqs = {r["name"]: r for r in before["requirements"]}139+ after_reqs = {r["name"]: r for r in after["requirements"]}140+141+ added: list[dict[str, Any]] = []142+ modified: list[dict[str, Any]] = []143+ removed: list[dict[str, Any]] = []144+ unchanged: list[str] = []145+146+ all_names = set(before_reqs) | set(after_reqs)147+ for name in sorted(all_names):148+ b_req = before_reqs.get(name)149+ a_req = after_reqs.get(name)150+151+ if b_req is None and a_req is not None:152+ added.append(a_req)153+ elif b_req is not None and a_req is None:154+ removed.append(b_req)155+ elif b_req is not None and a_req is not None:156+ if _requirements_equal(b_req, a_req):157+ unchanged.append(name)158+ else:159+ # Diff scenarios within the modified requirement160+ b_scns = {s["name"]: s for s in b_req["scenarios"]}161+ a_scns = {s["name"]: s for s in a_req["scenarios"]}162+163+ scn_added: list[dict[str, Any]] = []164+ scn_modified: list[dict[str, Any]] = []165+ scn_removed: list[dict[str, Any]] = []166+167+ for scn_name in sorted(set(b_scns) | set(a_scns)):168+ b_scn = b_scns.get(scn_name)169+ a_scn = a_scns.get(scn_name)170+ if b_scn is None and a_scn is not None:171+ scn_added.append(a_scn)172+ elif b_scn is not None and a_scn is None:173+ scn_removed.append(b_scn)174+ elif b_scn is not None and a_scn is not None:175+ if not _scenarios_equal(b_scn, a_scn):176+ scn_modified.append({177+ "name": scn_name,178+ "before": b_scn,179+ "after": a_scn,180+ })181+182+ modified.append({183+ "name": name,184+ "before": {185+ "description": b_req["description"],186+ "scenarios": b_req["scenarios"],187+ },188+ "after": {189+ "description": a_req["description"],190+ "scenarios": a_req["scenarios"],191+ },192+ "scenarios_added": scn_added,193+ "scenarios_modified": scn_modified,194+ "scenarios_removed": scn_removed,195+ })196+197+ # Overall status198+ if not before_md and not after_md:199+ status = "unchanged"200+ elif not before_md:201+ status = "added"202+ elif not after_md:203+ status = "deleted"204+ elif not added and not modified and not removed:205+ status = "unchanged"206+ else:207+ status = "modified"208+209+ return {210+ "status": status,211+ "requirements": {212+ "added": added,213+ "modified": modified,214+ "removed": removed,215+ "unchanged": unchanged,216+ },217+ }218+219+220+def semantic_summary(diff: dict[str, Any]) -> dict[str, int]:221+ """Extract compact counts from a semantic diff for list display."""222+ reqs = diff.get("requirements", {})223+ return {224+ "added": len(reqs.get("added", [])),225+ "modified": len(reqs.get("modified", [])),226+ "removed": len(reqs.get("removed", [])),227+ }228+229+230+def unified_diff(before_md: str | None, after_md: str | None, path: str = "spec") -> str:231+ """Return a unified line diff for fallback display."""232+ return "\n".join(233+ difflib.unified_diff(234+ (before_md or "").splitlines(),235+ (after_md or "").splitlines(),236+ fromfile=f"before/{path}",237+ tofile=f"after/{path}",238+ lineterm="",239+ )240+ )241+242+243+def re_match(pattern: str, string: str):244+ """Wrapper to avoid importing re at module level in callers."""245+ import re246+ return re.match(pattern, string, re.IGNORECASE)@@ -0,0 +1,331 @@
1+"""Tests for the semantic spec diff parser, diff function, and agent tool."""2+3+import json4+import sys5+from pathlib import Path6+7+import pytest8+9+# Make the repo root importable10+_repo_root = Path(__file__).resolve().parent.parent11+sys.path.insert(0, str(_repo_root))12+13+import spec_parser14+import tools15+16+17+# ─── Parser tests ───────────────────────────────────────────────────────18+19+20+SAMPLE_SPEC = """# Agent Tools Specification21+22+## Purpose23+24+Tools for agents to interact with OpenSpec.25+26+## Requirements27+28+### Requirement: Context resolution29+Resolve identifiers into repo paths.30+31+#### Scenario: Resolve a source name32+- **GIVEN** a source is registered33+- **WHEN** an agent calls context34+- **THEN** the tool returns the repo path35+36+#### Scenario: Resolve a change token37+- **GIVEN** a change exists38+- **WHEN** an agent calls context with a token39+- **THEN** the tool returns the change content40+41+### Requirement: Validation42+Validate artifacts.43+44+#### Scenario: Validate a change45+- **WHEN** an agent calls validate46+- **THEN** the tool returns validation errors47+"""48+49+50+def test_parse_spec_well_formed():51+ result = spec_parser.parse_spec(SAMPLE_SPEC)52+ assert result["title"] == "Agent Tools"53+ assert "Tools for agents" in result["purpose"]54+ assert len(result["requirements"]) == 255+56+ req1 = result["requirements"][0]57+ assert req1["name"] == "Context resolution"58+ assert "Resolve identifiers" in req1["description"]59+ assert len(req1["scenarios"]) == 260+61+ scn1 = req1["scenarios"][0]62+ assert scn1["name"] == "Resolve a source name"63+ assert len(scn1["steps"]) == 364+ assert scn1["steps"][0]["type"] == "GIVEN"65+ assert scn1["steps"][0]["text"] == "a source is registered"66+67+68+def test_parse_spec_no_requirements():69+ md = "# Title\n\n## Purpose\n\nSome purpose text.\n"70+ result = spec_parser.parse_spec(md)71+ assert result["title"] == "Title"72+ assert result["purpose"] == "Some purpose text."73+ assert result["requirements"] == []74+75+76+def test_parse_spec_empty():77+ result = spec_parser.parse_spec("")78+ assert result == {"title": "", "purpose": "", "requirements": []}79+80+81+def test_parse_spec_none():82+ result = spec_parser.parse_spec(None)83+ assert result == {"title": "", "purpose": "", "requirements": []}84+85+86+def test_parse_spec_fallback_no_requirements():87+ md = "# Title\n\nSome text without requirement headers.\n"88+ result = spec_parser.parse_spec(md)89+ assert result["title"] == "Title"90+ assert result["requirements"] == []91+92+93+# ─── Semantic diff tests ────────────────────────────────────────────────94+95+96+def test_diff_added_requirement():97+ before = "# S\n## Purpose\nP\n"98+ after = "# S\n## Purpose\nP\n### Requirement: New Req\nDesc.\n"99+ diff = spec_parser.semantic_spec_diff(before, after)100+ assert diff["status"] == "modified"101+ assert len(diff["requirements"]["added"]) == 1102+ assert diff["requirements"]["added"][0]["name"] == "New Req"103+104+105+def test_diff_removed_requirement():106+ before = "# S\n### Requirement: Old Req\nDesc.\n"107+ after = "# S\n"108+ diff = spec_parser.semantic_spec_diff(before, after)109+ assert diff["status"] == "modified"110+ assert len(diff["requirements"]["removed"]) == 1111+ assert diff["requirements"]["removed"][0]["name"] == "Old Req"112+113+114+def test_diff_modified_requirement_description():115+ before = "# S\n### Requirement: Req A\nOld description.\n"116+ after = "# S\n### Requirement: Req A\nNew description.\n"117+ diff = spec_parser.semantic_spec_diff(before, after)118+ assert diff["status"] == "modified"119+ assert len(diff["requirements"]["modified"]) == 1120+ mod = diff["requirements"]["modified"][0]121+ assert mod["name"] == "Req A"122+ assert mod["before"]["description"] == "Old description."123+ assert mod["after"]["description"] == "New description."124+125+126+def test_diff_scenario_level_delta():127+ before = """# S128+### Requirement: Req A129+Description.130+131+#### Scenario: Scenario 1132+- **GIVEN** something133+- **THEN** result134+135+#### Scenario: Scenario 2136+- **WHEN** action137+- **THEN** result2138+"""139+ after = """# S140+### Requirement: Req A141+Description.142+143+#### Scenario: Scenario 1144+- **GIVEN** something else145+- **THEN** result146+147+#### Scenario: Scenario 3148+- **WHEN** new action149+- **THEN** new result150+"""151+ diff = spec_parser.semantic_spec_diff(before, after)152+ assert diff["status"] == "modified"153+ mod = diff["requirements"]["modified"][0]154+ assert len(mod["scenarios_added"]) == 1155+ assert mod["scenarios_added"][0]["name"] == "Scenario 3"156+ assert len(mod["scenarios_modified"]) == 1157+ assert mod["scenarios_modified"][0]["name"] == "Scenario 1"158+ assert len(mod["scenarios_removed"]) == 1159+ assert mod["scenarios_removed"][0]["name"] == "Scenario 2"160+161+162+def test_diff_unchanged_requirement():163+ before = "# S\n### Requirement: Req A\nSame desc.\n"164+ after = "# S\n### Requirement: Req A\nSame desc.\n"165+ diff = spec_parser.semantic_spec_diff(before, after)166+ assert diff["status"] == "unchanged"167+ assert "Req A" in diff["requirements"]["unchanged"]168+169+170+def test_diff_no_baseline():171+ before = None172+ after = "# S\n### Requirement: Req A\nDesc.\n"173+ diff = spec_parser.semantic_spec_diff(before, after)174+ assert diff["status"] == "added"175+ assert len(diff["requirements"]["added"]) == 1176+177+178+def test_diff_deleted_spec():179+ before = "# S\n### Requirement: Req A\nDesc.\n"180+ after = None181+ diff = spec_parser.semantic_spec_diff(before, after)182+ assert diff["status"] == "deleted"183+ assert len(diff["requirements"]["removed"]) == 1184+185+186+def test_semantic_summary():187+ diff = {188+ "requirements": {189+ "added": [{"name": "A"}, {"name": "B"}],190+ "modified": [{"name": "C"}],191+ "removed": [],192+ "unchanged": ["D"],193+ }194+ }195+ summary = spec_parser.semantic_summary(diff)196+ assert summary == {"added": 2, "modified": 1, "removed": 0}197+198+199+# ─── Tool handler tests ─────────────────────────────────────────────────200+201+202+203+def tmp_project(tmp_path):204+ """Create a minimal OpenSpec project with a baseline spec and a change."""205+ openspec = tmp_path / "openspec"206+ openspec.mkdir()207+208+ # Baseline spec209+ baseline = openspec / "specs" / "agent-tools" / "spec.md"210+ baseline.parent.mkdir(parents=True)211+ baseline.write_text("""# Agent Tools Specification212+213+## Purpose214+215+Tools for agents.216+217+## Requirements218+219+### Requirement: Context resolution220+Resolve identifiers.221+222+#### Scenario: Resolve source223+- **GIVEN** a source224+- **WHEN** called225+- **THEN** returns path226+227+### Requirement: Validation228+Validate artifacts.229+""")230+231+ # Change spec (adds a requirement, modifies one)232+ change_spec = openspec / "changes" / "test-change" / "specs" / "agent-tools" / "spec.md"233+ change_spec.parent.mkdir(parents=True)234+ change_spec.write_text("""# Agent Tools Specification235+236+## Purpose237+238+Tools for agents.239+240+## Requirements241+242+### Requirement: Context resolution243+Resolve identifiers into repo paths and workdir hints.244+245+#### Scenario: Resolve source246+- **GIVEN** a source247+- **WHEN** called248+- **THEN** returns path249+250+### Requirement: Semantic diff251+Compare specs at the requirement level.252+""")253+254+ # Minimal proposal so the change is valid255+ (openspec / "changes" / "test-change" / "proposal.md").write_text("## Why\nTest.\n")256+257+ return tmp_path258+259+260+def test_tool_diff_change_vs_baseline(tmp_project):261+ result = json.loads(tools.openspec_spec_diff({262+ "workdir": str(tmp_project),263+ "spec": "agent-tools",264+ "change": "test-change",265+ }))266+ assert result["ok"] is True267+ assert result["status"] == "modified"268+ assert result["baseline_exists"] is True269+ reqs = result["requirements"]270+ assert len(reqs["added"]) == 1271+ assert reqs["added"][0]["name"] == "Semantic diff"272+ assert len(reqs["removed"]) == 1273+ assert reqs["removed"][0]["name"] == "Validation"274+ assert len(reqs["modified"]) == 1275+ assert reqs["modified"][0]["name"] == "Context resolution"276+ assert "line_diff" in result277+ assert result["line_diff"] # non-empty278+279+280+def test_tool_new_spec_no_baseline(tmp_project):281+ # Create a change with a spec that has no baseline282+ change_spec = tmp_project / "openspec" / "changes" / "test-change" / "specs" / "new-spec" / "spec.md"283+ change_spec.parent.mkdir(parents=True)284+ change_spec.write_text("# New Spec\n### Requirement: New Req\nDesc.\n")285+286+ result = json.loads(tools.openspec_spec_diff({287+ "workdir": str(tmp_project),288+ "spec": "new-spec",289+ "change": "test-change",290+ }))291+ assert result["ok"] is True292+ assert result["status"] == "added"293+ assert result["baseline_exists"] is False294+ assert len(result["requirements"]["added"]) == 1295+296+297+def test_tool_missing_change_spec(tmp_project):298+ result = json.loads(tools.openspec_spec_diff({299+ "workdir": str(tmp_project),300+ "spec": "nonexistent",301+ "change": "test-change",302+ }))303+ assert result["ok"] is False304+ assert "not found" in result["error"]305+306+307+def test_tool_missing_spec_no_change(tmp_project):308+ result = json.loads(tools.openspec_spec_diff({309+ "workdir": str(tmp_project),310+ "spec": "nonexistent",311+ }))312+ assert result["ok"] is False313+ assert "not found" in result["error"]314+315+316+def test_tool_path_traversal(tmp_project):317+ result = json.loads(tools.openspec_spec_diff({318+ "workdir": str(tmp_project),319+ "spec": "../../../etc/passwd",320+ "change": "test-change",321+ }))322+ assert result["ok"] is False323+ assert "parent references" in result["error"]324+325+326+def test_tool_no_spec(tmp_project):327+ result = json.loads(tools.openspec_spec_diff({328+ "workdir": str(tmp_project),329+ }))330+ assert result["ok"] is False331+ assert "spec is required" in result["error"]@@ -1107,3 +1107,115 @@ def openspec_instructions(args: dict, **kwargs) -> str:
11071107 reason = "\n".join(str(parsed.get(key) or "") for key in ("stderr", "stdout", "error"))11081108 fallback = _template_instruction_fallback(artifact, reason, args.get("workdir"), schema)11091109 return fallback or result1110+1111+1112+def openspec_spec_diff(args: dict, **kwargs) -> str:1113+ """Compare a change's delta spec against its baseline, or a worktree spec1114+ against its HEAD version, and return the structured semantic delta plus a1115+ unified line diff fallback.1116+1117+ Filesystem-backed — does not require the OpenSpec CLI binary.1118+ """1119+ root, err, _context = _resolve_project(args)1120+ if err or root is None:1121+ return _error(err or "workdir could not be resolved")1122+1123+ spec = str(args.get("spec") or "").strip()1124+ if not spec:1125+ return _error("spec is required (e.g. 'agent-tools')")1126+1127+ # Prevent path traversal1128+ if spec.startswith("/") or ".." in Path(spec).parts:1129+ return _error("spec must be a relative path without parent references")1130+1131+ change = str(args.get("change") or "").strip()1132+1133+ try:1134+ from . import spec_parser # type: ignore1135+ except ImportError:1136+ import spec_parser # type: ignore1137+1138+ if change:1139+ # Diff change spec vs baseline1140+ change_spec_path = root / "openspec" / "changes" / change / "specs" / f"{spec}" / "spec.md"1141+ # Also handle nested paths like "agent-tools/spec.md"1142+ if not change_spec_path.is_file():1143+ change_spec_path = root / "openspec" / "changes" / change / "specs" / f"{spec}.md"1144+ if not change_spec_path.is_file():1145+ return _error(1146+ f"change spec not found: openspec/changes/{change}/specs/{spec}/spec.md",1147+ change=change,1148+ spec=spec,1149+ )1150+1151+ after_md = _read_doc(change_spec_path)1152+ baseline_path = root / "openspec" / "specs" / spec / "spec.md"1153+ if not baseline_path.is_file():1154+ baseline_path = root / "openspec" / "specs" / f"{spec}.md"1155+1156+ before_md = _read_doc(baseline_path) if baseline_path.is_file() else None1157+ baseline_exists = before_md is not None1158+ else:1159+ # Diff worktree spec vs HEAD1160+ worktree_path = root / "openspec" / "specs" / spec / "spec.md"1161+ if not worktree_path.is_file():1162+ worktree_path = root / "openspec" / "specs" / f"{spec}.md"1163+ if not worktree_path.is_file():1164+ return _error(1165+ f"spec not found: openspec/specs/{spec}/spec.md",1166+ spec=spec,1167+ )1168+1169+ after_md = _read_doc(worktree_path)1170+ before_md = _git_show_spec(root, spec)1171+ if before_md is None:1172+ return _error(1173+ "git is required for worktree-vs-HEAD comparison and the spec "1174+ "has no git history (it may be untracked). Pass 'change' to "1175+ "diff against a change's delta spec instead.",1176+ spec=spec,1177+ )1178+ baseline_exists = True1179+1180+ diff = spec_parser.semantic_spec_diff(before_md, after_md)1181+ line_diff = spec_parser.unified_diff(before_md, after_md, spec)1182+1183+ return json.dumps({1184+ "ok": True,1185+ "spec": spec,1186+ "change": change or None,1187+ "status": diff["status"],1188+ "baseline_exists": baseline_exists,1189+ "requirements": diff["requirements"],1190+ "line_diff": line_diff,1191+ "workdir": str(root),1192+ })1193+1194+1195+def _git_show_spec(root: Path, spec_path: str) -> str | None:1196+ """Return the HEAD version of a spec via git, or None if unavailable."""1197+ try:1198+ proc = subprocess.run(1199+ ["git", "-C", str(root), "show", f"HEAD:openspec/specs/{spec_path}/spec.md"],1200+ capture_output=True,1201+ text=True,1202+ timeout=8,1203+ check=False,1204+ )1205+ except Exception:1206+ return None1207+ if proc.returncode != 0:1208+ # Try flat path1209+ try:1210+ proc = subprocess.run(1211+ ["git", "-C", str(root), "show", f"HEAD:openspec/specs/{spec_path}.md"],1212+ capture_output=True,1213+ text=True,1214+ timeout=8,1215+ check=False,1216+ )1217+ except Exception:1218+ return None1219+ if proc.returncode != 0:1220+ return None1221+ return proc.stdout