- commit
- c85eef806594294b1a46d0a9515687f6cf6fe24f
- author
- dami etoile <dami.etoile@gmail.com>
| A | .gitignore | 4 | + |
| A | __init__.py | 40 | ++ |
| A | dashboard/ | 605 | ++++++++++++++++++++++++ |
| A | dashboard/ | 126 | +++++ |
| A | dashboard/ | 14 | + |
| A | dashboard/ | 755 | ++++++++++++++++++++++++++++++ |
| A | plugin.yaml | 10 | + |
| A | registry.py | 236 | +++++++++ |
| A | schemas.py | 163 | ++++++ |
| A | tools.py | 350 | ++++++++++++++ |
@@ -0,0 +1,40 @@
1+"""OpenSpec plugin — registers CLI-backed spec workflow tools."""2+3+from __future__ import annotations4+5+from . import schemas6+from . import tools7+8+_TOOLS = (9+ ("openspec_list", schemas.OPENSPEC_LIST, tools.openspec_list, "📋"),10+ ("openspec_show", schemas.OPENSPEC_SHOW, tools.openspec_show, "📖"),11+ ("openspec_validate", schemas.OPENSPEC_VALIDATE, tools.openspec_validate, "✅"),12+ ("openspec_status", schemas.OPENSPEC_STATUS, tools.openspec_status, "📊"),13+ ("openspec_instructions", schemas.OPENSPEC_INSTRUCTIONS, tools.openspec_instructions, "🧭"),14+)15+16+17+def _check_openspec_available() -> bool:18+ return tools._openspec_bin() is not None19+20+21+def register(ctx) -> None:22+ # openspec_context only reads the registry DB + files on disk, so it is23+ # available even when the openspec CLI binary is not installed. It is the24+ # entry point for resolving copyable 'os_' identifiers.25+ ctx.register_tool(26+ name="openspec_context",27+ toolset="openspec",28+ schema=schemas.OPENSPEC_CONTEXT,29+ handler=tools.openspec_context,30+ emoji="🔖",31+ )32+ for name, schema, handler, emoji in _TOOLS:33+ ctx.register_tool(34+ name=name,35+ toolset="openspec",36+ schema=schema,37+ handler=handler,38+ check_fn=_check_openspec_available,39+ emoji=emoji,40+ )@@ -0,0 +1,605 @@
1+/**2+ * Hermes OpenSpec — Dashboard Plugin3+ *4+ * Spec-driven development board backed by the plugin's own backend at5+ * /api/plugins/openspec/. Register repos with an openspec/ folder, drill into6+ * change proposals/tasks/designs/specs, and browse current specs or branch7+ * diffs.8+ *9+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +10+ * shadcn primitives. Mirrors the kanban dashboard plugin pattern.11+ */12+(function () {13+ "use strict";14+15+ var SDK = window.__HERMES_PLUGIN_SDK__;16+ if (!SDK) return;17+ var React = SDK.React;18+ var h = React.createElement;19+ var C = SDK.components;20+ var Button = C.Button, Card = C.Card, CardContent = C.CardContent, Badge = C.Badge;21+ var Input = C.Input, Label = C.Label, Select = C.Select, SelectOption = C.SelectOption;22+ var Separator = C.Separator;23+ var useState = SDK.hooks.useState, useEffect = SDK.hooks.useEffect;24+ var useCallback = SDK.hooks.useCallback, useMemo = SDK.hooks.useMemo, useRef = SDK.hooks.useRef;25+ var cn = SDK.utils.cn;26+ var fetchJSON = SDK.fetchJSON;27+28+ var BASE = "/api/plugins/openspec";29+30+ // ── API helpers ──────────────────────────────────────────────────────31+ function apiErr(err) {32+ var raw = err && err.message ? String(err.message) : String(err || "");33+ var m = raw.match(/^(\d{3}):\s*([\s\S]*)$/);34+ var body = m ? m[2] : raw;35+ try { var p = JSON.parse(body); if (p && typeof p.detail === "string") return p.detail; } catch (e) {}36+ return body || raw;37+ }38+ function getSources() { return fetchJSON(BASE + "/sources"); }39+ function addSource(path, name) {40+ return fetchJSON(BASE + "/sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });41+ }42+ function updateSource(id, path, name) {43+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });44+ }45+ function removeSource(id) {46+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "DELETE" });47+ }48+ function getChange(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/changes/" + encodeURIComponent(name)); }49+ function getIdea(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/ideas/" + encodeURIComponent(name)); }50+ function getSpec(sid, path) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/specs?path=" + encodeURIComponent(path)); }51+ function getSpecBrowser(sid, params) {52+ var qs = new URLSearchParams();53+ if (params && params.before) qs.set("before", params.before);54+ if (params && params.after) qs.set("after", params.after);55+ if (params && params.dirty) qs.set("dirty", "true");56+ var s = qs.toString();57+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/spec-browser" + (s ? "?" + s : ""));58+ }59+60+ // ── Minimal markdown renderer (headers, bold, code, lists, hr, para) ──61+ function esc(s) {62+ return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");63+ }64+ function inline(s) {65+ s = esc(s);66+ s = s.replace(/`([^`]+)`/g, '<code class="os-code">$1</code>');67+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");68+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");69+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');70+ return s;71+ }72+ function renderMarkdown(md) {73+ if (!md) return "";74+ var lines = String(md).split("\n");75+ var html = [];76+ var i = 0;77+ var inCode = false, codeBuf = [];78+ var listType = null; // "ul" | "ol"79+ var paraBuf = [];80+ function flushPara() {81+ if (paraBuf.length) { html.push("<p>" + inline(paraBuf.join(" ")) + "</p>"); paraBuf = []; }82+ }83+ function flushList() {84+ if (listType) { html.push("</" + listType + ">"); listType = null; }85+ }86+ while (i < lines.length) {87+ var line = lines[i];88+ // fenced code block89+ if (/^```/.test(line)) {90+ if (inCode) { html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>"); codeBuf = []; inCode = false; }91+ else { flushPara(); flushList(); inCode = true; }92+ i++; continue;93+ }94+ if (inCode) { codeBuf.push(line); i++; continue; }95+ // heading96+ var hm = line.match(/^(#{1,4})\s+(.*)$/);97+ if (hm) { flushPara(); flushList(); var lvl = hm[1].length; html.push("<h" + lvl + ">" + inline(hm[2]) + "</h" + lvl + ">"); i++; continue; }98+ // hr99+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { flushPara(); flushList(); html.push('<hr class="os-hr"/>'); i++; continue; }100+ // list item101+ var lm = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);102+ if (lm) {103+ flushPara();104+ var lt = /\d+\./.test(lm[2]) ? "ol" : "ul";105+ if (listType !== lt) { flushList(); html.push("<" + lt + ' class="os-list">'); listType = lt; }106+ html.push("<li>" + inline(lm[3]) + "</li>");107+ i++; continue;108+ }109+ // blank line110+ if (line.trim() === "") { flushPara(); flushList(); i++; continue; }111+ // paragraph text112+ flushList();113+ paraBuf.push(line.trim());114+ i++;115+ }116+ if (inCode) html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>");117+ flushPara(); flushList();118+ return html.join("\n");119+ }120+ function Markdown(_ref) {121+ var md = _ref.md;122+ var html = useMemo(function () { return renderMarkdown(md); }, [md]);123+ return h("div", { className: "os-md", dangerouslySetInnerHTML: { __html: html } });124+ }125+126+ // ── Icons (inline SVG, no icon lib available in plugin sandbox) ──────127+ function Icon(_ref2) {128+ var d = _ref2.d, size = _ref2.size;129+ var s = size || 16;130+ return h("svg", { width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },131+ h("path", { d: d })132+ );133+ }134+ var ICO = {135+ refresh: "M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16",136+ plus: "M12 5v14M5 12h14",137+ edit: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z",138+ trash: "M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6",139+ copy: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z",140+ close: "M18 6 6 18M6 6l12 12",141+ file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6",142+ folder: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z",143+ chevron: "M9 18l6-6-6-6",144+ branch: "M6 3v12M18 9a3 3 0 1 0-3-3M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a3 3 0 0 1-3 3H6",145+ };146+147+ // ── Modal (lightweight, no Dialog in SDK) ─────────────────────────────148+ function Modal(_ref3) {149+ var title = _ref3.title, onClose = _ref3.onClose, children = _ref3.children, wide = _ref3.wide;150+ useEffect(function () {151+ function onKey(e) { if (e.key === "Escape") onClose(); }152+ window.addEventListener("keydown", onKey);153+ return function () { window.removeEventListener("keydown", onKey); };154+ }, [onClose]);155+ return h("div", { className: "os-overlay", onClick: onClose },156+ h("div", { className: cn("os-modal", wide && "os-modal-wide"), onClick: function (e) { e.stopPropagation(); } },157+ h("div", { className: "os-modal-head" },158+ h("span", { className: "os-modal-title" }, title),159+ h("button", { className: "os-icon-btn", onClick: onClose, "aria-label": "Close" }, h(Icon, { d: ICO.close }))160+ ),161+ h("div", { className: "os-modal-body" }, children)162+ )163+ );164+ }165+166+ // ── Copy chip ────────────────────────────────────────────────────────167+ function CopyChip(_ref4) {168+ var text = _ref4.text;169+ var _s = useState(false);170+ var copied = _s[0], setCopied = _s[1];171+ return h("button", {172+ className: "os-chip",173+ onClick: function () {174+ navigator.clipboard.writeText(text).then(function () { setCopied(true); setTimeout(function () { setCopied(false); }, 1200); });175+ },176+ title: "Copy: " + text,177+ }, copied ? "copied" : text, h("span", { className: "os-chip-ico" }, h(Icon, { d: ICO.copy, size: 11 })));178+ }179+180+ // ── Status badge colors ──────────────────────────────────────────────181+ var STATUS_LABEL = { ideas: "Ideas", draft: "Draft", todo: "Todo", in_progress: "In Progress", done: "Done", archived: "Archived" };182+ var STATUS_TONE = { ideas: "os-tone-ideas", draft: "os-tone-draft", todo: "os-tone-todo", in_progress: "os-tone-progress", done: "os-tone-done", archived: "os-tone-archived" };183+ var COLUMN_ORDER = ["ideas", "draft", "todo", "in_progress", "done", "archived"];184+185+ // ── Source toolbar ───────────────────────────────────────────────────186+ function Toolbar(_ref5) {187+ var sources = _ref5.sources, activeId = _ref5.activeId, onSelect = _ref5.onSelect;188+ var onRefresh = _ref5.onRefresh, onAdd = _ref5.onAdd, onEdit = _ref5.onEdit, onRemove = _ref5.onRemove;189+ var active = sources.find(function (s) { return s.id === activeId; });190+ return h("div", { className: "os-toolbar" },191+ h(Label, { htmlFor: "os-project" }, "Project"),192+ h(Select, { id: "os-project", value: activeId || "", onValueChange: onSelect },193+ sources.length === 0 ? h(SelectOption, { value: "" }, "—") : null,194+ sources.map(function (s) { return h(SelectOption, { key: s.id, value: s.id }, s.name + (s.valid ? "" : " (invalid)")); })195+ ),196+ h("button", { className: "os-icon-btn", onClick: onRefresh, title: "Refresh", disabled: sources.length === 0 }, h(Icon, { d: ICO.refresh })),197+ h("button", { className: "os-icon-btn", onClick: onEdit, title: "Edit source", disabled: !active }, h(Icon, { d: ICO.edit })),198+ h("button", { className: "os-icon-btn", onClick: onRemove, title: "Remove source", disabled: !active }, h(Icon, { d: ICO.trash })),199+ h("button", { className: "os-icon-btn", onClick: onAdd, title: "Add source" }, h(Icon, { d: ICO.plus }))200+ );201+ }202+203+ // ── Add / Edit dialogs ───────────────────────────────────────────────204+ function SourceDialog(_ref6) {205+ var mode = _ref6.mode, initialPath = _ref6.initialPath, initialName = _ref6.initialName, onClose = _ref6.onClose, onSaved = _ref6.onSaved;206+ var activeId = _ref6.activeId;207+ var _p = useState(initialPath || ""), _n = useState(initialName || ""), _b = useState(false), _e = useState("");208+ var path = _p[0], setPath = _p[1], name = _n[0], setName = _n[1], busy = _b[0], setBusy = _b[1], err = _e[0], setErr = _e[1];209+ function save() {210+ if (!path.trim()) { setErr("Path is required"); return; }211+ setBusy(true); setErr("");212+ var p = mode === "edit" ? updateSource(activeId, path.trim(), name.trim()) : addSource(path.trim(), name.trim());213+ p.then(function (res) { onSaved(res); }).catch(function (e) { setErr(apiErr(e)); setBusy(false); });214+ }215+ return h(Modal, { title: mode === "edit" ? "Edit OpenSpec source" : "Add OpenSpec source", onClose: onClose },216+ h("div", { className: "os-field" },217+ h(Label, { htmlFor: "os-path" }, "Path"),218+ h(Input, { id: "os-path", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: "~/repos/my-project", disabled: busy })219+ ),220+ h("div", { className: "os-field" },221+ h(Label, { htmlFor: "os-name" }, "Display name (optional)"),222+ h(Input, { id: "os-name", value: name, onChange: function (e) { setName(e.target.value); }, placeholder: "my-project", disabled: busy })223+ ),224+ err ? h("p", { className: "os-err" }, err) : null,225+ h("div", { className: "os-dialog-actions" },226+ h(Button, { variant: "outline", onClick: onClose, disabled: busy }, "Cancel"),227+ h(Button, { onClick: save, disabled: busy }, busy ? "Saving…" : "Save")228+ )229+ );230+ }231+232+ // ── Board (kanban) ───────────────────────────────────────────────────233+ function Board(_ref7) {234+ var source = _ref7.source, onSelectItem = _ref7.onSelectItem;235+ var board = source.openspec;236+ if (!board) return h("p", { className: "os-empty" }, "No openspec/ content found.");237+ var byCol = {};238+ COLUMN_ORDER.forEach(function (c) { byCol[c] = []; });239+ board.ideas.forEach(function (it) { byCol.ideas.push(it); });240+ board.changes.forEach(function (ch) { var s = ch.status || "draft"; (byCol[s] || (byCol[s] = [])).push(ch); });241+ return h("div", { className: "os-board" },242+ h("div", { className: "os-board-head" },243+ h("span", { className: "os-repo-name" }, source.name),244+ h(CopyChip, { text: source.name }),245+ h("span", { className: "os-repo-path" }, source.path)246+ ),247+ h("div", { className: "os-cols" },248+ COLUMN_ORDER.map(function (col) {249+ var items = byCol[col] || [];250+ return h("div", { key: col, className: "os-col" },251+ h("div", { className: "os-col-head" },252+ h("span", null, STATUS_LABEL[col] || col),253+ h(Badge, { variant: "secondary" }, String(items.length))254+ ),255+ h("div", { className: "os-col-list" },256+ items.length === 0 ? h("span", { className: "os-col-empty" }, "—") :257+ items.map(function (it) {258+ return h("button", {259+ key: it.id || it.name, className: cn("os-card", STATUS_TONE[it.status] || ""), onClick: function () { onSelectItem(it); },260+ },261+ h("div", { className: "os-card-title" }, it.title || it.name),262+ h("div", { className: "os-card-foot" },263+ h("span", { className: "os-card-name" }, it.name),264+ it.token ? h(CopyChip, { text: source.name + "/" + it.token }) : null,265+ it.taskStats ? h("span", { className: "os-card-stats" }, it.taskStats.done + "/" + it.taskStats.total) : null266+ )267+ );268+ })269+ )270+ );271+ })272+ )273+ );274+ }275+276+ // ── Change / Idea detail dialog ──────────────────────────────────────277+ function DetailDialog(_ref8) {278+ var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose;279+ var _d = useState(null), _l = useState(true), _e2 = useState("");280+ var detail = _d[0], setDetail = _d[1], loading = _l[0], setLoading = _l[1], err = _e2[0], setErr = _e2[1];281+ var isIdea = item.status === "ideas";282+ useEffect(function () {283+ setLoading(true); setErr(""); setDetail(null);284+ var p = isIdea ? getIdea(source.id, item.name) : getChange(source.id, item.name);285+ p.then(function (d) { setDetail(d); setLoading(false); }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });286+ }, [source.id, item.name, isIdea]);287+ return h(Modal, { title: item.title || item.name, onClose: onClose, wide: true },288+ h("div", { className: "os-detail-chip" }, h(CopyChip, { text: source.name + "/" + item.token })),289+ loading ? h("p", { className: "os-empty" }, "Loading…") :290+ err ? h("p", { className: "os-err" }, err) :291+ isIdea ? h(Markdown, { md: detail && detail.content }) :292+ detail ? h(ChangeDetailTabs, { detail: detail }) : null293+ );294+ }295+ function ChangeDetailTabs(_ref9) {296+ var detail = _ref9.detail;297+ var tabs = [];298+ if (detail.proposal) tabs.push(["proposal", "Proposal"]);299+ if (detail.tasks) tabs.push(["tasks", "Tasks"]);300+ if (detail.design) tabs.push(["design", "Design"]);301+ if (detail.specs && detail.specs.length) tabs.push(["specs", "Specs (" + detail.specs.length + ")"]);302+ if (!tabs.length) return h("p", { className: "os-empty" }, "No documents found.");303+ var _tab = useState(tabs[0][0]);304+ var activeTab = _tab[0], setActiveTab = _tab[1];305+ var content = activeTab === "specs"306+ ? 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 })); })307+ : h(Markdown, { md: detail[activeTab] });308+ return h("div", { className: "os-tabs" },309+ h("div", { className: "os-tabs-bar" }, tabs.map(function (t) {310+ return h("button", {311+ key: t[0], className: cn("os-tab-btn", activeTab === t[0] && "os-tab-btn-active"),312+ onClick: function () { setActiveTab(t[0]); }313+ }, t[1]);314+ })),315+ h("div", { className: "os-tab-panel" }, content)316+ );317+ }318+319+ // ── Specs view (spec browser: current / dirty / refs) ────────────────320+ var SORT_MODES = [321+ { value: "alpha", label: "A→Z" },322+ { value: "alpha-desc", label: "Z→A" },323+ { value: "edited", label: "Latest edited" },324+ { value: "edited-desc", label: "Oldest edited" },325+ ];326+ function sortFiles(files, sortMode) {327+ if (!files || !files.length) return files;328+ var arr = files.slice();329+ if (sortMode === "alpha-desc") {330+ arr.sort(function (a, b) { return b.path.localeCompare(a.path); });331+ } else if (sortMode === "edited") {332+ arr.sort(function (a, b) {333+ var ta = a.mtime ? Date.parse(a.mtime) : 0;334+ var tb = b.mtime ? Date.parse(b.mtime) : 0;335+ return tb - ta;336+ });337+ } else if (sortMode === "edited-desc") {338+ arr.sort(function (a, b) {339+ var ta = a.mtime ? Date.parse(a.mtime) : 0;340+ var tb = b.mtime ? Date.parse(b.mtime) : 0;341+ return ta - tb;342+ });343+ } else {344+ arr.sort(function (a, b) { return a.path.localeCompare(b.path); });345+ }346+ return arr;347+ }348+ function SpecsView(_ref10) {349+ var source = _ref10.source, anchorToken = _ref10.anchorToken;350+ var _m = useState("current"), _b2 = useState(null), _l2 = useState(true), _e3 = useState("");351+ var _sel = useState(null), _bf = useState(""), _af = useState("");352+ var _sm = useState("alpha");353+ var mode = _m[0], setMode = _m[1], browser = _b2[0], setBrowser = _b2[1];354+ var loading = _l2[0], setLoading = _l2[1], err = _e3[0], setErr = _e3[1];355+ var selPath = _sel[0], setSelPath = _sel[1], before = _bf[0], setBefore = _bf[1], after = _af[0], setAfter = _af[1];356+ var sortMode = _sm[0], setSortMode = _sm[1];357+ var firstLoad = useRef(true);358+359+ function load(currentMode, b, a) {360+ setLoading(true); setErr("");361+ var params = {};362+ if (currentMode === "dirty") params.dirty = true;363+ else if (currentMode === "refs") { params.before = b; params.after = a; }364+ getSpecBrowser(source.id, params).then(function (d) {365+ setBrowser(d);366+ // Pick initial selection: anchor token match, else first file.367+ var pick = null;368+ if (anchorToken && d.files) {369+ pick = d.files.find(function (f) { return f.token === anchorToken; });370+ }371+ if (!pick && d.files && d.files.length) pick = d.files[0];372+ setSelPath(pick ? pick.path : null);373+ setLoading(false);374+ }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });375+ }376+ useEffect(function () {377+ if (firstLoad.current) { firstLoad.current = false; load(mode, before, after); }378+ }, []);379+ function switchMode(m) {380+ setMode(m);381+ if (m === "refs" && (!before.trim() || !after.trim())) { setBrowser(null); setLoading(false); return; }382+ load(m, before, after);383+ }384+ function reload() { load(mode, before, after); }385+ var selFile = browser && browser.files ? browser.files.find(function (f) { return f.path === selPath; }) : null;386+ return h("div", { className: "os-specs" },387+ h("div", { className: "os-specs-toolbar" },388+ h("span", { className: "os-specs-title" }, "Specs"),389+ browser && browser.branch ? h(Badge, { variant: "outline" }, h(Icon, { d: ICO.branch, size: 11 }), " ", browser.branch) : null,390+ h("div", { className: "os-specs-modes" },391+ h(Button, { variant: mode === "current" ? "default" : "outline", size: "sm", onClick: function () { switchMode("current"); } }, "Current"),392+ h(Button, { variant: mode === "dirty" ? "default" : "outline", size: "sm", onClick: function () { switchMode("dirty"); } }, "Dirty (HEAD→worktree)"),393+ h(Button, { variant: mode === "refs" ? "default" : "outline", size: "sm", onClick: function () { switchMode("refs"); } }, "Compare refs")394+ ),395+ mode === "refs" ? h("div", { className: "os-refs-inputs" },396+ h(Input, { placeholder: "before (HEAD~1)", value: before, onChange: function (e) { setBefore(e.target.value); }, className: "os-ref-input" }),397+ h(Input, { placeholder: "after (origin/main)", value: after, onChange: function (e) { setAfter(e.target.value); }, className: "os-ref-input" }),398+ h(Button, { size: "sm", onClick: reload, disabled: !before.trim() || !after.trim() }, "Load")399+ ) : null,400+ h("button", { className: "os-icon-btn", onClick: reload, title: "Refresh" }, h(Icon, { d: ICO.refresh })),401+ h(Select, { value: sortMode, onValueChange: setSortMode, "aria-label": "Sort specs" },402+ SORT_MODES.map(function (s) { return h(SelectOption, { key: s.value, value: s.value }, s.label); })403+ )404+ ),405+ browser && browser.changedCount > 0 ? h("div", { className: "os-specs-changed" }, browser.changedCount + " changed spec" + (browser.changedCount === 1 ? "" : "s")) : null,406+ loading ? h("p", { className: "os-empty" }, "Loading…") :407+ err ? h("p", { className: "os-err" }, err) :408+ browser && browser.files && browser.files.length ? (function () {409+ var sorted = sortFiles(browser.files, sortMode);410+ return h("div", { className: "os-specs-grid" },411+ h("div", { className: "os-specs-list" },412+ sorted.map(function (f) {413+ return h("button", {414+ key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),415+ onClick: function () { setSelPath(f.path); },416+ },417+ h(Icon, { d: ICO.file, size: 13 }),418+ h("span", { className: "os-spec-item-path" }, f.path),419+ f.changed ? h(Badge, { className: "os-spec-status os-status-" + f.status }, f.status) : null420+ );421+ })422+ ),423+ selFile ? h("div", { className: "os-specs-detail" },424+ h("div", { className: "os-specs-detail-head" },425+ h("span", { className: "os-specs-detail-path" }, selFile.path),426+ selFile.token ? h(CopyChip, { text: source.name + "/" + selFile.token }) : null427+ ),428+ mode === "current" ? h(Markdown, { md: selFile.after }) :429+ h("div", { className: "os-spec-diff" },430+ h("div", { className: "os-diff-cols" },431+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "before"), h(Markdown, { md: selFile.before })),432+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "after"), h(Markdown, { md: selFile.after }))433+ ),434+ selFile.diff ? h("pre", { className: "os-diff-unified" }, selFile.diff) : null435+ )436+ ) : h("p", { className: "os-empty" }, "Select a spec.")437+ );438+ })() : h("p", { className: "os-empty" }, "No specs found.")439+ );440+ }441+442+ // ── Error boundary (class-based, catches render crashes) ─────────────443+ var ErrorBoundary = (function (_super) {444+ function EB(props) {445+ _super.call(this, props);446+ this.state = { hasError: false, error: null };447+ }448+ EB.prototype = Object.create(_super.prototype);449+ EB.prototype.constructor = EB;450+ EB.getDerivedStateFromError = function (error) {451+ return { hasError: true, error: error };452+ };453+ EB.prototype.componentDidCatch = function (error) {454+ console.error("[openspec] render error:", error);455+ };456+ EB.prototype.render = function () {457+ if (this.state.hasError) {458+ return h("div", { className: "os-err" },459+ "Something went wrong rendering this view: " + (this.state.error && this.state.error.message || String(this.state.error)),460+ h("button", { className: "os-icon-btn", style: { marginLeft: "0.5rem" }, onClick: function () { this.setState({ hasError: false, error: null }); }.bind(this) }, "Retry")461+ );462+ }463+ return this.props.children;464+ };465+ return EB;466+ })(React.Component);467+468+ // ── Hash deep-linking ────────────────────────────────────────────────469+ function parseHash() {470+ var h2 = window.location.hash.replace(/^#/, "").trim();471+ if (!h2) return { project: null, token: null };472+ var parts = h2.split("/");473+ return { project: decodeURIComponent(parts[0]) || null, token: parts[1] ? decodeURIComponent(parts[1]) : null };474+ }475+ function setHash(project, token) {476+ var v = project ? (token ? project + "/" + token : project) : "";477+ if (("#" + v) !== window.location.hash) window.location.hash = v;478+ }479+480+ // ── Main page component ──────────────────────────────────────────────481+ function OpenSpecPage() {482+ var _s2 = useState([]), _a2 = useState(null), _l3 = useState(true), _e4 = useState("");483+ var sources = _s2[0], setSources = _s2[1], activeId = _a2[0], setActiveId = _a2[1];484+ var loading = _l3[0], setLoading = _l3[1], err = _e4[0], setErr = _e4[1];485+ var _dlg = useState(null), _anchor = useState({ project: null, token: null });486+ var dlg = _dlg[0], setDlg = _dlg[1], anchor = _anchor[0], setAnchor = _anchor[1];487+ var _view = useState("board"), _selItem = useState(null);488+ var view = _view[0], setView = _view[1], selItem = _selItem[0], setSelItem = _selItem[1];489+490+ function loadSources(silentAfter) {491+ if (!silentAfter) setLoading(true);492+ setErr("");493+ getSources().then(function (res) {494+ setSources(res.sources || []);495+ setLoading(false);496+ }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });497+ }498+ useEffect(function () { loadSources(); }, []);499+500+ // Apply hash anchor on load + when sources change. Auto-select the501+ // first source when no hash project is present so the user doesn't502+ // have to manually pick from the dropdown to see anything.503+ useEffect(function () {504+ if (!sources.length) return;505+ var ha = parseHash();506+ setAnchor(ha);507+ if (ha.project) {508+ var proj = ha.project.toLowerCase();509+ var match = sources.find(function (s) {510+ return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;511+ });512+ if (match && match.id !== activeId) setActiveId(match.id);513+ return;514+ }515+ if (!activeId) setActiveId(sources[0].id);516+ }, [sources]);517+518+ // When anchor has a token, switch view / open detail.519+ useEffect(function () {520+ if (!anchor.token || !activeId || !sources.length) return;521+ var src = sources.find(function (s) { return s.id === activeId; });522+ if (!src || !src.openspec) return;523+ // Check changes + ideas524+ var item = (src.openspec.changes || []).find(function (c) { return c.token === anchor.token; })525+ || (src.openspec.ideas || []).find(function (c) { return c.token === anchor.token; });526+ if (item) { setView("board"); setSelItem(item); return; }527+ // Check specs528+ var spec = (src.openspec.specs || []).find(function (s) { return s.token === anchor.token; });529+ if (spec) { setView("specs"); }530+ }, [anchor, activeId, sources]);531+532+ var active = sources.find(function (s) { return s.id === activeId; });533+534+ 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); }535+ function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token); }536+ function closeItem() { setSelItem(null); if (active) setHash(active.name, null); }537+538+ function onAdd() { setDlg({ mode: "add" }); }539+ function onEdit() { if (active) setDlg({ mode: "edit", activeId: active.id, path: active.path, name: active.name }); }540+ function onRemove() {541+ if (!active) return;542+ if (!window.confirm("Remove source '" + active.name + "'?")) return;543+ removeSource(active.id).then(function () {544+ setSources(sources.filter(function (s) { return s.id !== active.id; }));545+ setActiveId(null); setHash(null, null);546+ }).catch(function (e) { setErr(apiErr(e)); });547+ }548+ function onSaved(res) {549+ setDlg(null);550+ getSources().then(function (r) {551+ setSources(r.sources || []);552+ if (res && res.source) setActiveId(res.source.id);553+ }).catch(function (e) { setErr(apiErr(e)); });554+ }555+556+ // Hash change listener (browser back/forward + manual URL edits).557+ useEffect(function () {558+ function onHash() {559+ var ha = parseHash();560+ setAnchor(ha);561+ if (ha.project && sources.length) {562+ var proj = ha.project.toLowerCase();563+ var match = sources.find(function (s) {564+ return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;565+ });566+ if (match) setActiveId(match.id);567+ }568+ }569+ window.addEventListener("hashchange", onHash);570+ return function () { window.removeEventListener("hashchange", onHash); };571+ }, [sources]);572+573+ if (loading) return h("div", { className: "os-page os-loading" }, "Loading…");574+ return h(ErrorBoundary, null,575+ h("div", { className: "os-page" },576+ h(Toolbar, {577+ sources: sources, activeId: activeId, onSelect: selectSource,578+ onRefresh: function () { loadSources(true); }, onAdd: onAdd, onEdit: onEdit, onRemove: onRemove,579+ }),580+ err ? h("p", { className: "os-err" }, err) : null,581+ sources.length === 0 ? h("div", { className: "os-empty-state" },582+ h("p", null, "No OpenSpec sources registered."),583+ h(Button, { onClick: onAdd }, h(Icon, { d: ICO.plus, size: 14 }), " Add your first source")584+ ) : null,585+ active && active.valid ? h("div", { className: "os-view-switch" },586+ h(Button, { variant: view === "board" ? "default" : "outline", size: "sm", onClick: function () { setView("board"); } }, "Changes"),587+ h(Button, { variant: view === "specs" ? "default" : "outline", size: "sm", onClick: function () { setView("specs"); } }, "Specs")588+ ) : null,589+ active && !active.valid ? h("p", { className: "os-err" }, active.error || "Source path is invalid.") : null,590+ active && active.valid && view === "board" ? h(Board, { source: active, onSelectItem: selectItem }) : null,591+ active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token }) : null,592+ selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem }) : null,593+ dlg ? h(SourceDialog, {594+ mode: dlg.mode, initialPath: dlg.path, initialName: dlg.name, activeId: dlg.activeId,595+ onClose: function () { setDlg(null); }, onSaved: onSaved,596+ }) : null597+ )598+ );599+ }600+601+ // ── Register ─────────────────────────────────────────────────────────602+ if (window.__HERMES_PLUGINS__ && window.__HERMES_PLUGINS__.register) {603+ window.__HERMES_PLUGINS__.register("openspec", OpenSpecPage);604+ }605+})();@@ -0,0 +1,126 @@
1+/* OpenSpec dashboard plugin styles. Loaded via manifest "css" field.2+ Uses the dashboard's --color-* shadcn tokens + --midground / --background-base3+ so it reskins with the active theme automatically (see extending-the-dashboard docs). */4+5+.os-page { display: flex; flex-direction: column; gap: 0.75rem; padding: 1rem; min-height: calc(100vh - 4rem); }6+.os-loading { color: var(--color-muted-foreground, #888); }7+8+/* Toolbar */9+.os-toolbar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }10+.os-toolbar label { font-size: 0.75rem; color: var(--color-muted-foreground, #aaa); margin-right: 0.25rem; }11+12+/* Shrink Select dropdowns to fit their text content instead of stretching13+ (the SDK Select trigger has w-full, which over-sizes in flex toolbars). */14+.os-page div.relative:has(> button[role="combobox"]) { width: fit-content; }15+16+.os-icon-btn {17+ display: inline-flex; align-items: center; justify-content: center;18+ width: 2rem; height: 2rem; border-radius: var(--radius-sm, 0.375rem);19+ border: 1px solid var(--color-border, #333); background: transparent;20+ color: var(--color-muted-foreground, #aaa); cursor: pointer; transition: background 0.12s;21+}22+.os-icon-btn:hover:not(:disabled) { background: var(--color-secondary, rgba(255,255,255,0.06)); color: var(--color-foreground, #eee); }23+.os-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }24+25+/* View switch */26+.os-view-switch { display: flex; gap: 0.25rem; }27+28+/* Board */29+.os-board { display: flex; flex-direction: column; gap: 0.75rem; }30+.os-board-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }31+.os-repo-name { font-weight: 600; font-size: 0.95rem; color: var(--color-foreground, #eee); }32+.os-repo-path { font-size: 0.75rem; color: var(--color-muted-foreground, #777); font-family: var(--theme-font-mono, monospace); }33+34+.os-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 0.75rem; }35+.os-col { display: flex; flex-direction: column; max-height: calc(100vh - 16rem); min-height: 7rem; border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius, 0.5rem); background: var(--color-card, #161616); }36+.os-col-head { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.625rem; border-bottom: 1px solid var(--color-border, #2a2a2a); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-muted-foreground, #aaa); }37+.os-col-list { display: flex; flex-direction: column; gap: 0.375rem; padding: 0.5rem; overflow-y: auto; min-height: 0; flex: 1; }38+.os-col-empty { color: var(--color-muted-foreground, #555); font-size: 0.75rem; text-align: center; padding: 0.5rem; }39+40+.os-card { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem; border-radius: var(--radius-sm, 0.375rem); border: 1px solid var(--color-border, #2a2a2a); background: var(--color-popover, #1a1a1a); text-align: left; cursor: pointer; transition: border-color 0.12s; }41+.os-card:hover { border-color: var(--color-primary, #555); }42+.os-card-title { font-size: 0.82rem; font-weight: 500; line-height: 1.3; color: var(--color-foreground, #eee); }43+.os-card-foot { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }44+.os-card-name { font-size: 0.68rem; color: var(--color-muted-foreground, #777); font-family: var(--theme-font-mono, monospace); }45+.os-card-stats { font-size: 0.68rem; color: var(--color-muted-foreground, #aaa); margin-left: auto; }46+47+.os-tone-ideas { border-left: 3px solid #a78bfa; }48+.os-tone-draft { border-left: 3px solid var(--color-muted-foreground, #6b7280); }49+.os-tone-todo { border-left: 3px solid #60a5fa; }50+.os-tone-progress { border-left: 3px solid #fbbf24; }51+.os-tone-done { border-left: 3px solid #34d399; }52+.os-tone-archived { border-left: 3px solid var(--color-muted-foreground, #4b5563); opacity: 0.7; }53+54+/* Chip */55+.os-chip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.0625rem 0.375rem; border-radius: var(--radius-sm, 0.25rem); border: 1px solid var(--color-border, #333); background: transparent; font-size: 0.65rem; font-family: var(--theme-font-mono, monospace); color: var(--color-muted-foreground, #aaa); cursor: pointer; transition: background 0.1s; }56+.os-chip:hover { background: var(--color-secondary, rgba(255,255,255,0.06)); }57+.os-chip-ico { display: inline-flex; opacity: 0.6; }58+59+/* Modal */60+.os-overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: flex-start; justify-content: center; padding: 3rem 1rem; background: rgba(0,0,0,0.6); overflow-y: auto; }61+.os-modal { width: 100%; max-width: 36rem; background: var(--color-card, #161616); border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius, 0.5rem); }62+.os-modal-wide { max-width: 56rem; }63+.os-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border, #2a2a2a); }64+.os-modal-title { font-weight: 600; font-size: 0.95rem; color: var(--color-foreground, #eee); }65+.os-modal-body { padding: 1rem; max-height: calc(100vh - 12rem); overflow-y: auto; }66+67+.os-field { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }68+.os-field label { font-size: 0.75rem; color: var(--color-muted-foreground, #aaa); }69+.os-dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }70+71+.os-err { color: #f87171; font-size: 0.8rem; }72+.os-empty { color: var(--color-muted-foreground, #777); font-size: 0.85rem; padding: 0.5rem 0; }73+.os-empty-state { text-align: center; padding: 3rem 1rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; color: var(--color-muted-foreground, #777); }74+75+.os-detail-chip { margin-bottom: 0.75rem; }76+77+/* Custom tabs (replaces Radix Tabs — not all hosts expose TabsContent) */78+.os-tabs-bar { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--color-border, #2a2a2a); margin-bottom: 0.75rem; }79+.os-tab-btn { padding: 0.375rem 0.75rem; font-size: 0.8rem; font-weight: 500; border: none; border-bottom: 2px solid transparent; background: transparent; color: var(--color-muted-foreground, #aaa); cursor: pointer; transition: color 0.12s, border-color 0.12s; }80+.os-tab-btn:hover { color: var(--color-foreground, #eee); }81+.os-tab-btn-active { color: var(--color-foreground, #eee); border-bottom-color: var(--color-primary, var(--midground, #eee)); }82+.os-tab-panel { padding-top: 0.25rem; }83+84+/* Markdown */85+.os-md { font-size: 0.85rem; line-height: 1.6; color: var(--color-foreground, #eee); }86+.os-md h1, .os-md h2, .os-md h3, .os-md h4 { margin: 0.75rem 0 0.375rem; font-weight: 600; color: var(--color-foreground, #eee); }87+.os-md h1 { font-size: 1.15rem; } .os-md h2 { font-size: 1.05rem; } .os-md h3 { font-size: 0.95rem; } .os-md h4 { font-size: 0.88rem; }88+.os-md p { margin: 0.375rem 0; }89+.os-md ul, .os-md ol { margin: 0.375rem 0; padding-left: 1.25rem; }90+.os-md li { margin: 0.125rem 0; }91+.os-md .os-code { font-family: var(--theme-font-mono, monospace); font-size: 0.82em; color: color-mix(in srgb, var(--midground, var(--color-foreground, inherit)) 90%, transparent); background: var(--color-secondary, color-mix(in srgb, var(--midground-base, currentColor) 8%, transparent)); padding: 0.0625rem 0.25rem; border-radius: var(--radius-sm, 0.1875rem); }92+.os-md .os-pre { background: var(--color-secondary, #0e0e0e); border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.625rem; overflow-x: auto; margin: 0.5rem 0; }93+.os-md .os-pre code { font-family: var(--theme-font-mono, monospace); font-size: 0.78rem; color: var(--color-foreground, #eee); }94+.os-md .os-hr { border: none; border-top: 1px solid var(--color-border, #2a2a2a); margin: 0.75rem 0; }95+.os-md a { color: var(--color-primary, #60a5fa); text-decoration: underline; }96+97+.os-spec-block { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--color-border, #2a2a2a); }98+.os-spec-block:last-child { border-bottom: none; }99+.os-spec-block h4 { font-size: 0.82rem; font-family: var(--theme-font-mono, monospace); color: var(--color-muted-foreground, #aaa); margin-bottom: 0.375rem; }100+101+/* Specs view */102+.os-specs { display: flex; flex-direction: column; gap: 0.75rem; }103+.os-specs-toolbar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }104+.os-specs-title { font-weight: 600; font-size: 0.9rem; color: var(--color-foreground, #eee); }105+.os-specs-modes { display: flex; gap: 0.25rem; }106+.os-refs-inputs { display: flex; gap: 0.375rem; align-items: center; }107+.os-ref-input { width: 9rem; font-size: 0.75rem; }108+.os-specs-changed { font-size: 0.78rem; color: #fbbf24; }109+110+.os-specs-grid { display: grid; grid-template-columns: 16rem 1fr; gap: 0.75rem; min-height: calc(100vh - 18rem); }111+.os-specs-list { display: flex; flex-direction: column; gap: 0.25rem; border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius, 0.5rem); padding: 0.5rem; overflow-y: auto; max-height: calc(100vh - 14rem); }112+.os-spec-item { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.5rem; border-radius: var(--radius-sm, 0.375rem); border: 1px solid transparent; background: transparent; text-align: left; cursor: pointer; font-size: 0.78rem; color: var(--color-muted-foreground, #aaa); }113+.os-spec-item:hover { background: var(--color-secondary, rgba(255,255,255,0.05)); }114+.os-spec-item-active { border-color: var(--color-primary, #555); background: var(--color-secondary, rgba(255,255,255,0.08)); color: var(--color-foreground, #eee); }115+.os-spec-item-changed { color: #fbbf24; }116+.os-spec-item-path { flex: 1; font-family: var(--theme-font-mono, monospace); font-size: 0.72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }117+.os-spec-status { font-size: 0.6rem; text-transform: uppercase; }118+.os-status-added { color: #34d399; } .os-status-modified { color: #fbbf24; } .os-status-deleted { color: #f87171; }119+120+.os-specs-detail { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius, 0.5rem); padding: 0.75rem; overflow-y: auto; max-height: calc(100vh - 14rem); }121+.os-specs-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }122+.os-specs-detail-path { font-family: var(--theme-font-mono, monospace); font-size: 0.8rem; font-weight: 500; color: var(--color-foreground, #eee); }123+.os-diff-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }124+.os-diff-col { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.5rem; overflow-x: auto; }125+.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; }126+.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; }@@ -0,0 +1,14 @@
1+{2+ "name": "openspec",3+ "label": "OpenSpec",4+ "description": "Spec-driven development board — register repos, drill into change proposals/tasks/designs/specs, browse current specs and branch diffs",5+ "icon": "Code",6+ "version": "0.1.0",7+ "tab": {8+ "path": "/openspec",9+ "position": "after:skills"10+ },11+ "entry": "dist/index.js",12+ "css": "dist/style.css",13+ "api": "plugin_api.py"14+}@@ -0,0 +1,755 @@
1+"""OpenSpec dashboard plugin — backend API routes.2+3+Mounted at /api/plugins/openspec/ by the dashboard plugin system4+(``hermes_cli.web_server._mount_plugin_api_routes`` imports this file and5+``app.include_router``\\ s it). Every handler is a thin wrapper around the6+plugin-local registry (``plugins.openspec.registry``) plus filesystem/git7+scanners that live in this module — no core ``web_server`` helpers are used,8+so the plugin is self-contained.9+10+Routes11+------12+- GET /sources — list registered sources (with live board scan)13+- POST /sources — register a source by path14+- DELETE /sources/{source_id} — unregister a source15+- PUT /sources/{source_id} — update a source's path/name16+- GET /sources/{source_id}/changes/{change} — change detail (proposal/tasks/design/specs)17+- GET /sources/{source_id}/ideas/{idea} — idea detail (markdown content)18+- GET /sources/{source_id}/specs?path= — spec detail (current worktree content)19+- GET /sources/{source_id}/spec-browser — current/dirty/ref-diff spec browser20+21+Auth22+----23+HTTP routes go through the dashboard's session-token auth middleware24+(``web_server.auth_middleware``) just like core API routes — every25+``/api/plugins/...`` request must present the session bearer token (or the26+session cookie set when you load the dashboard HTML).27+"""28+29+from __future__ import annotations30+31+import difflib32+import importlib.util33+import re34+import subprocess35+from datetime import datetime, timezone36+from pathlib import Path37+from typing import Any, Optional38+39+from fastapi import APIRouter, HTTPException, Query40+from pydantic import BaseModel41+42+# ---------------------------------------------------------------------------43+# Registry import — path-based, because the dashboard plugin loader imports44+# this file standalone via importlib (no package context for relative imports).45+# ---------------------------------------------------------------------------46+_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "registry.py"47+48+49+def _load_registry():50+ if not _REGISTRY_PATH.is_file():51+ return None52+ spec = importlib.util.spec_from_file_location("openspec_registry", _REGISTRY_PATH)53+ if spec is None or spec.loader is None:54+ return None55+ mod = importlib.util.module_from_spec(spec)56+ spec.loader.exec_module(mod)57+ return mod58+59+60+_registry = _load_registry()61+62+router = APIRouter()63+64+65+# ---------------------------------------------------------------------------66+# Path guards — local equivalents of web_server._fs_path / _fs_find_git_root.67+# Kept here so the plugin has zero core-dependency for filesystem traversal.68+# ---------------------------------------------------------------------------69+70+def _resolve_path(raw_path: str) -> Path:71+ """Resolve a user-supplied path string to an absolute Path, rejecting72+ null bytes and unparseable values. Mirrors web_server._fs_path semantics."""73+ raw = str(raw_path or "").strip()74+ if not raw:75+ raise HTTPException(status_code=400, detail="Path is required")76+ if "\0" in raw:77+ raise HTTPException(status_code=400, detail="Invalid path")78+ try:79+ candidate = Path(raw).expanduser()80+ if not candidate.is_absolute():81+ candidate = Path.cwd() / candidate82+ return candidate.resolve(strict=False)83+ except (OSError, RuntimeError, ValueError):84+ raise HTTPException(status_code=400, detail="Invalid path")85+86+87+def _find_git_root(start: Path) -> Optional[Path]:88+ directory = start89+ for _ in range(50):90+ try:91+ if (directory / ".git").exists():92+ return directory93+ except OSError:94+ return None95+ parent = directory.parent96+ if parent == directory:97+ return None98+ directory = parent99+ return None100+101+102+def _source_root(raw_path: str) -> tuple[Path | None, str | None]:103+ """Resolve a raw path to a repo root containing ``openspec/``.104+105+ Accepts either a repo root, the ``openspec/`` dir itself, or any path106+ inside a repo whose root has ``openspec/``.107+ """108+ try:109+ target = _resolve_path(raw_path)110+ except HTTPException as exc:111+ return None, str(exc.detail)112+ openspec_root = target if target.name == "openspec" else target / "openspec"113+ if openspec_root.is_dir():114+ return openspec_root.parent, None115+ git_root = _find_git_root(target)116+ if git_root and (git_root / "openspec").is_dir():117+ return git_root, None118+ return None, "No openspec/ directory found"119+120+121+# ---------------------------------------------------------------------------122+# Markdown / task parsing helpers123+# ---------------------------------------------------------------------------124+125+_OPENSPEC_DOC_MAX_BYTES = 512 * 1024126+_TASK_DONE_RE = re.compile(r"^- \[[xX]\]", re.MULTILINE)127+_TASK_TODO_RE = re.compile(r"^- \[ \]", re.MULTILINE)128+_STATUSES = ("ideas", "draft", "todo", "in_progress", "done", "archived")129+130+131+def _task_stats(tasks_path: Path) -> Optional[dict[str, int]]:132+ try:133+ content = tasks_path.read_text(encoding="utf-8", errors="replace")134+ except OSError:135+ return None136+ done = len(_TASK_DONE_RE.findall(content))137+ todo = len(_TASK_TODO_RE.findall(content))138+ return {"total": done + todo, "done": done}139+140+141+def _status(has_tasks: bool, stats: Optional[dict[str, int]], *, archived: bool) -> str:142+ if archived:143+ return "archived"144+ if not has_tasks or stats is None:145+ return "draft"146+ if stats["done"] == 0:147+ return "todo"148+ if stats["total"] > 0 and stats["done"] == stats["total"]:149+ return "done"150+ return "in_progress"151+152+153+def _title_from_markdown(path: Path, fallback: str) -> str:154+ try:155+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():156+ stripped = line.strip()157+ if stripped.startswith("# "):158+ return stripped[2:].strip() or fallback159+ except OSError:160+ pass161+ return fallback162+163+164+def _read_doc(path: Path) -> Optional[str]:165+ try:166+ if not path.is_file():167+ return None168+ if path.stat().st_size > _OPENSPEC_DOC_MAX_BYTES:169+ return path.read_text(encoding="utf-8", errors="replace")[:_OPENSPEC_DOC_MAX_BYTES]170+ return path.read_text(encoding="utf-8", errors="replace")171+ except OSError:172+ return None173+174+175+def _change_token(name: str) -> str:176+ if _registry is not None:177+ return _registry.change_token(name)178+ return ""179+180+181+# ---------------------------------------------------------------------------182+# Board scan183+# ---------------------------------------------------------------------------184+185+def _change_summary(path: Path, source_id: str, *, archived: bool = False) -> Optional[dict[str, Any]]:186+ if not path.is_dir():187+ return None188+ proposal_path = path / "proposal.md"189+ tasks_path = path / "tasks.md"190+ design_path = path / "design.md"191+ specs_path = path / "specs"192+ if not proposal_path.is_file():193+ return None194+ has_tasks = tasks_path.is_file()195+ stats = _task_stats(tasks_path) if has_tasks else None196+ name = path.name197+ return {198+ "id": f"{source_id}/{name}",199+ "token": _change_token(name),200+ "name": name,201+ "title": _title_from_markdown(proposal_path, name),202+ "status": _status(has_tasks, stats, archived=archived),203+ "hasProposal": True,204+ "hasTasks": has_tasks,205+ "hasDesign": design_path.is_file(),206+ "hasSpecs": specs_path.is_dir(),207+ "taskStats": stats,208+ }209+210+211+def _idea_summary(path: Path, source_id: str) -> Optional[dict[str, Any]]:212+ if not path.is_file() or path.suffix.lower() != ".md":213+ return None214+ return {215+ "id": f"{source_id}/{path.stem}",216+ "token": _change_token(path.stem),217+ "name": path.stem,218+ "title": _title_from_markdown(path, path.stem),219+ "status": "ideas",220+ }221+222+223+def _scan(root: Path) -> Optional[dict[str, Any]]:224+ openspec_root = root / "openspec"225+ if not openspec_root.is_dir():226+ return None227+ source_id = root.name228+ changes: list[dict[str, Any]] = []229+ ideas: list[dict[str, Any]] = []230+ specs: list[dict[str, Any]] = []231+232+ changes_root = openspec_root / "changes"233+ if changes_root.is_dir():234+ try:235+ for child in sorted(changes_root.iterdir(), key=lambda item: item.name.lower()):236+ if child.name == "archive":237+ continue238+ change = _change_summary(child, source_id)239+ if change is not None:240+ changes.append(change)241+ except OSError:242+ pass243+ archive_root = changes_root / "archive"244+ if archive_root.is_dir():245+ try:246+ for child in sorted(archive_root.iterdir(), key=lambda item: item.name.lower()):247+ change = _change_summary(child, source_id, archived=True)248+ if change is not None:249+ changes.append(change)250+ except OSError:251+ pass252+253+ ideas_root = openspec_root / "ideas"254+ if ideas_root.is_dir():255+ try:256+ for child in sorted(ideas_root.iterdir(), key=lambda item: item.name.lower()):257+ idea = _idea_summary(child, source_id)258+ if idea is not None:259+ ideas.append(idea)260+ except OSError:261+ pass262+263+ specs_root = openspec_root / "specs"264+ if specs_root.is_dir():265+ try:266+ for child in sorted(specs_root.rglob("*.md")):267+ rel = child.relative_to(specs_root).as_posix()268+ specs.append({269+ "id": f"{source_id}/spec/{rel}",270+ "token": _change_token(f"spec:{rel}"),271+ "name": rel,272+ "path": rel,273+ "title": _title_from_markdown(child, rel),274+ })275+ except OSError:276+ pass277+278+ by_status = {s: 0 for s in _STATUSES}279+ by_status["ideas"] = len(ideas)280+ for change in changes:281+ status = str(change.get("status") or "draft")282+ by_status[status] = by_status.get(status, 0) + 1283+284+ return {285+ "path": str(openspec_root),286+ "changes": changes,287+ "ideas": ideas,288+ "specs": specs,289+ "counts": {290+ "changes": len(changes),291+ "ideas": len(ideas),292+ "specs": len(specs),293+ "byStatus": by_status,294+ },295+ }296+297+298+def _source_payload(source: dict[str, Any]) -> dict[str, Any]:299+ root, error = _source_root(source["path"])300+ name = source.get("name") or (root.name if root else Path(source["path"]).expanduser().name)301+ base = {302+ "token": source.get("token") or source.get("id"),303+ "id": source.get("id") or source.get("token"),304+ "name": name,305+ "path": str(Path(source["path"]).expanduser()),306+ }307+ if root is None:308+ return {**base, "valid": False, "repoRoot": None, "openspec": None, "error": error}309+ return {310+ **base,311+ "valid": True,312+ "repoRoot": str(root),313+ "openspec": _scan(root),314+ "error": None,315+ }316+317+318+# ---------------------------------------------------------------------------319+# Detail helpers320+# ---------------------------------------------------------------------------321+322+def _find_change_dir(root: Path, change_name: str) -> tuple[Path | None, bool]:323+ changes_root = root / "openspec" / "changes"324+ candidate = changes_root / change_name325+ if candidate.is_dir():326+ return candidate, False327+ archive_root = changes_root / "archive"328+ if archive_root.is_dir():329+ archived = archive_root / change_name330+ if archived.is_dir():331+ return archived, True332+ try:333+ for child in archive_root.iterdir():334+ if child.is_dir() and (child.name == change_name or child.name.endswith(change_name)):335+ return child, True336+ except OSError:337+ pass338+ return None, False339+340+341+def _change_detail(root: Path, change_name: str) -> Optional[dict[str, Any]]:342+ change_dir, archived = _find_change_dir(root, change_name)343+ if change_dir is None:344+ return None345+ proposal_path = change_dir / "proposal.md"346+ tasks_path = change_dir / "tasks.md"347+ design_path = change_dir / "design.md"348+ specs_root = change_dir / "specs"349+ has_tasks = tasks_path.is_file()350+ stats = _task_stats(tasks_path) if has_tasks else None351+ specs: list[dict[str, str]] = []352+ if specs_root.is_dir():353+ try:354+ for child in sorted(specs_root.rglob("*.md")):355+ rel = child.relative_to(specs_root).as_posix()356+ specs.append({"path": rel, "content": _read_doc(child) or ""})357+ except OSError:358+ pass359+ return {360+ "name": change_dir.name,361+ "title": _title_from_markdown(proposal_path, change_dir.name),362+ "status": _status(has_tasks, stats, archived=archived),363+ "archived": archived,364+ "taskStats": stats,365+ "proposal": _read_doc(proposal_path),366+ "design": _read_doc(design_path),367+ "tasks": _read_doc(tasks_path),368+ "specs": specs,369+ }370+371+372+def _idea_detail(root: Path, idea_name: str) -> Optional[dict[str, Any]]:373+ ideas_root = root / "openspec" / "ideas"374+ candidate = ideas_root / f"{idea_name}.md"375+ if not candidate.is_file():376+ return None377+ return {378+ "name": idea_name,379+ "title": _title_from_markdown(candidate, idea_name),380+ "status": "ideas",381+ "content": _read_doc(candidate) or "",382+ }383+384+385+def _spec_detail(root: Path, spec_path: str) -> Optional[dict[str, Any]]:386+ specs_root = (root / "openspec" / "specs").resolve()387+ candidate = (specs_root / spec_path).resolve()388+ if specs_root not in candidate.parents and candidate != specs_root:389+ return None390+ if not candidate.is_file():391+ return None392+ return {393+ "path": spec_path,394+ "title": _title_from_markdown(candidate, spec_path),395+ "content": _read_doc(candidate) or "",396+ }397+398+399+# ---------------------------------------------------------------------------400+# Spec browser (current / dirty / ref-diff)401+# ---------------------------------------------------------------------------402+403+def _git_run(root: Path, args: list[str], *, timeout: int = 8) -> subprocess.CompletedProcess[str]:404+ return subprocess.run(405+ ["git", "-C", str(root), *args],406+ capture_output=True,407+ text=True,408+ timeout=timeout,409+ check=False,410+ )411+412+413+def _git_output(root: Path, args: list[str], *, timeout: int = 8) -> Optional[str]:414+ try:415+ proc = _git_run(root, args, timeout=timeout)416+ except Exception:417+ return None418+ if proc.returncode != 0:419+ return None420+ return proc.stdout421+422+423+def _current_branch(root: Path) -> str:424+ return (_git_output(root, ["branch", "--show-current"], timeout=3) or "").strip()425+426+427+def _list_worktree_specs(root: Path) -> list[str]:428+ specs_root = root / "openspec" / "specs"429+ if not specs_root.is_dir():430+ return []431+ out: list[str] = []432+ try:433+ for child in sorted(specs_root.rglob("*.md")):434+ out.append(child.relative_to(specs_root).as_posix())435+ except OSError:436+ return []437+ return out438+439+440+def _list_ref_specs(root: Path, ref: str) -> list[str]:441+ ref = (ref or "").strip()442+ if not ref:443+ return []444+ # `git ls-tree` returns paths relative to repo root. No shell involved, so445+ # arbitrary refs like origin/main, HEAD~3, or full SHAs are safe arguments.446+ text = _git_output(root, ["ls-tree", "-r", "--name-only", ref, "--", "openspec/specs"], timeout=8)447+ if text is None:448+ return []449+ prefix = "openspec/specs/"450+ return sorted(451+ line[len(prefix):]452+ for line in text.splitlines()453+ if line.startswith(prefix) and line.endswith(".md")454+ )455+456+457+def _read_worktree_spec(root: Path, spec_path: str) -> Optional[str]:458+ specs_root = (root / "openspec" / "specs").resolve()459+ candidate = (specs_root / spec_path).resolve()460+ if specs_root not in candidate.parents and candidate != specs_root:461+ return None462+ if not candidate.is_file():463+ return None464+ return _read_doc(candidate) or ""465+466+467+def _worktree_spec_mtime(root: Path, spec_path: str) -> Optional[str]:468+ """Return ISO-8601 mtime for a worktree spec file, or None if unavailable."""469+ specs_root = (root / "openspec" / "specs").resolve()470+ candidate = (specs_root / spec_path).resolve()471+ if specs_root not in candidate.parents and candidate != specs_root:472+ return None473+ try:474+ return datetime.fromtimestamp(candidate.stat().st_mtime, tz=timezone.utc).isoformat()475+ except OSError:476+ return None477+478+479+def _spec_git_mtimes(root: Path) -> dict[str, str]:480+ """Return ``{rel_path: iso_date}`` for the last commit touching each spec.481+482+ Uses a single ``git log`` call and returns the most recent commit date483+ per file. Falls back to an empty dict if git is unavailable.484+ """485+ text = _git_output(486+ root,487+ ["log", "--no-merges", "--format=%cI", "--name-only", "--", "openspec/specs/"],488+ timeout=10,489+ )490+ if not text:491+ return {}492+ prefix = "openspec/specs/"493+ mtimes: dict[str, str] = {}494+ current_date: Optional[str] = None495+ for line in text.splitlines():496+ if not line:497+ continue498+ if line.startswith(prefix):499+ rel = line[len(prefix):]500+ if rel.endswith(".md") and current_date and rel not in mtimes:501+ mtimes[rel] = current_date502+ else:503+ current_date = line.strip()504+ return mtimes505+506+507+def _read_ref_spec(root: Path, ref: str, spec_path: str) -> Optional[str]:508+ ref = (ref or "").strip()509+ if not ref:510+ return None511+ if spec_path.startswith("/") or ".." in Path(spec_path).parts:512+ return None513+ return _git_output(root, ["show", f"{ref}:openspec/specs/{spec_path}"], timeout=8)514+515+516+def _spec_title_from_content(content: Optional[str], fallback: str) -> str:517+ for line in (content or "").splitlines():518+ stripped = line.strip()519+ if stripped.startswith("# "):520+ return stripped[2:].strip() or fallback521+ return fallback522+523+524+def _spec_diff(before: Optional[str], after: Optional[str], path: str) -> str:525+ return "\n".join(526+ difflib.unified_diff(527+ (before or "").splitlines(),528+ (after or "").splitlines(),529+ fromfile=f"before/{path}",530+ tofile=f"after/{path}",531+ lineterm="",532+ )533+ )534+535+536+def _spec_status(before: Optional[str], after: Optional[str]) -> str:537+ if before is None and after is None:538+ return "missing"539+ if before is None:540+ return "added"541+ if after is None:542+ return "deleted"543+ if before != after:544+ return "modified"545+ return "unchanged"546+547+548+def _spec_browser(root: Path, before: str = "", after: str = "", dirty: bool = False) -> dict[str, Any]:549+ before_ref = (before or "").strip()550+ after_ref = (after or "").strip()551+ dirty_mode = bool(dirty)552+553+ if dirty_mode:554+ before_ref = before_ref or "HEAD"555+ after_label = "working tree"556+ before_paths = _list_ref_specs(root, before_ref)557+ after_paths = _list_worktree_specs(root)558+ mode = "dirty"559+ elif before_ref or after_ref:560+ if not before_ref or not after_ref:561+ raise HTTPException(status_code=400, detail="both before and after are required for ref diff")562+ after_label = after_ref563+ before_paths = _list_ref_specs(root, before_ref)564+ after_paths = _list_ref_specs(root, after_ref)565+ mode = "refs"566+ else:567+ after_label = "working tree"568+ before_paths = []569+ after_paths = _list_worktree_specs(root)570+ mode = "current"571+572+ files: list[dict[str, Any]] = []573+ # Pre-fetch git commit dates for all specs in one call (current/dirty modes).574+ git_mtimes = _spec_git_mtimes(root) if mode in ("current", "dirty") else {}575+ for rel in sorted(set(before_paths) | set(after_paths)):576+ if mode == "current":577+ # No comparison — just showing the current worktree state.578+ after_content = _read_worktree_spec(root, rel)579+ files.append({580+ "path": rel,581+ "token": _change_token(f"spec:{rel}"),582+ "title": _spec_title_from_content(after_content, rel),583+ "status": "current" if after_content is not None else "missing",584+ "changed": False,585+ "mtime": git_mtimes.get(rel) or _worktree_spec_mtime(root, rel),586+ "before": None,587+ "after": after_content,588+ "diff": "",589+ })590+ continue591+ before_content = _read_ref_spec(root, before_ref, rel) if before_ref else None592+ if dirty_mode:593+ after_content = _read_worktree_spec(root, rel)594+ mtime = git_mtimes.get(rel) or _worktree_spec_mtime(root, rel)595+ else:596+ after_content = _read_ref_spec(root, after_ref, rel)597+ mtime = None598+ status = _spec_status(before_content, after_content)599+ files.append({600+ "path": rel,601+ "token": _change_token(f"spec:{rel}"),602+ "title": _spec_title_from_content(after_content if after_content is not None else before_content, rel),603+ "status": status,604+ "changed": status not in {"unchanged", "missing"},605+ "mtime": mtime,606+ "before": before_content,607+ "after": after_content,608+ "diff": _spec_diff(before_content, after_content, rel) if status not in {"unchanged", "missing"} else "",609+ })610+611+ return {612+ "mode": mode,613+ "branch": _current_branch(root),614+ "before": before_ref or None,615+ "after": after_label,616+ "files": files,617+ "changedCount": sum(1 for item in files if item["changed"]),618+ }619+620+621+# ---------------------------------------------------------------------------622+# Source resolution623+# ---------------------------------------------------------------------------624+625+class _SourceCreate(BaseModel):626+ path: str627+ name: Optional[str] = None628+629+630+def _resolve_source(source_id: str) -> tuple[dict[str, Any], Path]:631+ if _registry is None:632+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")633+ source = _registry.get_source(source_id)634+ if source is None:635+ raise HTTPException(status_code=404, detail="Source not found")636+ root, error = _source_root(source["path"])637+ if root is None:638+ raise HTTPException(status_code=400, detail=error or "Source is invalid")639+ return source, root640+641+642+# ---------------------------------------------------------------------------643+# Legacy config migration (one-time, idempotent)644+# ---------------------------------------------------------------------------645+646+_migration_done = False647+648+649+def _registered_sources() -> list[dict[str, Any]]:650+ global _migration_done651+ if _registry is None:652+ return []653+ if not _migration_done:654+ _migration_done = True655+ try:656+ from hermes_cli.config import load_config657+ dashboard = (load_config() or {}).get("dashboard") or {}658+ legacy = dashboard.get("openspec_sources")659+ if isinstance(legacy, list) and legacy:660+ _registry.migrate_from_config_sources(legacy)661+ except Exception:662+ pass663+ return _registry.list_sources()664+665+666+# ---------------------------------------------------------------------------667+# Routes668+# ---------------------------------------------------------------------------669+670+671+def list_sources():672+ return {"sources": [_source_payload(s) for s in _registered_sources()]}673+674+675+676+def add_source(body: _SourceCreate):677+ if _registry is None:678+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")679+ raw_path = str(body.path or "").strip()680+ if not raw_path:681+ raise HTTPException(status_code=400, detail="path is required")682+ root, error = _source_root(raw_path)683+ if root is None:684+ raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")685+ try:686+ source = _registry.add_source(raw_path, str(body.name or "").strip())687+ except ValueError:688+ raise HTTPException(status_code=409, detail="Source already registered")689+ return {"ok": True, "source": _source_payload(source)}690+691+692+693+def remove_source(source_id: str):694+ if _registry is None:695+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")696+ if not _registry.remove_source(source_id):697+ raise HTTPException(status_code=404, detail="Source not found")698+ return {"ok": True}699+700+701+702+def update_source(source_id: str, body: _SourceCreate):703+ if _registry is None:704+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")705+ raw_path = str(body.path or "").strip()706+ if not raw_path:707+ raise HTTPException(status_code=400, detail="path is required")708+ root, error = _source_root(raw_path)709+ if root is None:710+ raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")711+ try:712+ source = _registry.update_source(source_id, path=raw_path, name=str(body.name or "").strip())713+ except ValueError:714+ raise HTTPException(status_code=409, detail="Source already registered")715+ if source is None:716+ raise HTTPException(status_code=404, detail="Source not found")717+ return {"ok": True, "source": _source_payload(source)}718+719+720+721+def change_detail(source_id: str, change_name: str):722+ _source, root = _resolve_source(source_id)723+ detail = _change_detail(root, change_name)724+ if detail is None:725+ raise HTTPException(status_code=404, detail="Change not found")726+ return detail727+728+729+730+def idea_detail(source_id: str, idea_name: str):731+ _source, root = _resolve_source(source_id)732+ detail = _idea_detail(root, idea_name)733+ if detail is None:734+ raise HTTPException(status_code=404, detail="Idea not found")735+ return detail736+737+738+739+def spec_browser(740+ source_id: str,741+ before: str = "",742+ after: str = "",743+ dirty: bool = False,744+):745+ _source, root = _resolve_source(source_id)746+ return _spec_browser(root, before=before, after=after, dirty=dirty)747+748+749+750+def spec_detail(source_id: str, path: str = Query(...)):751+ _source, root = _resolve_source(source_id)752+ detail = _spec_detail(root, path)753+ if detail is None:754+ raise HTTPException(status_code=404, detail="Spec not found")755+ return detail@@ -0,0 +1,236 @@
1+"""SQLite-backed registry of OpenSpec sources (repos with an ``openspec/`` dir).2+3+Each registered source keeps a stable internal token, but the user-facing copy4+format is the source's vanity name (for example ``puzzletea``) plus a derived5+change token (for example ``puzzletea/os_a1b2c3``). The OpenSpec plugin's6+``openspec_context`` tool resolves the vanity name back to the repo path and7+maps the change token to the matching change folder.8+9+The DB lives at ``<hermes_home>/openspec.db`` and is shared between the dashboard10+plugin (which owns source CRUD via its ``plugin_api.py`` routes) and the agent11+plugin (which only reads to resolve sources). Schema is intentionally tiny —12+one row per source. Individual changes are NOT stored; they are addressed live13+by deterministic tokens derived from their folder names, so the registry never14+drifts from the files on disk.15+"""16+17+from __future__ import annotations18+19+import hashlib20+import secrets21+import sqlite322+import time23+from pathlib import Path24+from typing import Any, Optional25+26+from hermes_constants import get_hermes_home27+28+_TOKEN_PREFIX = "os_"29+_TOKEN_NBYTES = 3 # 6 hex chars30+_CHANGE_TOKEN_HEXLEN = 6 # os_ + 6 hex chars, derived from the change name31+32+33+def db_path() -> Path:34+ """Absolute path to the OpenSpec registry DB for the active Hermes home."""35+ return get_hermes_home() / "openspec.db"36+37+38+def _connect() -> sqlite3.Connection:39+ path = db_path()40+ path.parent.mkdir(parents=True, exist_ok=True)41+ conn = sqlite3.connect(str(path), timeout=10.0)42+ conn.row_factory = sqlite3.Row43+ conn.execute("PRAGMA journal_mode=WAL")44+ conn.execute("PRAGMA busy_timeout=10000")45+ conn.execute(46+ """47+ CREATE TABLE IF NOT EXISTS openspec_sources (48+ token TEXT PRIMARY KEY,49+ name TEXT NOT NULL DEFAULT '',50+ path TEXT NOT NULL,51+ created_at REAL NOT NULL52+ )53+ """54+ )55+ conn.execute(56+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_openspec_sources_path ON openspec_sources(path)"57+ )58+ return conn59+60+61+def _gen_token(conn: sqlite3.Connection) -> str:62+ for _ in range(64):63+ token = _TOKEN_PREFIX + secrets.token_hex(_TOKEN_NBYTES)64+ exists = conn.execute(65+ "SELECT 1 FROM openspec_sources WHERE token = ?", (token,)66+ ).fetchone()67+ if not exists:68+ return token69+ # Astronomically unlikely; widen the space rather than fail.70+ return _TOKEN_PREFIX + secrets.token_hex(_TOKEN_NBYTES + 2)71+72+73+def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:74+ return {75+ "token": row["token"],76+ "id": row["token"], # back-compat alias for existing dashboard code77+ "name": row["name"] or "",78+ "path": row["path"],79+ "created_at": row["created_at"],80+ }81+82+83+def list_sources() -> list[dict[str, Any]]:84+ """All registered sources, oldest first."""85+ with _connect() as conn:86+ rows = conn.execute(87+ "SELECT * FROM openspec_sources ORDER BY created_at ASC"88+ ).fetchall()89+ return [_row_to_dict(r) for r in rows]90+91+92+def get_source(token: str) -> Optional[dict[str, Any]]:93+ token = (token or "").strip()94+ if not token:95+ return None96+ with _connect() as conn:97+ row = conn.execute(98+ "SELECT * FROM openspec_sources WHERE token = ?", (token,)99+ ).fetchone()100+ return _row_to_dict(row) if row else None101+102+103+def effective_name(source: dict[str, Any]) -> str:104+ """The human-facing vanity name for a source: stored name or path basename."""105+ return (source.get("name") or "").strip() or Path(source["path"]).expanduser().name106+107+108+def get_source_by_name(name: str) -> Optional[dict[str, Any]]:109+ """Look up a source by its vanity name (case-insensitive). First match wins.110+111+ Matches against ``effective_name`` so a source registered without an explicit112+ display name still resolves by its path basename.113+ """114+ key = (name or "").strip().lower()115+ if not key:116+ return None117+ for source in list_sources():118+ if effective_name(source).lower() == key:119+ return source120+ return None121+122+123+def change_token(change_name: str) -> str:124+ """Deterministic, opaque token for a change, derived from its folder name.125+126+ Stable across restarts and never stored — the resolver recomputes it for each127+ change on disk and matches, so the DB can never drift from the filesystem.128+ """129+ digest = hashlib.sha256((change_name or "").encode("utf-8")).hexdigest()130+ return _TOKEN_PREFIX + digest[:_CHANGE_TOKEN_HEXLEN]131+132+133+def get_source_by_path(path: str) -> Optional[dict[str, Any]]:134+ normalized = str(Path(path).expanduser())135+ with _connect() as conn:136+ row = conn.execute(137+ "SELECT * FROM openspec_sources WHERE path = ?", (normalized,)138+ ).fetchone()139+ return _row_to_dict(row) if row else None140+141+142+def add_source(path: str, name: str = "") -> dict[str, Any]:143+ """Register a source. Raises ValueError if the path is already registered."""144+ normalized = str(Path(path).expanduser())145+ with _connect() as conn:146+ existing = conn.execute(147+ "SELECT * FROM openspec_sources WHERE path = ?", (normalized,)148+ ).fetchone()149+ if existing:150+ raise ValueError("Source already registered")151+ token = _gen_token(conn)152+ conn.execute(153+ "INSERT INTO openspec_sources (token, name, path, created_at) VALUES (?, ?, ?, ?)",154+ (token, (name or "").strip(), normalized, time.time()),155+ )156+ conn.commit()157+ row = conn.execute(158+ "SELECT * FROM openspec_sources WHERE token = ?", (token,)159+ ).fetchone()160+ return _row_to_dict(row)161+162+163+def update_source(token: str, *, path: str, name: str = "") -> Optional[dict[str, Any]]:164+ """Update a registered source. Raises ValueError if the new path conflicts."""165+ token = (token or "").strip()166+ normalized = str(Path(path).expanduser())167+ if not token or not normalized:168+ return None169+ with _connect() as conn:170+ current = conn.execute(171+ "SELECT * FROM openspec_sources WHERE token = ?", (token,)172+ ).fetchone()173+ if current is None:174+ return None175+ conflict = conn.execute(176+ "SELECT token FROM openspec_sources WHERE path = ? AND token != ?",177+ (normalized, token),178+ ).fetchone()179+ if conflict:180+ raise ValueError("Source already registered")181+ conn.execute(182+ "UPDATE openspec_sources SET name = ?, path = ? WHERE token = ?",183+ ((name or "").strip(), normalized, token),184+ )185+ conn.commit()186+ row = conn.execute(187+ "SELECT * FROM openspec_sources WHERE token = ?", (token,)188+ ).fetchone()189+ return _row_to_dict(row) if row else None190+191+192+def remove_source(token: str) -> bool:193+ token = (token or "").strip()194+ if not token:195+ return False196+ with _connect() as conn:197+ cur = conn.execute("DELETE FROM openspec_sources WHERE token = ?", (token,))198+ conn.commit()199+ return cur.rowcount > 0200+201+202+def parse_identifier(identifier: str) -> tuple[str, Optional[str]]:203+ """Split a pasted identifier into ``(source, change_token | None)``.204+205+ Accepts a vanity name (``puzzletea``) or ``puzzletea/os_a1b2c3``. The first206+ segment locates the source (by vanity name, or legacy ``os_`` source token);207+ everything after the first ``/`` is the change token. The resolver maps the208+ change token back to a change folder by recomputing ``change_token`` for each209+ change on disk, so nothing about changes is ever stored.210+ """211+ raw = (identifier or "").strip()212+ if "/" in raw:213+ source, _, change = raw.partition("/")214+ return source.strip(), (change.strip() or None)215+ return raw, None216+217+218+def migrate_from_config_sources(config_sources: list[dict[str, Any]]) -> int:219+ """One-time import of legacy ``config.yaml`` ``dashboard.openspec_sources``.220+221+ Returns the number of rows inserted. Idempotent: paths already present are222+ skipped, so calling it repeatedly is safe.223+ """224+ inserted = 0225+ for item in config_sources or []:226+ if not isinstance(item, dict):227+ continue228+ path = str(item.get("path") or "").strip()229+ if not path:230+ continue231+ try:232+ add_source(path, str(item.get("name") or "").strip())233+ inserted += 1234+ except ValueError:235+ continue # already migrated236+ return inserted@@ -0,0 +1,163 @@
1+"""OpenSpec Hermes tool schemas."""2+3+WORKDIR_PROP = {4+ "type": "string",5+ "description": (6+ "Project directory containing the openspec/ folder. Defaults to the "7+ "agent/session working directory. Use an absolute path when known."8+ ),9+}10+11+OPENSPEC_CONTEXT = {12+ "name": "openspec_context",13+ "description": (14+ "Resolve a copyable OpenSpec identifier into working context. Paste an "15+ "identifier like 'puzzletea' (a registered repo, by its vanity name) or "16+ "'puzzletea/os_a1b2c3' (a specific change or spec within it) and this returns the "17+ "repo path plus the change proposal/tasks/design/specs content, or the spec content. "18+ "The bare repo name lists active changes and current specs with their tokens. Use the "19+ "returned 'path' as 'workdir' for the other openspec_* tools. Call this "20+ "first whenever the user gives you an OpenSpec identifier."21+ ),22+ "parameters": {23+ "type": "object",24+ "properties": {25+ "identifier": {26+ "type": "string",27+ "description": "A repo vanity name ('puzzletea') or name/change-token ('puzzletea/os_a1b2c3').",28+ },29+ },30+ "required": ["identifier"],31+ },32+}33+34+35+OPENSPEC_LIST = {36+ "name": "openspec_list",37+ "description": (38+ "List OpenSpec changes or specs in a project. Use before choosing a "39+ "change/spec to inspect, validate, or coordinate with kanban tasks."40+ ),41+ "parameters": {42+ "type": "object",43+ "properties": {44+ "workdir": WORKDIR_PROP,45+ "kind": {46+ "type": "string",47+ "enum": ["changes", "specs"],48+ "description": "Which OpenSpec items to list. Defaults to changes.",49+ },50+ "sort": {51+ "type": "string",52+ "enum": ["recent", "name"],53+ "description": "Sort order. Defaults to recent.",54+ },55+ },56+ },57+}58+59+OPENSPEC_SHOW = {60+ "name": "openspec_show",61+ "description": (62+ "Show an OpenSpec change or spec as JSON. Use to read proposals, tasks, "63+ "designs, specs, deltas, and requirement/scenario content."64+ ),65+ "parameters": {66+ "type": "object",67+ "properties": {68+ "workdir": WORKDIR_PROP,69+ "name": {"type": "string", "description": "Change or spec name to show."},70+ "type": {71+ "type": "string",72+ "enum": ["change", "spec"],73+ "description": "Disambiguate item type when needed.",74+ },75+ "deltas_only": {76+ "type": "boolean",77+ "description": "For changes, return only requirement deltas.",78+ },79+ "requirements_only": {80+ "type": "boolean",81+ "description": "For JSON output, omit scenarios and return requirement text only.",82+ },83+ "no_scenarios": {84+ "type": "boolean",85+ "description": "For JSON output, exclude scenario content.",86+ },87+ "requirement": {88+ "type": "string",89+ "description": "Optional 1-based requirement id to show, matching openspec --requirement.",90+ },91+ },92+ "required": ["name"],93+ },94+}95+96+OPENSPEC_VALIDATE = {97+ "name": "openspec_validate",98+ "description": (99+ "Validate OpenSpec artifacts. Use after editing specs/proposals/tasks or "100+ "before completing kanban tasks tied to OpenSpec work."101+ ),102+ "parameters": {103+ "type": "object",104+ "properties": {105+ "workdir": WORKDIR_PROP,106+ "target": {107+ "type": "string",108+ "description": "Specific change/spec name to validate. Omit when using scope.",109+ },110+ "scope": {111+ "type": "string",112+ "enum": ["target", "all", "changes", "specs"],113+ "description": "Validation scope. Defaults to target if target is supplied, otherwise all.",114+ },115+ "type": {116+ "type": "string",117+ "enum": ["change", "spec"],118+ "description": "Disambiguate target type when needed.",119+ },120+ "strict": {121+ "type": "boolean",122+ "description": "Enable strict validation. Defaults to true.",123+ },124+ },125+ },126+}127+128+OPENSPEC_STATUS = {129+ "name": "openspec_status",130+ "description": (131+ "Show artifact completion status for an OpenSpec change as JSON. Useful "132+ "for turning proposal/task progress into kanban updates."133+ ),134+ "parameters": {135+ "type": "object",136+ "properties": {137+ "workdir": WORKDIR_PROP,138+ "change": {"type": "string", "description": "OpenSpec change id/name."},139+ "schema": {"type": "string", "description": "Optional schema override."},140+ },141+ "required": ["change"],142+ },143+}144+145+OPENSPEC_INSTRUCTIONS = {146+ "name": "openspec_instructions",147+ "description": (148+ "Return enriched OpenSpec instructions for creating an artifact or "149+ "applying tasks. Use before implementing a spec-driven change."150+ ),151+ "parameters": {152+ "type": "object",153+ "properties": {154+ "workdir": WORKDIR_PROP,155+ "artifact": {156+ "type": "string",157+ "description": "Artifact/instruction name, e.g. proposal, design, tasks, spec, apply.",158+ },159+ "change": {"type": "string", "description": "Optional change id/name."},160+ "schema": {"type": "string", "description": "Optional schema override."},161+ },162+ },163+}@@ -0,0 +1,350 @@
1+"""OpenSpec CLI-backed Hermes tool handlers."""2+3+from __future__ import annotations4+5+import json6+import os7+import shutil8+import subprocess9+from pathlib import Path10+from typing import Any11+12+13+MAX_OUTPUT_CHARS = 50_00014+15+16+def _openspec_bin() -> str | None:17+ configured = os.getenv("OPENSPEC_BIN", "").strip()18+ candidates = [19+ configured,20+ shutil.which("openspec") or "",21+ str(Path.home() / ".npm-global" / "bin" / "openspec"),22+ ]23+ for candidate in candidates:24+ if candidate and Path(candidate).exists() and os.access(candidate, os.X_OK):25+ return candidate26+ return None27+28+29+def _resolve_workdir(value: Any) -> tuple[Path | None, str | None]:30+ raw = str(value or "").strip()31+ if not raw:32+ return Path.cwd(), None33+ p = Path(raw).expanduser()34+ if not p.is_absolute():35+ p = (Path.cwd() / p).resolve()36+ if not p.exists() or not p.is_dir():37+ return None, f"workdir does not exist or is not a directory: {p}"38+ return p, None39+40+41+def _json_or_text(text: str) -> Any:42+ stripped = text.strip()43+ if not stripped:44+ return ""45+ try:46+ return json.loads(stripped)47+ except Exception:48+ return stripped49+50+51+def _run(args: list[str], workdir: Any = None) -> str:52+ exe = _openspec_bin()53+ if not exe:54+ return json.dumps({55+ "ok": False,56+ "error": "openspec executable not found. Install OpenSpec or set OPENSPEC_BIN.",57+ })58+59+ cwd, err = _resolve_workdir(workdir)60+ if err:61+ return json.dumps({"ok": False, "error": err})62+63+ env = os.environ.copy()64+ env.setdefault("OPENSPEC_TELEMETRY", "0")65+ env.setdefault("NO_COLOR", "1")66+67+ cmd = [exe, *args]68+ try:69+ proc = subprocess.run(70+ cmd,71+ cwd=str(cwd),72+ env=env,73+ text=True,74+ stdout=subprocess.PIPE,75+ stderr=subprocess.PIPE,76+ timeout=120,77+ check=False,78+ )79+ except subprocess.TimeoutExpired:80+ return json.dumps({81+ "ok": False,82+ "command": cmd,83+ "workdir": str(cwd),84+ "error": "openspec command timed out after 120s",85+ })86+ except Exception as exc:87+ return json.dumps({88+ "ok": False,89+ "command": cmd,90+ "workdir": str(cwd),91+ "error": str(exc),92+ })93+94+ stdout = proc.stdout[-MAX_OUTPUT_CHARS:]95+ stderr = proc.stderr[-MAX_OUTPUT_CHARS:]96+ return json.dumps({97+ "ok": proc.returncode == 0,98+ "exit_code": proc.returncode,99+ "command": cmd,100+ "workdir": str(cwd),101+ "stdout": _json_or_text(stdout),102+ "stderr": _json_or_text(stderr),103+ "truncated": len(proc.stdout) > MAX_OUTPUT_CHARS or len(proc.stderr) > MAX_OUTPUT_CHARS,104+ })105+106+107+def _registry_module():108+ """Import the plugin-local OpenSpec registry (``plugins.openspec.registry``).109+110+ Tolerates odd sys.path setups where the package isn't importable by name;111+ in that case fall back to a path-based load from this file's location.112+ """113+ try:114+ from . import registry # type: ignore115+ return registry116+ except Exception:117+ pass118+ try:119+ import importlib.util120+ candidate = Path(__file__).resolve().parent / "registry.py"121+ if candidate.is_file():122+ spec = importlib.util.spec_from_file_location("openspec_registry", candidate)123+ if spec and spec.loader:124+ mod = importlib.util.module_from_spec(spec)125+ spec.loader.exec_module(mod)126+ return mod127+ except Exception:128+ pass129+ return None130+131+132+def _read_doc(path: Path) -> str:133+ try:134+ if path.is_file():135+ return path.read_text(encoding="utf-8", errors="replace")136+ except OSError:137+ pass138+ return ""139+140+141+def _iter_change_dirs(root: Path):142+ """Yield ``(change_dir, archived)`` for every change folder with a proposal."""143+ changes_root = root / "openspec" / "changes"144+ if changes_root.is_dir():145+ for child in sorted(changes_root.iterdir()):146+ if child.name == "archive":147+ continue148+ if child.is_dir() and (child / "proposal.md").is_file():149+ yield child, False150+ archive_root = changes_root / "archive"151+ if archive_root.is_dir():152+ for child in sorted(archive_root.iterdir()):153+ if child.is_dir() and (child / "proposal.md").is_file():154+ yield child, True155+156+157+def _resolve_change(root: Path, registry, change_ref: str) -> tuple[Path | None, bool]:158+ """Map a change token (``os_xxx``) — or a literal change name (legacy) — to a159+ change folder under ``root``. Returns ``(change_dir, archived)``."""160+ for change_dir, archived in _iter_change_dirs(root):161+ if registry.change_token(change_dir.name) == change_ref or change_dir.name == change_ref:162+ return change_dir, archived163+ return None, False164+165+166+def _iter_specs(root: Path):167+ specs_root = root / "openspec" / "specs"168+ if specs_root.is_dir():169+ for child in sorted(specs_root.rglob("*.md")):170+ if child.is_file():171+ yield child.relative_to(specs_root).as_posix(), child172+173+174+def _resolve_spec(root: Path, registry, spec_ref: str) -> tuple[str | None, Path | None]:175+ for rel, path in _iter_specs(root):176+ if registry.change_token(f"spec:{rel}") == spec_ref or rel == spec_ref:177+ return rel, path178+ return None, None179+180+181+def openspec_context(args: dict, **kwargs) -> str:182+ """Resolve a copyable identifier (``puzzletea`` or ``puzzletea/os_xxx``) into183+ repo path + (optionally) the change's proposal/tasks/design/specs content."""184+ registry = _registry_module()185+ if registry is None:186+ return json.dumps({187+ "ok": False,188+ "error": "OpenSpec registry unavailable (plugins.openspec.registry not importable).",189+ })190+191+ identifier = str(args.get("identifier") or "").strip()192+ if not identifier:193+ return json.dumps({"ok": False, "error": "identifier is required (e.g. 'puzzletea' or 'puzzletea/os_a1b2c3')"})194+195+ source_ref, change_ref = registry.parse_identifier(identifier)196+ # Resolve source by vanity name first, then fall back to a legacy source token.197+ source = registry.get_source_by_name(source_ref) or registry.get_source(source_ref)198+ if source is None:199+ return json.dumps({"ok": False, "error": f"No registered OpenSpec source for '{source_ref}'."})200+201+ name = registry.effective_name(source)202+ repo_path = Path(source["path"]).expanduser()203+ if not (repo_path / "openspec").is_dir():204+ return json.dumps({205+ "ok": False,206+ "name": name,207+ "path": str(repo_path),208+ "error": f"openspec/ directory not found at {repo_path} (repo may have moved).",209+ })210+211+ result: dict[str, Any] = {212+ "ok": True,213+ "name": name,214+ "path": str(repo_path),215+ "workdir": str(repo_path),216+ "hint": "Use this path as 'workdir' for other openspec_* tools, or run openspec commands from here.",217+ }218+219+ if change_ref:220+ change_dir, archived = _resolve_change(repo_path, registry, change_ref)221+ if change_dir is None:222+ spec_rel, spec_path = _resolve_spec(repo_path, registry, change_ref)223+ if spec_rel and spec_path:224+ result["spec"] = {225+ "path": spec_rel,226+ "token": registry.change_token(f"spec:{spec_rel}"),227+ "content": _read_doc(spec_path),228+ }229+ return json.dumps(result)230+ return json.dumps({231+ "ok": False,232+ "name": name,233+ "path": str(repo_path),234+ "error": f"No change or spec matching '{change_ref}' under openspec/.",235+ })236+ specs_root = change_dir / "specs"237+ specs = []238+ if specs_root.is_dir():239+ for child in sorted(specs_root.rglob("*.md")):240+ specs.append({241+ "path": child.relative_to(specs_root).as_posix(),242+ "content": _read_doc(child),243+ })244+ result["change"] = {245+ "name": change_dir.name,246+ "token": registry.change_token(change_dir.name),247+ "archived": archived,248+ "proposal": _read_doc(change_dir / "proposal.md"),249+ "tasks": _read_doc(change_dir / "tasks.md"),250+ "design": _read_doc(change_dir / "design.md"),251+ "specs": specs,252+ }253+ else:254+ changes = [255+ {"name": change_dir.name, "token": registry.change_token(change_dir.name)}256+ for change_dir, archived in _iter_change_dirs(repo_path)257+ if not archived258+ ]259+ specs = [260+ {"path": rel, "token": registry.change_token(f"spec:{rel}")}261+ for rel, _path in _iter_specs(repo_path)262+ ]263+ result["changes"] = changes264+ result["specs"] = specs265+ result["hint"] = (266+ "Active changes are listed in 'changes'; current specs are listed in 'specs'. Re-call with identifier "267+ f"'{name}/<token>' to load a specific change or spec, or use workdir with other openspec_* tools."268+ )269+270+ return json.dumps(result)271+272+273+def openspec_list(args: dict, **kwargs) -> str:274+ cmd = ["list", "--json"]275+ kind = str(args.get("kind") or "changes").strip().lower()276+ if kind == "specs":277+ cmd.append("--specs")278+ else:279+ cmd.append("--changes")280+ sort = str(args.get("sort") or "").strip().lower()281+ if sort in {"recent", "name"}:282+ cmd.extend(["--sort", sort])283+ return _run(cmd, args.get("workdir"))284+285+286+def openspec_show(args: dict, **kwargs) -> str:287+ name = str(args.get("name") or "").strip()288+ if not name:289+ return json.dumps({"ok": False, "error": "name is required"})290+ cmd = ["show", name, "--json", "--no-interactive"]291+ item_type = str(args.get("type") or "").strip().lower()292+ if item_type in {"change", "spec"}:293+ cmd.extend(["--type", item_type])294+ if args.get("deltas_only"):295+ cmd.append("--deltas-only")296+ if args.get("requirements_only"):297+ cmd.append("--requirements")298+ if args.get("no_scenarios"):299+ cmd.append("--no-scenarios")300+ requirement = str(args.get("requirement") or "").strip()301+ if requirement:302+ cmd.extend(["--requirement", requirement])303+ return _run(cmd, args.get("workdir"))304+305+306+def openspec_validate(args: dict, **kwargs) -> str:307+ cmd = ["validate", "--json", "--no-interactive"]308+ if args.get("strict", True):309+ cmd.append("--strict")310+ scope = str(args.get("scope") or "").strip().lower()311+ target = str(args.get("target") or "").strip()312+ if scope == "all" or (not target and not scope):313+ cmd.append("--all")314+ elif scope == "changes":315+ cmd.append("--changes")316+ elif scope == "specs":317+ cmd.append("--specs")318+ elif target:319+ cmd.append(target)320+ else:321+ return json.dumps({"ok": False, "error": "target is required when scope='target'"})322+ item_type = str(args.get("type") or "").strip().lower()323+ if item_type in {"change", "spec"}:324+ cmd.extend(["--type", item_type])325+ return _run(cmd, args.get("workdir"))326+327+328+def openspec_status(args: dict, **kwargs) -> str:329+ change = str(args.get("change") or "").strip()330+ if not change:331+ return json.dumps({"ok": False, "error": "change is required"})332+ cmd = ["status", "--change", change, "--json"]333+ schema = str(args.get("schema") or "").strip()334+ if schema:335+ cmd.extend(["--schema", schema])336+ return _run(cmd, args.get("workdir"))337+338+339+def openspec_instructions(args: dict, **kwargs) -> str:340+ cmd = ["instructions", "--json"]341+ artifact = str(args.get("artifact") or "").strip()342+ if artifact:343+ cmd.append(artifact)344+ change = str(args.get("change") or "").strip()345+ if change:346+ cmd.extend(["--change", change])347+ schema = str(args.get("schema") or "").strip()348+ if schema:349+ cmd.extend(["--schema", schema])350+ return _run(cmd, args.get("workdir"))