clone

a hermes agent plugin for openspec: tools for spec workflows and a dashboard tab for browsing changes and specs

merge: semantic spec diff — parser, agent tool, dashboard UX

dami etoile committed b6ca4cf
Merges changes/spec-diff branch with three completed changes:
- semantic-spec-diff-tool: spec_parser.py + openspec_spec_diff agent tool
- change-specs-semantic-diff-view: SpecDeltaView in Change > Specs tab
- spec-browser-structured-rendering: structured rendering + semantic summary

All changes archived. 31 tests pass. Docs updated (16 tools).
commit
b6ca4cfe7d8aab8851a04d96876a6986cb33fdd2
parent
7dc0e8a1f3a10d7ac21ce08d5e878b7f5f936e06
parent
42720c97509c583eb2f69b6b0dc35fdd2817e5a8
author
dami etoile <dami.etoile@gmail.com>
27 files changed, +1376 -11
MREADME.md5+-
M__init__.py1+
Mdashboard/dist/index.js135++++++++++++-
Mdashboard/dist/style.css28+++
Mdashboard/plugin_api.py40++++
Mdocs/tool-reference.md4+-
Aopenspec/changes/archive/change-specs-semantic-diff-view/.openspec.yaml2+
Aopenspec/changes/archive/change-specs-semantic-diff-view/proposal.md26++
Aopenspec/changes/archive/change-specs-semantic-diff-view/specs/change-board/spec.md25++
Aopenspec/changes/archive/change-specs-semantic-diff-view/specs/change-specs-semantic-diff-view/spec.md44++++
Aopenspec/changes/archive/change-specs-semantic-diff-view/tasks.md9+
Aopenspec/changes/archive/semantic-spec-diff-tool/.openspec.yaml2+
Aopenspec/changes/archive/semantic-spec-diff-tool/design.md114++++++++++
Aopenspec/changes/archive/semantic-spec-diff-tool/proposal.md23++
Aopenspec/changes/archive/semantic-spec-diff-tool/specs/agent-tools/spec.md26++
Aopenspec/changes/archive/semantic-spec-diff-tool/specs/semantic-spec-diff/spec.md84++++++++
Aopenspec/changes/archive/semantic-spec-diff-tool/tasks.md9+
Aopenspec/changes/archive/spec-browser-structured-rendering/.openspec.yaml2+
Aopenspec/changes/archive/spec-browser-structured-rendering/proposal.md25++
Aopenspec/changes/archive/spec-browser-structured-rendering/specs/spec-browser-structured-rendering/spec.md20++
Aopenspec/changes/archive/spec-browser-structured-rendering/specs/spec-browser/spec.md40++++
Aopenspec/changes/archive/spec-browser-structured-rendering/tasks.md6+
Mplugin.yaml1+
Mschemas.py27++
Aspec_parser.py246++++++++++++++++++++++
Atests/test_spec_diff.py331++++++++++++++++++++++++++++++
Mtools.py112++++++++++
M README.md +3 -2
@@ -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
M __init__.py +1 -0
M dashboard/dist/index.js +127 -8
@@ -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 )
M dashboard/dist/style.css +28 -0
@@ -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; }
M dashboard/plugin_api.py +40 -0
@@ -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 {
M docs/tool-reference.md +3 -1
A openspec/changes/archive/change-specs-semantic-diff-view/.openspec.yaml +2 -0
A openspec/changes/archive/change-specs-semantic-diff-view/proposal.md +26 -0
@@ -0,0 +1,26 @@
1+## Why2+3+The change > Specs tab currently shows side-by-side full specs plus a unified line diff — three views of the same data with no summary of what actually changed at the requirement level. For a change that touches one scenario in a 10-requirement spec, the maintainer has to scan two full documents to find the difference. The semantic delta computed by the shared parser (from the `semantic-spec-diff-tool` change) should drive this view instead.4+5+## What Changes6+7+Replace the "Diff vs current" side-by-side view in `ChangeSpecsView` with a semantic delta view. The backend `_change_detail` function computes a `semantic_diff` field per spec. The frontend renders a summary-first view: requirement-level added/modified/removed sections with scenario-level deltas inside modified requirements, unchanged requirements collapsed by default, and the unified line diff as a collapsible raw fallback at the bottom.8+9+## Capabilities10+11+### New Capabilities12+- `change-specs-semantic-diff-view`: Frontend `SpecDeltaView` component and backend `semantic_diff` field in change detail, rendering requirement-level deltas instead of side-by-side full specs13+14+### Modified Capabilities15+- `change-board`: The "Delta spec comparison" requirement is modified to include semantic diff alongside the existing unified diff in change detail responses16+17+## Impact18+19+- `dashboard/plugin_api.py` — `_change_detail` computes `semantic_diff` per spec using the shared `_semantic_spec_diff` function20+- `dashboard/dist/index.js` — new `SpecDeltaView` component; `ChangeSpecsView` updated to use it for the diff mode21+- `dashboard/dist/style.css` — styles for delta sections, added/modified/removed badges, collapsible unchanged requirements22+- `openspec/specs/change-board/spec.md` — delta spec modifying the "Delta spec comparison" requirement23+24+### Dependencies25+26+- Requires `semantic-spec-diff-tool` — the shared `_parse_spec` and `_semantic_spec_diff` functions must exist in `tools.py` before the backend can use them
A openspec/changes/archive/change-specs-semantic-diff-view/specs/change-board/spec.md +25 -0
A openspec/changes/archive/change-specs-semantic-diff-view/specs/change-specs-semantic-diff-view/spec.md +44 -0
@@ -0,0 +1,44 @@
1+# Change Specs Semantic Diff View Delta2+3+## ADDED Requirements4+5+### Requirement: Semantic delta view component6+The dashboard UI SHALL render a `SpecDeltaView` component that displays the semantic diff as a summary-first view with requirement-level sections.7+8+#### Scenario: Delta summary9+- **WHEN** the delta view renders for a modified spec10+- **THEN** it shows a summary line indicating how many requirements were added, modified, and removed11+12+#### Scenario: Added requirements section13+- **GIVEN** the semantic diff contains requirements in `added`14+- **WHEN** the delta view renders15+- **THEN** each added requirement is shown with its description and scenarios, marked with an `added` badge16+17+#### Scenario: Modified requirements section18+- **GIVEN** the semantic diff contains requirements in `modified`19+- **WHEN** the delta view renders20+- **THEN** each modified requirement shows before/after description changes and scenario-level deltas (added/modified/removed scenarios)21+22+#### Scenario: Removed requirements section23+- **GIVEN** the semantic diff contains requirements in `removed`24+- **WHEN** the delta view renders25+- **THEN** each removed requirement is shown with its description and scenarios, marked with a `removed` badge26+27+#### Scenario: Unchanged requirements collapsed28+- **GIVEN** the semantic diff contains requirements in `unchanged`29+- **WHEN** the delta view renders30+- **THEN** unchanged requirements are collapsed or hidden by default to reduce noise31+32+### Requirement: Raw diff fallback33+The dashboard UI SHALL provide the unified line diff as a collapsible section at the bottom of the semantic delta view.34+35+#### Scenario: Collapsible raw diff36+- **WHEN** the delta view renders for a modified spec37+- **THEN** a collapsible "raw diff" section appears at the bottom containing the unified line diff, collapsed by default38+39+### Requirement: Replace side-by-side view40+The dashboard UI SHALL use the semantic delta view instead of side-by-side full specs for the change > Specs diff mode.41+42+#### Scenario: Diff mode renders delta view43+- **WHEN** the user selects "Diff vs current" in the change > Specs tab44+- **THEN** the `SpecDeltaView` component renders instead of the previous side-by-side `SpecContentView` + unified diff layout
A openspec/changes/archive/change-specs-semantic-diff-view/tasks.md +9 -0
A openspec/changes/archive/semantic-spec-diff-tool/.openspec.yaml +2 -0
A openspec/changes/archive/semantic-spec-diff-tool/design.md +114 -0
@@ -0,0 +1,114 @@
1+# Design: Semantic Spec Diff Tool2+3+## Context4+5+The frontend `parseSpec` in `dashboard/dist/index.js` (lines 445–495) parses spec6+markdown into `{ title, purpose, requirements: [{ name, description, scenarios: [{ name, steps }] }] }`.7+This logic needs to exist in Python so the backend (`plugin_api.py`) and agent tools8+(`tools.py`) can use it. The diff itself is currently `difflib.unified_diff` — line-level,9+not semantic.10+11+## Parser: `_parse_spec(md)`12+13+Mirror the JS `parseSpec` exactly so the dashboard and tool produce identical structure.14+15+State machine: `top → purpose → reqDesc → scenario`16+17+| Line pattern | Action |18+|---|---|19+| `# Title` (not `## `) | Set title, state → `top` |20+| `## Purpose` | state → `purpose` |21+| `## Requirements` | state → `top` (skip header) |22+| `### Requirement: Name` | Push new requirement, state → `reqDesc` |23+| `#### Scenario: Name` | Push new scenario to current requirement, state → `scenario` |24+| (in purpose) | Append to `purpose` |25+| (in reqDesc) | Append to current requirement `description` |26+| (in scenario) | Parse `**GIVEN**`/`**WHEN**`/`**THEN**` bullets into `steps` |27+28+Return: `{ title, purpose, requirements: [{ name, description, scenarios: [{ name, steps: [{ type, text }] }] }] }`29+30+## Diff: `_semantic_spec_diff(before_md, after_md)`31+32+1. Parse both specs.33+2. Build dicts keyed by requirement name for O(1) lookup.34+3. Classify each requirement:35+ - In after only → `added`36+ - In before only → `removed`37+ - In both, same description + same scenarios → `unchanged`38+ - In both, different → `modified`39+4. For modified requirements, diff scenarios the same way (by scenario name).40+5. Return:41+42+```python43+{44+ "status": "added" | "modified" | "deleted" | "unchanged",45+ "requirements": {46+ "added": [{ name, description, scenarios }],47+ "modified": [{48+ "name",49+ "before": { description, scenarios },50+ "after": { description, scenarios },51+ "scenarios_added": [...],52+ "scenarios_modified": [...],53+ "scenarios_removed": [...],54+ }],55+ "removed": [{ name, description, scenarios }],56+ "unchanged": ["req name", ...],57+ },58+}59+```60+61+Overall status: `added` if before is None/empty, `deleted` if after is None/empty,62+`unchanged` if all requirements unchanged, else `modified`.63+64+## Tool: `openspec_spec_diff`65+66+### Parameters67+68+| Param | Required | Description |69+|---|---|---|70+| `workdir` | yes (or `identifier`) | Project directory |71+| `spec` | yes | Spec name (e.g. `agent-tools`) |72+| `change` | no | Change id — when provided, diff change spec vs baseline |73+74+### Resolution75+76+- `change` provided:77+ - Change spec: `openspec/changes/<change>/specs/<spec>/spec.md`78+ - Baseline: `openspec/specs/<spec>/spec.md`79+ - If change spec missing → `ok: false, error: "change spec not found"`80+ - If baseline missing → status `added`, all requirements in `added`81+- `change` omitted:82+ - Worktree spec: `openspec/specs/<spec>/spec.md` (current content)83+ - Baseline: `git show HEAD:openspec/specs/<spec>/spec.md`84+ - If git unavailable → `ok: false, error: "git required for HEAD comparison"`85+86+### Output87+88+```json89+{90+ "ok": true,91+ "spec": "agent-tools",92+ "change": "add-idea-tools",93+ "status": "modified",94+ "baseline_exists": true,95+ "requirements": { "added": [...], "modified": [...], "removed": [...], "unchanged": [...] },96+ "line_diff": "--- before/...\n+++ after/...\n@@ ..."97+}98+```99+100+## Registration101+102+Filesystem-backed — always available, no `check_fn` gating. Same pattern as103+`openspec_context`, `openspec_idea_create`, etc.104+105+## Sharing with the dashboard106+107+The `_parse_spec` and `_semantic_spec_diff` functions live in `tools.py`. The108+dashboard backend (`plugin_api.py`) imports them the same way it imports109+`registry.py` — via `importlib.util.spec_from_file_location`. This avoids110+circular imports and keeps the plugin self-contained.111+112+Alternatively, extract into a `spec_parser.py` module that both `tools.py` and113+`plugin_api.py` import directly. Preferred — cleaner separation, testable in114+isolation.
A openspec/changes/archive/semantic-spec-diff-tool/proposal.md +23 -0
@@ -0,0 +1,23 @@
1+## Why2+3+The frontend `parseSpec` function in `dashboard/dist/index.js` parses OpenSpec spec markdown into structured data (title, purpose, requirements with scenarios). This logic exists only in JavaScript — the Python backend and agent tools can't use it. Meanwhile, the dashboard's spec diff is line-level unified diff (`difflib.unified_diff`), which shows what text changed but not what requirements or scenarios changed. Agents have no way to get a structured delta at all — they must read two full specs and mentally diff them.4+5+## What Changes6+7+Extract the spec parser into a shared Python function, build a semantic diff function on top of it, and expose it as a new `openspec_spec_diff` agent tool. The tool returns a structured JSON delta at the requirement/scenario level (added, modified, removed, unchanged) plus a unified line diff as a fallback field. Filesystem-backed — works without the OpenSpec CLI binary, same as the idea and change lifecycle tools.8+9+## Capabilities10+11+### New Capabilities12+- `semantic-spec-diff`: Python spec parser, semantic diff function, and `openspec_spec_diff` agent tool for comparing specs at the requirement/scenario level13+14+### Modified Capabilities15+- `agent-tools`: New `openspec_spec_diff` tool added to the plugin's registered tools, filesystem-backed (always available, no CLI gating)16+17+## Impact18+19+- `tools.py` — new `_parse_spec()`, `_semantic_spec_diff()`, and `openspec_spec_diff()` functions20+- `schemas.py` — new `OPENSPEC_SPEC_DIFF` schema21+- `__init__.py` — register the new tool (filesystem-backed, no `check_fn`)22+- `plugin.yaml` — add `openspec_spec_diff` to `provides_tools`23+- `tests/` — new test file for parser, diff, and tool handler
A openspec/changes/archive/semantic-spec-diff-tool/specs/agent-tools/spec.md +26 -0
@@ -0,0 +1,26 @@
1+# Agent Tools Delta — Semantic Spec Diff2+3+## MODIFIED Requirements4+5+### Requirement: Artifact listing and inspection6+The plugin SHALL provide tools that map directly to OpenSpec list/show/status/instructions workflows while smoothing known CLI rough edges that would otherwise block fresh-repository agent workflows and SHALL expose a filesystem-backed semantic spec diff tool for comparing specs at the requirement and scenario level without the CLI.7+8+#### Scenario: List changes or specs9+- **WHEN** an agent calls `openspec_list` with kind `changes` or `specs`10+- **THEN** the tool invokes the OpenSpec CLI JSON list command for that kind and respects supported sort options11+12+#### Scenario: Show a change or spec13+- **WHEN** an agent calls `openspec_show` with a target name and optional flags14+- **THEN** the tool invokes the OpenSpec CLI JSON show command with the requested type, delta, requirements, scenario, or requirement filters15+16+#### Scenario: Validate artifacts17+- **WHEN** an agent calls `openspec_validate`18+- **THEN** the tool invokes the OpenSpec CLI validation command with strict JSON non-interactive output and the requested target or scope19+20+#### Scenario: Retrieve implementation instructions21+- **WHEN** an agent calls `openspec_instructions`22+- **THEN** the tool returns OpenSpec's enriched instructions for the requested artifact, schema, and optional change23+24+#### Scenario: Semantic spec diff25+- **WHEN** an agent calls `openspec_spec_diff` with `workdir`, `spec`, and optional `change`26+- **THEN** the tool returns a structured semantic delta at the requirement and scenario level plus a unified line diff fallback, without requiring the OpenSpec CLI binary
A openspec/changes/archive/semantic-spec-diff-tool/specs/semantic-spec-diff/spec.md +84 -0
@@ -0,0 +1,84 @@
1+# Semantic Spec Diff Delta2+3+## ADDED Requirements4+5+### Requirement: Shared spec parser6+The plugin SHALL provide a `_parse_spec(md)` Python function that parses OpenSpec spec markdown into structured data: title, purpose, and a list of requirements, each with a name, description, and list of scenarios, each scenario with a name and steps.7+8+#### Scenario: Parse a well-formed spec9+- **GIVEN** a spec markdown file with `# Title`, `## Purpose`, `### Requirement:` headers, and `#### Scenario:` headers with `**GIVEN**`/`**WHEN**`/`**THEN**` steps10+- **WHEN** `_parse_spec` is called with the markdown content11+- **THEN** it returns a dict with `title`, `purpose`, and `requirements` list, where each requirement has `name`, `description`, and `scenarios`, and each scenario has `name` and `steps`12+13+#### Scenario: Parse a spec with no requirements14+- **GIVEN** a spec markdown file with only a title and purpose, no `### Requirement:` headers15+- **WHEN** `_parse_spec` is called with the markdown content16+- **THEN** it returns a dict with `title`, `purpose`, and an empty `requirements` list17+18+#### Scenario: Parse empty or None input19+- **WHEN** `_parse_spec` is called with an empty string or None20+- **THEN** it returns a dict with empty `title`, empty `purpose`, and empty `requirements` list21+22+### Requirement: Semantic spec diff function23+The plugin SHALL provide a `_semantic_spec_diff(before_md, after_md)` function that compares two parsed specs and returns a structured delta at the requirement and scenario level.24+25+#### Scenario: Added requirement26+- **GIVEN** the after spec contains a requirement that the before spec does not27+- **WHEN** `_semantic_spec_diff` is called28+- **THEN** the requirement appears in `requirements.added` with its full description and scenarios29+30+#### Scenario: Removed requirement31+- **GIVEN** the before spec contains a requirement that the after spec does not32+- **WHEN** `_semantic_spec_diff` is called33+- **THEN** the requirement appears in `requirements.removed` with its full description and scenarios34+35+#### Scenario: Modified requirement description36+- **GIVEN** a requirement exists in both specs with the same name but different descriptions37+- **WHEN** `_semantic_spec_diff` is called38+- **THEN** the requirement appears in `requirements.modified` with `before` and `after` description fields39+40+#### Scenario: Scenario-level delta in modified requirement41+- **GIVEN** a requirement exists in both specs with the same name but different scenarios42+- **WHEN** `_semantic_spec_diff` is called43+- **THEN** the modified entry includes `scenarios_added`, `scenarios_modified`, and `scenarios_removed` lists44+45+#### Scenario: Unchanged requirement46+- **GIVEN** a requirement exists in both specs with identical descriptions and scenarios47+- **WHEN** `_semantic_spec_diff` is called48+- **THEN** the requirement name appears in `requirements.unchanged`49+50+#### Scenario: No baseline exists51+- **GIVEN** the before spec is None or empty (new spec being added)52+- **WHEN** `_semantic_spec_diff` is called53+- **THEN** all requirements appear in `requirements.added` and the overall status is `added`54+55+### Requirement: openspec_spec_diff agent tool56+The plugin SHALL provide an `openspec_spec_diff` tool that compares a change's delta spec against its baseline, or a worktree spec against its HEAD version, and returns the structured semantic delta plus a unified line diff fallback.57+58+#### Scenario: Diff a change spec against baseline59+- **GIVEN** a change contains a delta spec under `openspec/changes/<change>/specs/<spec>/spec.md` and a baseline spec exists at `openspec/specs/<spec>/spec.md`60+- **WHEN** an agent calls `openspec_spec_diff` with `workdir`, `spec`, and `change`61+- **THEN** the tool returns the semantic delta between the change spec and the baseline spec, including overall status, requirements delta, and a `line_diff` unified diff field62+63+#### Scenario: Diff a worktree spec against HEAD64+- **GIVEN** a baseline spec exists at `openspec/specs/<spec>/spec.md` and has been modified in the worktree65+- **WHEN** an agent calls `openspec_spec_diff` with `workdir` and `spec` (no `change`)66+- **THEN** the tool returns the semantic delta between the HEAD version (via git) and the worktree version67+68+#### Scenario: New spec with no baseline69+- **GIVEN** a change contains a delta spec but no baseline spec exists70+- **WHEN** an agent calls `openspec_spec_diff` with `workdir`, `spec`, and `change`71+- **THEN** the tool returns status `added` with all requirements in `requirements.added` and an empty `line_diff`72+73+#### Scenario: Missing change spec74+- **WHEN** an agent calls `openspec_spec_diff` with a `change` that does not contain the requested `spec`75+- **THEN** the tool returns `ok: false` with an error explaining the change spec was not found76+77+#### Scenario: Filesystem-backed availability78+- **GIVEN** no OpenSpec CLI binary is available79+- **WHEN** Hermes registers plugin tools80+- **THEN** `openspec_spec_diff` is still available because it reads spec files directly from the filesystem81+82+#### Scenario: Git unavailable for HEAD comparison83+- **GIVEN** `openspec_spec_diff` is called without `change` (HEAD comparison mode) and git is unavailable84+- **THEN** the tool returns `ok: false` with an error explaining git is required for worktree-vs-HEAD comparison
A openspec/changes/archive/semantic-spec-diff-tool/tasks.md +9 -0
A openspec/changes/archive/spec-browser-structured-rendering/.openspec.yaml +2 -0
A openspec/changes/archive/spec-browser-structured-rendering/proposal.md +25 -0
@@ -0,0 +1,25 @@
1+## Why2+3+The spec browser uses raw `Markdown` rendering in dirty and refs diff modes — two unstructured markdown blobs side-by-side. This is inconsistent with the change > Specs tab, which uses `SpecContentView` (structured requirement/scenario rendering). The spec list in dirty/refs mode also shows only a `changed` badge with no indication of what changed at the requirement level, so maintainers must click into each spec to see the delta.4+5+## What Changes6+7+Switch the spec browser's dirty and refs modes from raw `Markdown` to `SpecContentView` for before/after rendering. Add a compact semantic summary to spec list items in dirty/refs mode — requirement-level added/modified/removed counts computed from before/after content — so maintainers see "+2 req, ~1, -0" at a glance before clicking in.8+9+## Capabilities10+11+### New Capabilities12+- `spec-browser-structured-rendering`: Structured rendering in spec browser diff modes and compact semantic summary in the spec list13+14+### Modified Capabilities15+- `spec-browser`: Requirements modified to use structured rendering in diff modes and include semantic summary in list entries16+17+## Impact18+19+- `dashboard/dist/index.js` — `SpecsView` uses `SpecContentView` instead of `Markdown` in dirty/refs modes; spec list items show semantic summary20+- `dashboard/plugin_api.py` — `_spec_browser` computes `semantic_summary` (added/modified/removed counts) per spec in dirty and refs modes21+- `openspec/specs/spec-browser/spec.md` — delta spec adding structured rendering and semantic summary requirements22+23+### Dependencies24+25+- Requires `semantic-spec-diff-tool` — the shared `_parse_spec` and `_semantic_spec_diff` functions for computing summaries
A openspec/changes/archive/spec-browser-structured-rendering/specs/spec-browser-structured-rendering/spec.md +20 -0
A openspec/changes/archive/spec-browser-structured-rendering/specs/spec-browser/spec.md +40 -0
@@ -0,0 +1,40 @@
1+# Spec Browser Delta — Structured Rendering2+3+## MODIFIED Requirements4+5+### Requirement: Dirty worktree diff mode6+The dashboard API SHALL compare a git ref to current worktree specs when dirty mode is requested, and the UI SHALL render diffs using structured spec content views.7+8+#### Scenario: Dirty diff defaults9+- **WHEN** dirty mode is requested without an explicit before ref10+- **THEN** the API compares `HEAD` to the working tree11+12+#### Scenario: Dirty diff filters unchanged specs13+- **GIVEN** some specs match the selected before ref exactly14+- **WHEN** dirty mode is requested15+- **THEN** unchanged specs are omitted from the returned files list16+17+#### Scenario: Dirty diff entry18+- **GIVEN** a spec was added, modified, or deleted relative to the before ref19+- **WHEN** dirty mode is requested20+- **THEN** the API returns before content, after content, status, changed flag, unified diff, and available timestamps for that spec21+22+#### Scenario: Dirty mode structured rendering23+- **WHEN** the spec browser is in dirty mode and a changed spec is selected24+- **THEN** the before and after panels render using `SpecContentView` with structured requirement and scenario sections instead of raw markdown25+26+### Requirement: Ref-to-ref diff mode27+The dashboard API SHALL compare specs between two explicit git refs when both before and after refs are supplied, and the UI SHALL render diffs using structured spec content views.28+29+#### Scenario: Valid ref comparison30+- **GIVEN** both before and after refs are supplied31+- **WHEN** the dashboard requests the spec browser32+- **THEN** the API returns mode `refs`, both labels, all spec paths present in either ref, per-spec status, before content, after content, changed flag, and unified diff when changed33+34+#### Scenario: Incomplete ref comparison35+- **WHEN** only one of before or after is supplied outside dirty mode36+- **THEN** the API rejects the request with a 400-level error explaining that both refs are required37+38+#### Scenario: Refs mode structured rendering39+- **WHEN** the spec browser is in refs mode and a changed spec is selected40+- **THEN** the before and after panels render using `SpecContentView` with structured requirement and scenario sections instead of raw markdown
A openspec/changes/archive/spec-browser-structured-rendering/tasks.md +6 -0
M plugin.yaml +1 -0
M schemas.py +27 -0
A spec_parser.py +246 -0
@@ -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)
A tests/test_spec_diff.py +331 -0
@@ -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+@pytest.fixture203+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"]
M tools.py +112 -0
@@ -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