clone

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

Merge branch 'dev'

dami etoile committed 3cf148b
commit
3cf148b1fcc8ee7ebaff307af88cc27869fc4cfd
parent
7ca714feaeaac02e4b2306b9e481da989e9883fb
parent
bc6a1cc62a582f701515b262ce2e132bea61e3fb
author
dami etoile <dami.etoile@gmail.com>
7 files changed, +303 -30
ACONTRIBUTING.md86+++++++++++++++++++++++
ALICENSE21++++++
MREADME.md8+-
Mdashboard/dist/index.js110+++++++++++++++++++++++++-----
Mdashboard/dist/style.css2+
Mdashboard/plugin_api.py95++++++++++++++++++++++++--
Arequirements.txt11+++
A CONTRIBUTING.md +86 -0
@@ -0,0 +1,86 @@
1+# Contributing to hermes-openspec2+3+Thanks for your interest in improving this plugin. This is a small, focused4+project — the bar is a clean diff that fixes a real bug or adds a feature5+that fits the plugin's scope (OpenSpec integration for Hermes Agent).6+7+## Reporting issues8+9+1. **Check existing issues** first to avoid duplicates.10+2. **Include a reproduction**: the exact steps, what you expected, and what11+ happened. If it's a dashboard issue, note your browser and Hermes version.12+3. **Include the error**: paste the full traceback or the HTTP response. A13+ screenshot of the dashboard is fine for UI bugs.14+4. **Note your environment**: OS, Hermes version, OpenSpec CLI version15+ (`openspec --version`), and whether the `openspec` binary is on `PATH`.16+17+## Submitting pull requests18+19+### Before you start20+21+If the change is non-trivial (new endpoint, new UI component, schema change),22+open an issue or draft PR first to scope it. This avoids wasted work on23+something that doesn't fit the design.24+25+### Branches26+27+- `main` is a blank orphan branch — do not push to it.28+- All development happens on `dev`. Branch from `dev`, PR into `dev`.29+30+### What we look for31+32+- **Fix real bugs.** Reproduce the symptom on `dev`, point to the exact line,33+ and fix the whole bug class — not just the one site you hit.34+- **Keep the core narrow.** This plugin extends Hermes at the edge. Don't add35+ hooks, callbacks, or extension points with no concrete consumer.36+- **No new env vars for non-secret config.** Behavioral settings go in the37+ Hermes config, not `.env`. Only credentials belong in `.env`.38+- **Match existing style.** The frontend is a hand-written IIFE in39+ `dashboard/dist/index.js` — no build step, no framework, no JSX. The backend40+ is plain FastAPI in `dashboard/plugin_api.py`. Follow the patterns already41+ there.42+- **Don't break the registry DB.** `registry.py` manages a SQLite DB at43+ `~/.hermes/openspec.db`. Schema changes must be backward-compatible or44+ include a migration. Never delete user data.45+46+### Testing47+48+There's no automated test suite yet. Before submitting:49+50+1. Install your changes: copy the plugin dir to `~/.hermes/plugins/openspec/`51+ (only `openspec.db` persists across updates — everything else is52+ overwritten).53+2. Restart Hermes and verify the dashboard tab loads.54+3. Exercise the affected code path manually — register a source, browse55+ changes/specs, trigger the bug you fixed.56+4. If you added a backend route, confirm it returns sane responses for both57+ the happy path and error cases (missing source, invalid path, etc.).58+59+### Commit messages60+61+Follow the existing convention:62+63+```64+type: short description65+66+Optional body explaining why, not what.67+```68+69+Types: `fix`, `feat`, `docs`, `refactor`, `chore`.70+71+### Deploying for development72+73+```bash74+# Copy from repo to installed plugin location75+cp -r ~/repos/hermes-openspec/* ~/.hermes/plugins/openspec/76+77+# If backend routes changed, restart Hermes to remount them78+# Frontend-only changes can be hot-reloaded via dashboard rescan79+```80+81+The `openspec.db` database lives at `~/.hermes/openspec.db` (outside the82+plugin directory) and survives plugin updates. Do not include it in PRs.83+84+## Questions85+86+Open an issue with the `question` label.
A LICENSE +21 -0
M README.md +4 -4
@@ -29,9 +29,10 @@ This plugin closes both gaps:
2929 **Dashboard tab** (`/openspec`):3030 3131 - Register repos by path — each gets a vanity name and stable tokens (`name/os_a1b2c3`).32-- **Changes view** — Kanban-style board: ideas → draft → todo → in_progress → done → archived. Click any change to read its proposal, tasks, design, and spec deltas.32+- **Changes view** — Kanban-style board: ideas → draft → todo → in_progress → done → archived. Click any change to read its proposal, tasks, design, and spec deltas. Spec deltas render side-by-side (current vs proposed) with structured requirement/scenario parsing.3333 - **Specs view** — browse current specs in the worktree, sorted alphabetically or by last git commit date. Compare against HEAD (dirty mode) or arbitrary git refs (before/after).34-- Deep-linking via URL hash (`#project-name/token`).34+- **Source initialization** — register a repo before it has an `openspec/` directory, then initialize it from the dashboard (runs `openspec init --tools none`, or creates the directory structure as a fallback).35+- Deep-linking via URL hash (`#project-name/token#anchor` — the second `#` selects a tab: proposal, tasks, design, or specs).3536 3637 ![Change board](screenshots/board.png)3738
@@ -61,7 +62,7 @@ To register a repo in the dashboard, open the OpenSpec tab and click **Add sourc
6162 openspec_context(identifier="/path/to/your/repo")6263 ```6364 64-The repo needs an `openspec/` directory. Once registered, the dashboard shows changes and specs live from the filesystem.65+The repo doesn't need an `openspec/` directory upfront — if it's missing, the dashboard shows an **Initialize** button that creates it. Once initialized, the dashboard shows changes and specs live from the filesystem.6566 6667 ## Verify6768
@@ -116,4 +117,3 @@ hermes-openspec/
116117 | `dashboard/plugin_api.py` | All backend API routes and the spec-browser logic |117118 | `dashboard/manifest.json` | Tab registration, entry points |118119 | `dashboard/dist/index.js` | Frontend: board, specs view, source dialogs, deep-linking |119-```
M dashboard/dist/index.js +91 -19
@@ -46,6 +46,9 @@
4646 function removeSource(id) {4747 return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "DELETE" });4848 }49+ function initSource(id) {50+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id) + "/init", { method: "POST" });51+ }4952 function getChange(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/changes/" + encodeURIComponent(name)); }5053 function getIdea(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/ideas/" + encodeURIComponent(name)); }5154 function getSpec(sid, path) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/specs?path=" + encodeURIComponent(path)); }
@@ -298,7 +301,7 @@
298301 299302 // ── Change / Idea detail dialog ──────────────────────────────────────300303 function DetailDialog(_ref8) {301- var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose;304+ var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose, anchor = _ref8.anchor, onTabChange = _ref8.onTabChange;302305 var _d = useState(null), _l = useState(true), _e2 = useState("");303306 var detail = _d[0], setDetail = _d[1], loading = _l[0], setLoading = _l[1], err = _e2[0], setErr = _e2[1];304307 var isIdea = item.status === "ideas";
@@ -312,7 +315,7 @@
312315 loading ? h("p", { className: "os-empty" }, "Loading…") :313316 err ? h("p", { className: "os-err" }, err) :314317 isIdea ? h(Markdown, { md: detail && detail.content }) :315- detail ? h(ChangeDetailTabs, { detail: detail }) : null318+ detail ? h(ChangeDetailTabs, { detail: detail, anchor: anchor, onTabChange: onTabChange }) : null316319 );317320 }318321 // ── Task parser ─────────────────────────────────────────────────────
@@ -343,18 +346,59 @@
343346 return { sections: sections, total: total, done: done };344347 }345348 349+ function ChangeSpecsView(_ref11) {350+ var specs = _ref11.specs;351+ var _m = useState("proposed");352+ var mode = _m[0], setMode = _m[1];353+ return h("div", null,354+ h("div", { className: "os-specs-modes", style: { marginBottom: "0.5rem" } },355+ h(Button, { variant: mode === "proposed" ? "default" : "outline", size: "sm", onClick: function () { setMode("proposed"); } }, "Proposed"),356+ h(Button, { variant: mode === "diff" ? "default" : "outline", size: "sm", onClick: function () { setMode("diff"); } }, "Diff vs current")357+ ),358+ specs.map(function (s) {359+ return h("div", { key: s.path, className: "os-spec-block" },360+ h("div", { className: "os-specs-detail-head" },361+ h("span", { className: "os-specs-detail-path" }, s.path),362+ s.status && s.status !== "unchanged" && s.status !== "missing"363+ ? h(Badge, { className: "os-spec-status os-status-" + s.status }, s.status)364+ : null365+ ),366+ mode === "diff" && s.diff367+ ? 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)373+ )374+ : h(SpecContentView, { md: s.content })375+ );376+ })377+ );378+ }379+346380 function ChangeDetailTabs(_ref9) {347- var detail = _ref9.detail;381+ var detail = _ref9.detail, anchor = _ref9.anchor, onTabChange = _ref9.onTabChange;348382 var tabs = [];349383 if (detail.proposal) tabs.push(["proposal", "Proposal"]);350384 if (detail.tasks) tabs.push(["tasks", "Tasks"]);351385 if (detail.design) tabs.push(["design", "Design"]);352386 if (detail.specs && detail.specs.length) tabs.push(["specs", "Specs (" + detail.specs.length + ")"]);353387 if (!tabs.length) return h("p", { className: "os-empty" }, "No documents found.");354- var _tab = useState(tabs[0][0]);388+ var validAnchor = anchor && tabs.some(function (t) { return t[0] === anchor; });389+ var initialTab = validAnchor ? anchor : tabs[0][0];390+ var _tab = useState(initialTab);355391 var activeTab = _tab[0], setActiveTab = _tab[1];392+ // Sync from hash when anchor changes (browser back/forward).393+ useEffect(function () {394+ if (validAnchor && anchor !== activeTab) setActiveTab(anchor);395+ }, [anchor]);396+ function switchTab(t) {397+ setActiveTab(t);398+ if (onTabChange) onTabChange(t);399+ }356400 var content = activeTab === "specs"357- ? detail.specs.map(function (s) { return h("div", { key: s.path, className: "os-spec-block" }, h("h4", null, s.path), h(Markdown, { md: s.content })); })401+ ? h(ChangeSpecsView, { specs: detail.specs })358402 : activeTab === "tasks"359403 ? h(TasksView, { md: detail[activeTab], stats: detail.taskStats })360404 : h(Markdown, { md: detail[activeTab] });
@@ -362,7 +406,7 @@
362406 h("div", { className: "os-tabs-bar" }, tabs.map(function (t) {363407 return h("button", {364408 key: t[0], className: cn("os-tab-btn", activeTab === t[0] && "os-tab-btn-active"),365- onClick: function () { setActiveTab(t[0]); }409+ onClick: function () { switchTab(t[0]); }366410 }, t[1]);367411 })),368412 h("div", { className: "os-tab-panel" }, content)
@@ -453,7 +497,7 @@
453497 function SpecContentView(_ref) {454498 var md = _ref.md;455499 var spec = useMemo(function () { return parseSpec(md); }, [md]);456- if (!spec.title && !spec.purpose && !spec.requirements.length) {500+ if (!spec.requirements.length) {457501 return h(Markdown, { md: md });458502 }459503 return h("div", { className: "os-spec-content" },
@@ -514,7 +558,7 @@
514558 return arr;515559 }516560 function SpecsView(_ref10) {517- var source = _ref10.source, anchorToken = _ref10.anchorToken;561+ var source = _ref10.source, anchorToken = _ref10.anchorToken, onSelectToken = _ref10.onSelectToken;518562 var _m = useState("current"), _b2 = useState(null), _l2 = useState(true), _e3 = useState("");519563 var _sel = useState(null), _bf = useState(""), _af = useState("");520564 var _sm = useState("alpha");
@@ -544,6 +588,12 @@
544588 useEffect(function () {545589 if (firstLoad.current) { firstLoad.current = false; load(mode, before, after); }546590 }, []);591+ // Sync selection when anchor token changes (browser back/forward + hash edits).592+ useEffect(function () {593+ if (!anchorToken || !browser || !browser.files) return;594+ var match = browser.files.find(function (f) { return f.token === anchorToken; });595+ if (match && match.path !== selPath) setSelPath(match.path);596+ }, [anchorToken, browser]);547597 function switchMode(m) {548598 setMode(m);549599 if (m === "refs" && (!before.trim() || !after.trim())) { setBrowser(null); setLoading(false); return; }
@@ -580,7 +630,7 @@
580630 sorted.map(function (f) {581631 return h("button", {582632 key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),583- onClick: function () { setSelPath(f.path); },633+ onClick: function () { setSelPath(f.path); if (onSelectToken && f.token) onSelectToken(f.token); },584634 },585635 h(Icon, { d: ICO.file, size: 13 }),586636 h("span", { className: "os-spec-item-path" }, f.path),
@@ -638,15 +688,28 @@
638688 })(React.Component);639689 640690 // ── Hash deep-linking ────────────────────────────────────────────────691+ // Format: #project/token#anchor692+ // - project/token is the route (which source + which change/spec)693+ // - anchor is the sub-view (proposal/tasks/design/specs tab)694+ // The second # is a literal char in the hash string; we split on it.641695 function parseHash() {642- var h2 = window.location.hash.replace(/^#/, "").trim();643- if (!h2) return { project: null, token: null };644- var parts = h2.split("/");645- return { project: decodeURIComponent(parts[0]) || null, token: parts[1] ? decodeURIComponent(parts[1]) : null };696+ var raw = window.location.hash.replace(/^#/, "").trim();697+ if (!raw) return { project: null, token: null, anchor: null };698+ var route, anchor;699+ var hi = raw.indexOf("#");700+ if (hi >= 0) { route = raw.slice(0, hi); anchor = raw.slice(hi + 1); }701+ else { route = raw; anchor = null; }702+ var parts = route.split("/");703+ return {704+ project: decodeURIComponent(parts[0]) || null,705+ token: parts[1] ? decodeURIComponent(parts[1]) : null,706+ anchor: anchor || null,707+ };646708 }647- function setHash(project, token) {709+ function setHash(project, token, anchor) {648710 var v = project ? (token ? project + "/" + token : project) : "";649- if (("#" + v) !== window.location.hash) window.location.hash = v;711+ if (anchor) v += "#" + anchor;712+ if ((window.location.hash || "#") !== "#" + v) window.location.hash = v;650713 }651714 652715 // ── Main page component ──────────────────────────────────────────────
@@ -704,8 +767,10 @@
704767 var active = sources.find(function (s) { return s.id === activeId; });705768 706769 function selectSource(id) { setActiveId(id); var s = sources.find(function (x) { return x.id === id; }); setHash(s ? s.name : null, null); setView("board"); setSelItem(null); }707- function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token); }770+ function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token, null); }708771 function closeItem() { setSelItem(null); if (active) setHash(active.name, null); }772+ function onTabChange(tab) { if (active && selItem && selItem.token) setHash(active.name, selItem.token, tab); }773+ function selectSpec(token) { if (active) setHash(active.name, token, null); }709774 710775 function onAdd() { setDlg({ mode: "add" }); }711776 function onEdit() { if (active) setDlg({ mode: "edit", activeId: active.id, path: active.path, name: active.name }); }
@@ -758,10 +823,17 @@
758823 h(Button, { variant: view === "board" ? "default" : "outline", size: "sm", onClick: function () { setView("board"); } }, "Changes"),759824 h(Button, { variant: view === "specs" ? "default" : "outline", size: "sm", onClick: function () { setView("specs"); } }, "Specs")760825 ) : null,761- active && !active.valid ? h("p", { className: "os-err" }, active.error || "Source path is invalid.") : null,826+ active && !active.valid ? h("div", { className: "os-invalid-state" },827+ h("p", { className: "os-err" }, active.error || "Source path is invalid."),828+ active.error && active.error.toLowerCase().indexOf("openspec") >= 0 ? h(Button, {829+ size: "sm", onClick: function () {830+ initSource(active.id).then(function () { loadSources(true); }).catch(function (e) { setErr(apiErr(e)); });831+ }832+ }, h(Icon, { d: ICO.plus, size: 14 }), " Initialize OpenSpec") : null833+ ) : null,762834 active && active.valid && view === "board" ? h(Board, { source: active, onSelectItem: selectItem }) : null,763- active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token }) : null,764- selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem }) : null,835+ active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token, onSelectToken: selectSpec }) : null,836+ selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem, anchor: anchor.anchor, onTabChange: onTabChange }) : null,765837 dlg ? h(SourceDialog, {766838 mode: dlg.mode, initialPath: dlg.path, initialName: dlg.name, activeId: dlg.activeId,767839 onClose: function () { setDlg(null); }, onSaved: onSaved,
M dashboard/dist/style.css +2 -0
M dashboard/plugin_api.py +88 -7
@@ -30,7 +30,9 @@ from __future__ import annotations
3030 3131 import difflib3232 import importlib.util33+import os3334 import re35+import shutil3436 import subprocess3537 from datetime import datetime, timezone3638 from pathlib import Path
@@ -67,6 +69,19 @@ router = APIRouter()
6769 # Kept here so the plugin has zero core-dependency for filesystem traversal.6870 # ---------------------------------------------------------------------------6971 72+def _find_openspec_bin() -> Optional[str]:73+ """Locate the ``openspec`` CLI binary."""74+ candidates = [75+ os.getenv("OPENSPEC_BIN", "").strip(),76+ shutil.which("openspec") or "",77+ str(Path.home() / ".npm-global" / "bin" / "openspec"),78+ ]79+ for c in candidates:80+ if c and Path(c).exists() and os.access(c, os.X_OK):81+ return c82+ return None83+84+7085 def _resolve_path(raw_path: str) -> Path:7186 """Resolve a user-supplied path string to an absolute Path, rejecting7287 null bytes and unparseable values. Mirrors web_server._fs_path semantics."""
@@ -99,6 +114,24 @@ def _find_git_root(start: Path) -> Optional[Path]:
99114 return None100115 101116 117+def _repo_root(raw_path: str) -> tuple[Path | None, str | None]:118+ """Resolve a raw path to a repo root (git root or the path itself).119+120+ Unlike ``_source_root``, this does NOT require ``openspec/`` to exist —121+ it's used for add/update so sources can be registered before init.122+ """123+ try:124+ target = _resolve_path(raw_path)125+ except HTTPException as exc:126+ return None, str(exc.detail)127+ if not target.exists():128+ return None, "Path does not exist"129+ git_root = _find_git_root(target)130+ if git_root:131+ return git_root, None132+ return target, None133+134+102135 def _source_root(raw_path: str) -> tuple[Path | None, str | None]:103136 """Resolve a raw path to a repo root containing ``openspec/``.104137
@@ -296,7 +329,7 @@ def _scan(root: Path) -> Optional[dict[str, Any]]:
296329 297330 298331 def _source_payload(source: dict[str, Any]) -> dict[str, Any]:299- root, error = _source_root(source["path"])332+ root, error = _repo_root(source["path"])300333 name = source.get("name") or (root.name if root else Path(source["path"]).expanduser().name)301334 base = {302335 "token": source.get("token") or source.get("id"),
@@ -306,6 +339,9 @@ def _source_payload(source: dict[str, Any]) -> dict[str, Any]:
306339 }307340 if root is None:308341 return {**base, "valid": False, "repoRoot": None, "openspec": None, "error": error}342+ # Repo exists — check if openspec/ is initialized.343+ if not (root / "openspec").is_dir():344+ return {**base, "valid": False, "repoRoot": str(root), "openspec": None, "error": "No openspec/ directory found"}309345 return {310346 **base,311347 "valid": True,
@@ -348,12 +384,21 @@ def _change_detail(root: Path, change_name: str) -> Optional[dict[str, Any]]:
348384 specs_root = change_dir / "specs"349385 has_tasks = tasks_path.is_file()350386 stats = _task_stats(tasks_path) if has_tasks else None351- specs: list[dict[str, str]] = []387+ specs: list[dict[str, Any]] = []352388 if specs_root.is_dir():353389 try:354390 for child in sorted(specs_root.rglob("*.md")):355391 rel = child.relative_to(specs_root).as_posix()356- specs.append({"path": rel, "content": _read_doc(child) or ""})392+ after = _read_doc(child) or ""393+ before = _read_worktree_spec(root, rel)394+ status = _spec_status(before, after)395+ specs.append({396+ "path": rel,397+ "content": after,398+ "before": before,399+ "status": status,400+ "diff": _spec_diff(before, after, rel) if status not in ("unchanged", "missing") else "",401+ })357402 except OSError:358403 pass359404 return {
@@ -695,9 +740,9 @@ def add_source(body: _SourceCreate):
695740 raw_path = str(body.path or "").strip()696741 if not raw_path:697742 raise HTTPException(status_code=400, detail="path is required")698- root, error = _source_root(raw_path)743+ root, error = _repo_root(raw_path)699744 if root is None:700- raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")745+ raise HTTPException(status_code=400, detail=error or "Invalid path")701746 try:702747 source = _registry.add_source(raw_path, str(body.name or "").strip())703748 except ValueError:
@@ -721,9 +766,9 @@ def update_source(source_id: str, body: _SourceCreate):
721766 raw_path = str(body.path or "").strip()722767 if not raw_path:723768 raise HTTPException(status_code=400, detail="path is required")724- root, error = _source_root(raw_path)769+ root, error = _repo_root(raw_path)725770 if root is None:726- raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")771+ raise HTTPException(status_code=400, detail=error or "Invalid path")727772 try:728773 source = _registry.update_source(source_id, path=raw_path, name=str(body.name or "").strip())729774 except ValueError:
@@ -733,6 +778,42 @@ def update_source(source_id: str, body: _SourceCreate):
733778 return {"ok": True, "source": _source_payload(source)}734779 735780 781+@router.post("/sources/{source_id}/init")782+def init_source(source_id: str):783+ if _registry is None:784+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")785+ source = _registry.get_source(source_id)786+ if source is None:787+ raise HTTPException(status_code=404, detail="Source not found")788+ root, error = _repo_root(source["path"])789+ if root is None:790+ raise HTTPException(status_code=400, detail=error or "Invalid path")791+ if (root / "openspec").is_dir():792+ return {"ok": True, "message": "Already initialized", "source": _source_payload(source)}793+ # Try `openspec init <path> --tools none` first.794+ exe = _find_openspec_bin()795+ if exe:796+ try:797+ proc = subprocess.run(798+ [exe, "init", str(root), "--tools", "none"],799+ capture_output=True, text=True, timeout=30, check=False,800+ )801+ if proc.returncode != 0:802+ detail = (proc.stderr or proc.stdout or "").strip()803+ raise HTTPException(status_code=500, detail=f"openspec init failed: {detail}")804+ except subprocess.TimeoutExpired:805+ raise HTTPException(status_code=504, detail="openspec init timed out")806+ else:807+ # Fallback: create the minimal directory structure.808+ try:809+ (root / "openspec" / "changes").mkdir(parents=True, exist_ok=True)810+ (root / "openspec" / "specs").mkdir(parents=True, exist_ok=True)811+ (root / "openspec" / "ideas").mkdir(parents=True, exist_ok=True)812+ except OSError as exc:813+ raise HTTPException(status_code=500, detail=f"Failed to create openspec/ structure: {exc}")814+ return {"ok": True, "message": "OpenSpec initialized", "source": _source_payload(source)}815+816+736817 @router.get("/sources/{source_id}/changes/{change_name}")737818 def change_detail(source_id: str, change_name: str):738819 _source, root = _resolve_source(source_id)
A requirements.txt +11 -0