clone

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

Merge branch 'dev'

dami etoile committed 3bcac29
commit
3bcac292dbd58b48fd683cbe8e2e493f592c7505
parent
bd204017a37e5d299335b137bdd96e1dfa75d2bc
parent
d8f0a4d2e1f14517c9e2b1700c46ebb3f5a8e8b1
author
dami etoile <dami.etoile@gmail.com>
11 files changed, +2656 -0
A.gitignore4+
AREADME.md119+++++
A__init__.py40++
Adashboard/dist/index.js777++++++++++++++++++++++++++++++
Adashboard/dist/style.css172+++++++
Adashboard/manifest.json14+
Adashboard/plugin_api.py771++++++++++++++++++++++++++++++
Aplugin.yaml10+
Aregistry.py236+++++++++
Aschemas.py163++++++
Atools.py350++++++++++++++
A .gitignore +4 -0
A README.md +119 -0
@@ -0,0 +1,119 @@
1+# hermes-openspec2+3+OpenSpec integration plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — spec-driven development tools and a dashboard tab for browsing change proposals, specs, and branch diffs across registered repos.4+5+## Why this exists6+7+OpenSpec keeps spec files (`openspec/changes/`, `openspec/specs/`, `openspec/ideas/`) inside the repo alongside code. But there's no way to browse those specs from the Hermes dashboard, and the agent has no native tools to resolve OpenSpec identifiers or run OpenSpec CLI commands.8+9+This plugin closes both gaps:10+11+- **Agent tools** — six tools that wrap the `openspec` CLI so the agent can list, read, validate, and track OpenSpec changes and specs without shelling out manually. The tools are gated behind the CLI binary's availability, so they stay invisible if OpenSpec isn't installed.12+- **Dashboard tab** — a `/openspec` tab in the Hermes dashboard where you register repos, drill into change proposals (tasks, designs, specs, deltas), browse current specs, and compare branch diffs — all without leaving the dashboard.13+14+## What you get15+16+**Agent tools** (gated on `openspec` binary):17+18+| Tool | Does |19+|---|---|20+| `openspec_context` | Resolve a copyable `os_*` identifier (e.g. `puzzletea/os_a1b2c3`) into repo path + change/spec content. Entry point — call this first. |21+| `openspec_list` | List changes or specs in a project, sorted by recent or name. |22+| `openspec_show` | Show a change or spec as JSON — proposals, tasks, designs, deltas, requirements. |23+| `openspec_validate` | Validate OpenSpec changes or specs in a project. |24+| `openspec_status` | Show artifact completion status for a change (turns into kanban updates). |25+| `openspec_instructions` | Enriched instructions for creating artifacts or applying tasks. |26+27+`openspec_context` is always available (reads the registry DB + files only); the other five require the `openspec` CLI binary.28+29+**Dashboard tab** (`/openspec`):30+31+- Register repos by path — each gets a vanity name and stable tokens (`name/os_a1b2c3`).32+- **Changes view** — Kanban-style board: ideas → draft → todo → in_progress → done → archived. Click any change to read its proposal, tasks, design, and spec deltas.33+- **Specs view** — browse current specs in the worktree, sorted alphabetically or by last git commit date. Compare against HEAD (dirty mode) or arbitrary git refs (before/after).34+- Deep-linking via URL hash (`#project-name/token`).35+36+![Change board](http://clip.sandboxd.internal/files/013422f52f55fa99bb41f2aa6e9d1afc)37+38+![Spec browser](http://clip.sandboxd.internal/files/4c17f0ecfbd3552a93bfa5e34ce1d98f)39+40+## Requirements41+42+- **Hermes Agent** — any recent build that supports the plugin system (`hermes plugins install`).43+- **OpenSpec CLI** (optional) — needed for five of the six agent tools. The dashboard tab works without it. Install via `npm install -g @fission-ai/openspec@latest` or set `OPENSPEC_BIN` to the binary path.44+- **Git** — used for branch diffs and spec commit-date sorting.45+46+## Quickstart47+48+```bash49+# Install the plugin50+hermes plugins install FelineStateMachine/hermes-openspec51+52+# Enable it (prompts automatically during install, or run explicitly)53+hermes plugins enable openspec54+```55+56+Restart Hermes if it's running. The OpenSpec tab appears in the dashboard at `/openspec`.57+58+To register a repo in the dashboard, open the OpenSpec tab and click **Add source**, or use the agent tool:59+60+```61+openspec_context(identifier="/path/to/your/repo")62+```63+64+The repo needs an `openspec/` directory. Once registered, the dashboard shows changes and specs live from the filesystem.65+66+## Verify67+68+```bash69+# Confirm the plugin is installed and enabled70+hermes plugins list --plain71+72+# Check that the openspec binary is found73+openspec --version74+75+# In the dashboard, open the OpenSpec tab76+# (or curl the API if the dashboard is running on loopback)77+curl http://127.0.0.1:9119/api/plugins/openspec/sources78+```79+80+If `openspec` isn't on your PATH, set `OPENSPEC_BIN`:81+82+```bash83+export OPENSPEC_BIN=/home/user/.npm-global/bin/openspec84+```85+86+## Update87+88+```bash89+hermes plugins update openspec90+```91+92+This pulls the latest from the remote and reloads. If the backend API routes changed (`plugin_api.py`), restart Hermes to remount them — the dashboard rescan (`/api/dashboard/plugins/rescan`) reloads frontend assets but does not remount backend routes.93+94+## Documentation map95+96+```97+hermes-openspec/98+├── plugin.yaml # Tool plugin manifest — declares the six agent tools99+├── __init__.py # Plugin registration — wires tools, sets check_fn gating100+├── schemas.py # Tool parameter schemas (JSON Schema for each tool)101+├── tools.py # Tool handlers — wraps the openspec CLI binary102+├── registry.py # SQLite registry of sources at <hermes_home>/openspec.db103+├── dashboard/104+│ ├── manifest.json # Dashboard tab manifest — tab path, position, entry/css/api105+│ ├── plugin_api.py # FastAPI router mounted at /api/plugins/openspec/106+│ └── dist/107+│ ├── index.js # Dashboard tab frontend (IIFE, uses Hermes plugin SDK)108+│ └── style.css # Plugin styles (uses dashboard --color-* tokens)109+```110+111+| File | What to read it for |112+|---|---|113+| `plugin.yaml` | Which tools the plugin provides |114+| `tools.py` | How CLI commands are wrapped and how the binary is resolved |115+| `registry.py` | Source registry schema, token derivation, DB path |116+| `dashboard/plugin_api.py` | All backend API routes and the spec-browser logic |117+| `dashboard/manifest.json` | Tab registration, entry points |118+| `dashboard/dist/index.js` | Frontend: board, specs view, source dialogs, deep-linking |119+```
A __init__.py +40 -0
A dashboard/dist/index.js +777 -0
@@ -0,0 +1,777 @@
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 isoTimeAgo = SDK.utils.isoTimeAgo;27+ var fetchJSON = SDK.fetchJSON;28+29+ var BASE = "/api/plugins/openspec";30+31+ // ── API helpers ──────────────────────────────────────────────────────32+ function apiErr(err) {33+ var raw = err && err.message ? String(err.message) : String(err || "");34+ var m = raw.match(/^(\d{3}):\s*([\s\S]*)$/);35+ var body = m ? m[2] : raw;36+ try { var p = JSON.parse(body); if (p && typeof p.detail === "string") return p.detail; } catch (e) {}37+ return body || raw;38+ }39+ function getSources() { return fetchJSON(BASE + "/sources"); }40+ function addSource(path, name) {41+ return fetchJSON(BASE + "/sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });42+ }43+ function updateSource(id, path, name) {44+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });45+ }46+ function removeSource(id) {47+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "DELETE" });48+ }49+ function getChange(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/changes/" + encodeURIComponent(name)); }50+ function getIdea(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/ideas/" + encodeURIComponent(name)); }51+ function getSpec(sid, path) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/specs?path=" + encodeURIComponent(path)); }52+ function getSpecBrowser(sid, params) {53+ var qs = new URLSearchParams();54+ if (params && params.before) qs.set("before", params.before);55+ if (params && params.after) qs.set("after", params.after);56+ if (params && params.dirty) qs.set("dirty", "true");57+ var s = qs.toString();58+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/spec-browser" + (s ? "?" + s : ""));59+ }60+61+ // ── Minimal markdown renderer (headers, bold, code, lists, hr, para) ──62+ function esc(s) {63+ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");64+ }65+ function inline(s) {66+ s = esc(s);67+ s = s.replace(/`([^`]+)`/g, '<code class="os-code">$1</code>');68+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");69+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");70+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');71+ return s;72+ }73+ function renderMarkdown(md) {74+ if (!md) return "";75+ var lines = String(md).split("\n");76+ var html = [];77+ var i = 0;78+ var inCode = false, codeBuf = [];79+ var listType = null; // "ul" | "ol"80+ var paraBuf = [];81+ function flushPara() {82+ if (paraBuf.length) { html.push("<p>" + inline(paraBuf.join(" ")) + "</p>"); paraBuf = []; }83+ }84+ function flushList() {85+ if (listType) { html.push("</" + listType + ">"); listType = null; }86+ }87+ while (i < lines.length) {88+ var line = lines[i];89+ // fenced code block90+ if (/^```/.test(line)) {91+ if (inCode) { html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>"); codeBuf = []; inCode = false; }92+ else { flushPara(); flushList(); inCode = true; }93+ i++; continue;94+ }95+ if (inCode) { codeBuf.push(line); i++; continue; }96+ // heading97+ var hm = line.match(/^(#{1,4})\s+(.*)$/);98+ if (hm) { flushPara(); flushList(); var lvl = hm[1].length; html.push("<h" + lvl + ">" + inline(hm[2]) + "</h" + lvl + ">"); i++; continue; }99+ // hr100+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { flushPara(); flushList(); html.push('<hr class="os-hr"/>'); i++; continue; }101+ // list item102+ var lm = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);103+ if (lm) {104+ flushPara();105+ var lt = /\d+\./.test(lm[2]) ? "ol" : "ul";106+ if (listType !== lt) { flushList(); html.push("<" + lt + ' class="os-list">'); listType = lt; }107+ html.push("<li>" + inline(lm[3]) + "</li>");108+ i++; continue;109+ }110+ // blank line111+ if (line.trim() === "") { flushPara(); flushList(); i++; continue; }112+ // paragraph text113+ flushList();114+ paraBuf.push(line.trim());115+ i++;116+ }117+ if (inCode) html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>");118+ flushPara(); flushList();119+ return html.join("\n");120+ }121+ function Markdown(_ref) {122+ var md = _ref.md;123+ var html = useMemo(function () { return renderMarkdown(md); }, [md]);124+ return h("div", { className: "os-md", dangerouslySetInnerHTML: { __html: html } });125+ }126+127+ // ── Icons (inline SVG, no icon lib available in plugin sandbox) ──────128+ function Icon(_ref2) {129+ var d = _ref2.d, size = _ref2.size;130+ var s = size || 16;131+ 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 },132+ h("path", { d: d })133+ );134+ }135+ var ICO = {136+ refresh: "M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16",137+ plus: "M12 5v14M5 12h14",138+ 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",139+ 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",140+ 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",141+ close: "M18 6 6 18M6 6l12 12",142+ file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6",143+ 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",144+ chevron: "M9 18l6-6-6-6",145+ 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",146+ proposal: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h8M8 17h5",147+ tasks: "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",148+ design: "M12 19l7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.586 7.586M11 11a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z",149+ specs: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",150+ };151+152+ // ── Modal (lightweight, no Dialog in SDK) ─────────────────────────────153+ function Modal(_ref3) {154+ var title = _ref3.title, onClose = _ref3.onClose, children = _ref3.children, wide = _ref3.wide;155+ useEffect(function () {156+ function onKey(e) { if (e.key === "Escape") onClose(); }157+ window.addEventListener("keydown", onKey);158+ return function () { window.removeEventListener("keydown", onKey); };159+ }, [onClose]);160+ return h("div", { className: "os-overlay", onClick: onClose },161+ h("div", { className: cn("os-modal", wide && "os-modal-wide"), onClick: function (e) { e.stopPropagation(); } },162+ h("div", { className: "os-modal-head" },163+ h("span", { className: "os-modal-title" }, title),164+ h("button", { className: "os-icon-btn", onClick: onClose, "aria-label": "Close" }, h(Icon, { d: ICO.close }))165+ ),166+ h("div", { className: "os-modal-body" }, children)167+ )168+ );169+ }170+171+ // ── Copy chip ────────────────────────────────────────────────────────172+ function CopyChip(_ref4) {173+ var text = _ref4.text;174+ var _s = useState(false);175+ var copied = _s[0], setCopied = _s[1];176+ return h("button", {177+ className: "os-chip",178+ onClick: function () {179+ navigator.clipboard.writeText(text).then(function () { setCopied(true); setTimeout(function () { setCopied(false); }, 1200); });180+ },181+ title: "Copy: " + text,182+ }, copied ? "copied" : text, h("span", { className: "os-chip-ico" }, h(Icon, { d: ICO.copy, size: 11 })));183+ }184+185+ // ── Status badge colors ──────────────────────────────────────────────186+ var STATUS_LABEL = { ideas: "Ideas", draft: "Draft", todo: "Todo", in_progress: "In Progress", done: "Done", archived: "Archived" };187+ 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" };188+ var COLUMN_ORDER = ["ideas", "draft", "todo", "in_progress", "done", "archived"];189+190+ // ── Source toolbar ───────────────────────────────────────────────────191+ function Toolbar(_ref5) {192+ var sources = _ref5.sources, activeId = _ref5.activeId, onSelect = _ref5.onSelect;193+ var onRefresh = _ref5.onRefresh, onAdd = _ref5.onAdd, onEdit = _ref5.onEdit, onRemove = _ref5.onRemove;194+ var active = sources.find(function (s) { return s.id === activeId; });195+ return h("div", { className: "os-toolbar" },196+ h(Label, { htmlFor: "os-project" }, "Project"),197+ h(Select, { id: "os-project", value: activeId || "", onValueChange: onSelect },198+ sources.length === 0 ? h(SelectOption, { value: "" }, "—") : null,199+ sources.map(function (s) { return h(SelectOption, { key: s.id, value: s.id }, s.name + (s.valid ? "" : " (invalid)")); })200+ ),201+ h("button", { className: "os-icon-btn", onClick: onRefresh, title: "Refresh", disabled: sources.length === 0 }, h(Icon, { d: ICO.refresh })),202+ h("div", { className: "os-toolbar-actions" },203+ h("button", { className: "os-icon-btn", onClick: onEdit, title: "Edit source", disabled: !active }, h(Icon, { d: ICO.edit })),204+ h("button", { className: "os-icon-btn", onClick: onRemove, title: "Remove source", disabled: !active }, h(Icon, { d: ICO.trash })),205+ h("button", { className: "os-icon-btn", onClick: onAdd, title: "Add source" }, h(Icon, { d: ICO.plus }))206+ )207+ );208+ }209+210+ // ── Add / Edit dialogs ───────────────────────────────────────────────211+ function SourceDialog(_ref6) {212+ var mode = _ref6.mode, initialPath = _ref6.initialPath, initialName = _ref6.initialName, onClose = _ref6.onClose, onSaved = _ref6.onSaved;213+ var activeId = _ref6.activeId;214+ var _p = useState(initialPath || ""), _n = useState(initialName || ""), _b = useState(false), _e = useState("");215+ 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];216+ function save() {217+ if (!path.trim()) { setErr("Path is required"); return; }218+ setBusy(true); setErr("");219+ var p = mode === "edit" ? updateSource(activeId, path.trim(), name.trim()) : addSource(path.trim(), name.trim());220+ p.then(function (res) { onSaved(res); }).catch(function (e) { setErr(apiErr(e)); setBusy(false); });221+ }222+ return h(Modal, { title: mode === "edit" ? "Edit OpenSpec source" : "Add OpenSpec source", onClose: onClose },223+ h("div", { className: "os-field" },224+ h(Label, { htmlFor: "os-path" }, "Path"),225+ h(Input, { id: "os-path", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: "~/repos/my-project", disabled: busy })226+ ),227+ h("div", { className: "os-field" },228+ h(Label, { htmlFor: "os-name" }, "Display name (optional)"),229+ h(Input, { id: "os-name", value: name, onChange: function (e) { setName(e.target.value); }, placeholder: "my-project", disabled: busy })230+ ),231+ err ? h("p", { className: "os-err" }, err) : null,232+ h("div", { className: "os-dialog-actions" },233+ h(Button, { variant: "outline", onClick: onClose, disabled: busy }, "Cancel"),234+ h(Button, { onClick: save, disabled: busy }, busy ? "Saving…" : "Save")235+ )236+ );237+ }238+239+ // ── Board (kanban) ───────────────────────────────────────────────────240+ function Board(_ref7) {241+ var source = _ref7.source, onSelectItem = _ref7.onSelectItem;242+ var board = source.openspec;243+ if (!board) return h("p", { className: "os-empty" }, "No openspec/ content found.");244+ var byCol = {};245+ COLUMN_ORDER.forEach(function (c) { byCol[c] = []; });246+ board.ideas.forEach(function (it) { byCol.ideas.push(it); });247+ board.changes.forEach(function (ch) { var s = ch.status || "draft"; (byCol[s] || (byCol[s] = [])).push(ch); });248+ return h("div", { className: "os-board" },249+ h("div", { className: "os-board-head" },250+ h("span", { className: "os-repo-name" }, source.name),251+ h(CopyChip, { text: source.name }),252+ h("span", { className: "os-repo-path" }, source.path)253+ ),254+ h("div", { className: "os-cols" },255+ COLUMN_ORDER.map(function (col) {256+ var items = byCol[col] || [];257+ return h("div", { key: col, className: "os-col" },258+ h("div", { className: "os-col-head" },259+ h("span", null, STATUS_LABEL[col] || col),260+ h(Badge, { variant: "secondary" }, String(items.length))261+ ),262+ h("div", { className: "os-col-list" },263+ items.length === 0 ? h("span", { className: "os-col-empty" }, "—") :264+ items.map(function (it) {265+ var artifacts = [266+ { on: it.hasProposal, c: "#60a5fa", label: "proposal" },267+ { on: it.hasTasks, c: "#34d399", label: "tasks" },268+ { on: it.hasDesign, c: "#a78bfa", label: "design" },269+ { on: it.hasSpecs, c: "#fbbf24", label: "specs" },270+ ];271+ var pct = it.taskStats && it.taskStats.total > 0 ? Math.round((it.taskStats.done / it.taskStats.total) * 100) : 0;272+ return h("button", {273+ key: it.id || it.name, className: cn("os-card", STATUS_TONE[it.status] || ""), onClick: function () { onSelectItem(it); },274+ },275+ h("div", { className: "os-card-title" }, it.title || it.name),276+ h("div", { className: "os-card-foot" },277+ h("span", { className: "os-card-artifacts" },278+ artifacts.map(function (a, i) { return h("span", { key: i, className: cn("os-card-dot", !a.on && "os-card-dot-off"), title: a.label }); })279+ ),280+ it.taskStats ? h("span", { className: "os-card-progress" },281+ h("span", { className: "os-card-progress-track" }, h("span", { className: "os-card-progress-fill", style: { width: pct + "%" } })),282+ h("span", { className: "os-card-stats" }, it.taskStats.done + "/" + it.taskStats.total)283+ ) : null,284+ it.token ? h("button", {285+ className: "os-card-copy",286+ title: "Copy: " + source.name + "/" + it.token,287+ onClick: function (e) { e.stopPropagation(); navigator.clipboard.writeText(source.name + "/" + it.token); }288+ }, h(Icon, { d: ICO.copy, size: 12 })) : null289+ )290+ );291+ })292+ )293+ );294+ })295+ )296+ );297+ }298+299+ // ── Change / Idea detail dialog ──────────────────────────────────────300+ function DetailDialog(_ref8) {301+ var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose;302+ var _d = useState(null), _l = useState(true), _e2 = useState("");303+ var detail = _d[0], setDetail = _d[1], loading = _l[0], setLoading = _l[1], err = _e2[0], setErr = _e2[1];304+ var isIdea = item.status === "ideas";305+ useEffect(function () {306+ setLoading(true); setErr(""); setDetail(null);307+ var p = isIdea ? getIdea(source.id, item.name) : getChange(source.id, item.name);308+ p.then(function (d) { setDetail(d); setLoading(false); }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });309+ }, [source.id, item.name, isIdea]);310+ return h(Modal, { title: item.title || item.name, onClose: onClose, wide: true },311+ h("div", { className: "os-detail-chip" }, h(CopyChip, { text: source.name + "/" + item.token })),312+ loading ? h("p", { className: "os-empty" }, "Loading…") :313+ err ? h("p", { className: "os-err" }, err) :314+ isIdea ? h(Markdown, { md: detail && detail.content }) :315+ detail ? h(ChangeDetailTabs, { detail: detail }) : null316+ );317+ }318+ // ── Task parser ─────────────────────────────────────────────────────319+ function parseTasks(md) {320+ if (!md) return { sections: [], total: 0, done: 0 };321+ var lines = md.split("\n");322+ var sections = [];323+ var current = null;324+ var total = 0, done = 0;325+ for (var i = 0; i < lines.length; i++) {326+ var line = lines[i];327+ var h = line.match(/^##\s+(.+)$/);328+ if (h) {329+ current = { title: h[1].trim(), tasks: [] };330+ sections.push(current);331+ continue;332+ }333+ var t = line.match(/^[-*]\s+\[(.)\]\s+(.+)$/);334+ if (t) {335+ var checked = t[1] === "x" || t[1] === "X";336+ var text = t[2].trim();337+ if (checked) done++;338+ total++;339+ if (current) current.tasks.push({ checked: checked, text: text });340+ else { current = { title: "", tasks: [] }; sections.push(current); current.tasks.push({ checked: checked, text: text }); }341+ }342+ }343+ return { sections: sections, total: total, done: done };344+ }345+346+ function ChangeDetailTabs(_ref9) {347+ var detail = _ref9.detail;348+ var tabs = [];349+ if (detail.proposal) tabs.push(["proposal", "Proposal"]);350+ if (detail.tasks) tabs.push(["tasks", "Tasks"]);351+ if (detail.design) tabs.push(["design", "Design"]);352+ if (detail.specs && detail.specs.length) tabs.push(["specs", "Specs (" + detail.specs.length + ")"]);353+ if (!tabs.length) return h("p", { className: "os-empty" }, "No documents found.");354+ var _tab = useState(tabs[0][0]);355+ var activeTab = _tab[0], setActiveTab = _tab[1];356+ var content = activeTab === "specs"357+ ? detail.specs.map(function (s) { return h("div", { key: s.path, className: "os-spec-block" }, h("h4", null, s.path), h(Markdown, { md: s.content })); })358+ : activeTab === "tasks"359+ ? h(TasksView, { md: detail[activeTab], stats: detail.taskStats })360+ : h(Markdown, { md: detail[activeTab] });361+ return h("div", { className: "os-tabs" },362+ h("div", { className: "os-tabs-bar" }, tabs.map(function (t) {363+ return h("button", {364+ key: t[0], className: cn("os-tab-btn", activeTab === t[0] && "os-tab-btn-active"),365+ onClick: function () { setActiveTab(t[0]); }366+ }, t[1]);367+ })),368+ h("div", { className: "os-tab-panel" }, content)369+ );370+ }371+372+ // ── Tasks view (structured checklist) ───────────────────────────────373+ function TasksView(_ref) {374+ var md = _ref.md, stats = _ref.stats;375+ var parsed = useMemo(function () { return parseTasks(md); }, [md]);376+ var total = stats ? stats.total : parsed.total;377+ var done = stats ? stats.done : parsed.done;378+ var pct = total > 0 ? Math.round((done / total) * 100) : 0;379+ return h("div", { className: "os-tasks" },380+ total > 0 ? h("div", { className: "os-tasks-progress" },381+ h("div", { className: "os-tasks-bar" },382+ h("div", { className: "os-tasks-bar-fill", style: { width: pct + "%" } })383+ ),384+ h("span", { className: "os-tasks-count" }, done + " / " + total + " done (" + pct + "%)")385+ ) : null,386+ parsed.sections.map(function (sec, si) {387+ return h("div", { key: si, className: "os-tasks-section" },388+ sec.title ? h("h4", { className: "os-tasks-section-title" }, sec.title) : null,389+ sec.tasks.map(function (t, ti) {390+ return h("div", { key: ti, className: cn("os-task-item", t.checked && "os-task-done") },391+ h("span", { className: "os-task-check" }, t.checked ? "\u2713" : "\u25CB"),392+ h("span", { className: "os-task-text" }, t.text)393+ );394+ })395+ );396+ })397+ );398+ }399+400+ // ── Spec content parser + structured view ───────────────────────────401+ function parseSpec(md) {402+ if (!md) return { title: "", purpose: "", requirements: [] };403+ var lines = md.split("\n");404+ var title = "", purpose = "";405+ var reqs = [];406+ var state = "top"; // top | purpose | reqDesc | scenario407+ var currentReq = null, currentScn = null;408+409+ for (var i = 0; i < lines.length; i++) {410+ var line = lines[i];411+ var trim = line.trim();412+413+ if (trim.startsWith("# ") && !trim.startsWith("## ")) {414+ title = trim.replace(/^#\s+/, "").replace(/\s+Specification\s*$/i, "");415+ state = "top"; continue;416+ }417+ if (/^##\s+Purpose/i.test(trim)) { state = "purpose"; continue; }418+ if (/^##\s+Requirements/i.test(trim)) { state = "top"; continue; }419+ if (/^###\s+Requirement:\s*(.+)$/i.test(trim)) {420+ var m = trim.match(/^###\s+Requirement:\s*(.+)$/i);421+ currentReq = { name: m[1].trim(), description: "", scenarios: [] };422+ reqs.push(currentReq);423+ currentScn = null;424+ state = "reqDesc"; continue;425+ }426+ if (/^####\s+Scenario:\s*(.+)$/i.test(trim)) {427+ var m2 = trim.match(/^####\s+Scenario:\s*(.+)$/i);428+ currentScn = { name: m2[1].trim(), steps: [] };429+ if (currentReq) currentReq.scenarios.push(currentScn);430+ state = "scenario"; continue;431+ }432+433+ // Accumulate text based on state434+ if (state === "purpose" && trim) {435+ purpose += (purpose ? "\n" : "") + trim;436+ } else if (state === "reqDesc" && trim) {437+ if (currentReq) currentReq.description += (currentReq.description ? "\n" : "") + trim;438+ } else if (state === "scenario" && trim) {439+ var step = trim.match(/^[-*]\s+\*\*(\w+)\*\*\s*(.+)$/);440+ if (step && currentScn) {441+ currentScn.steps.push({ type: step[1].toUpperCase(), text: step[2].trim() });442+ } else if (currentScn) {443+ // Non-bullet text inside scenario — append to last step or skip444+ if (currentScn.steps.length) {445+ currentScn.steps[currentScn.steps.length - 1].text += " " + trim;446+ }447+ }448+ }449+ }450+ return { title: title, purpose: purpose, requirements: reqs };451+ }452+453+ function SpecContentView(_ref) {454+ var md = _ref.md;455+ var spec = useMemo(function () { return parseSpec(md); }, [md]);456+ if (!spec.title && !spec.purpose && !spec.requirements.length) {457+ return h(Markdown, { md: md });458+ }459+ return h("div", { className: "os-spec-content" },460+ spec.purpose ? h("div", { className: "os-spec-purpose" },461+ h("div", { className: "os-spec-purpose-label" }, "Purpose"),462+ h("p", null, spec.purpose)463+ ) : null,464+ spec.requirements.map(function (req, ri) {465+ return h("div", { key: ri, className: "os-spec-req" },466+ h("div", { className: "os-spec-req-head" },467+ h("span", { className: "os-spec-req-num" }, "R" + (ri + 1)),468+ h("span", { className: "os-spec-req-name" }, req.name)469+ ),470+ req.description ? h("p", { className: "os-spec-req-desc" }, req.description) : null,471+ req.scenarios.map(function (scn, si) {472+ return h("div", { key: si, className: "os-spec-scn" },473+ h("div", { className: "os-spec-scn-name" }, scn.name),474+ scn.steps.map(function (st, sti) {475+ return h("div", { key: sti, className: cn("os-spec-step", "os-spec-step-" + st.type.toLowerCase()) },476+ h("span", { className: "os-spec-step-label" }, st.type),477+ h("span", { className: "os-spec-step-text" }, st.text)478+ );479+ })480+ );481+ })482+ );483+ })484+ );485+ }486+487+ // ── Specs view (spec browser: current / dirty / refs) ────────────────488+ var SORT_MODES = [489+ { value: "alpha", label: "A→Z" },490+ { value: "alpha-desc", label: "Z→A" },491+ { value: "edited", label: "Latest edited" },492+ { value: "edited-desc", label: "Oldest edited" },493+ ];494+ function sortFiles(files, sortMode) {495+ if (!files || !files.length) return files;496+ var arr = files.slice();497+ if (sortMode === "alpha-desc") {498+ arr.sort(function (a, b) { return b.path.localeCompare(a.path); });499+ } else if (sortMode === "edited") {500+ arr.sort(function (a, b) {501+ var ta = a.mtime ? Date.parse(a.mtime) : 0;502+ var tb = b.mtime ? Date.parse(b.mtime) : 0;503+ return tb - ta;504+ });505+ } else if (sortMode === "edited-desc") {506+ arr.sort(function (a, b) {507+ var ta = a.mtime ? Date.parse(a.mtime) : 0;508+ var tb = b.mtime ? Date.parse(b.mtime) : 0;509+ return ta - tb;510+ });511+ } else {512+ arr.sort(function (a, b) { return a.path.localeCompare(b.path); });513+ }514+ return arr;515+ }516+ function SpecsView(_ref10) {517+ var source = _ref10.source, anchorToken = _ref10.anchorToken;518+ var _m = useState("current"), _b2 = useState(null), _l2 = useState(true), _e3 = useState("");519+ var _sel = useState(null), _bf = useState(""), _af = useState("");520+ var _sm = useState("alpha");521+ var mode = _m[0], setMode = _m[1], browser = _b2[0], setBrowser = _b2[1];522+ var loading = _l2[0], setLoading = _l2[1], err = _e3[0], setErr = _e3[1];523+ var selPath = _sel[0], setSelPath = _sel[1], before = _bf[0], setBefore = _bf[1], after = _af[0], setAfter = _af[1];524+ var sortMode = _sm[0], setSortMode = _sm[1];525+ var firstLoad = useRef(true);526+527+ function load(currentMode, b, a) {528+ setLoading(true); setErr("");529+ var params = {};530+ if (currentMode === "dirty") params.dirty = true;531+ else if (currentMode === "refs") { params.before = b; params.after = a; }532+ getSpecBrowser(source.id, params).then(function (d) {533+ setBrowser(d);534+ // Pick initial selection: anchor token match, else first file.535+ var pick = null;536+ if (anchorToken && d.files) {537+ pick = d.files.find(function (f) { return f.token === anchorToken; });538+ }539+ if (!pick && d.files && d.files.length) pick = d.files[0];540+ setSelPath(pick ? pick.path : null);541+ setLoading(false);542+ }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });543+ }544+ useEffect(function () {545+ if (firstLoad.current) { firstLoad.current = false; load(mode, before, after); }546+ }, []);547+ function switchMode(m) {548+ setMode(m);549+ if (m === "refs" && (!before.trim() || !after.trim())) { setBrowser(null); setLoading(false); return; }550+ load(m, before, after);551+ }552+ function reload() { load(mode, before, after); }553+ var selFile = browser && browser.files ? browser.files.find(function (f) { return f.path === selPath; }) : null;554+ return h("div", { className: "os-specs" },555+ h("div", { className: "os-specs-toolbar" },556+ h("span", { className: "os-specs-title" }, "Specs"),557+ browser && browser.branch ? h(Badge, { variant: "outline" }, h(Icon, { d: ICO.branch, size: 11 }), " ", browser.branch) : null,558+ h("div", { className: "os-specs-modes" },559+ h(Button, { variant: mode === "current" ? "default" : "outline", size: "sm", onClick: function () { switchMode("current"); } }, "Current"),560+ h(Button, { variant: mode === "dirty" ? "default" : "outline", size: "sm", onClick: function () { switchMode("dirty"); } }, "Dirty (HEAD→worktree)"),561+ h(Button, { variant: mode === "refs" ? "default" : "outline", size: "sm", onClick: function () { switchMode("refs"); } }, "Compare refs")562+ ),563+ mode === "refs" ? h("div", { className: "os-refs-inputs" },564+ h(Input, { placeholder: "before (HEAD~1)", value: before, onChange: function (e) { setBefore(e.target.value); }, className: "os-ref-input" }),565+ h(Input, { placeholder: "after (origin/main)", value: after, onChange: function (e) { setAfter(e.target.value); }, className: "os-ref-input" }),566+ h(Button, { size: "sm", onClick: reload, disabled: !before.trim() || !after.trim() }, "Load")567+ ) : null,568+ h("button", { className: "os-icon-btn", onClick: reload, title: "Refresh" }, h(Icon, { d: ICO.refresh })),569+ h(Select, { value: sortMode, onValueChange: setSortMode, "aria-label": "Sort specs" },570+ SORT_MODES.map(function (s) { return h(SelectOption, { key: s.value, value: s.value }, s.label); })571+ )572+ ),573+ browser && browser.changedCount > 0 ? h("div", { className: "os-specs-changed" }, browser.changedCount + " changed spec" + (browser.changedCount === 1 ? "" : "s")) : null,574+ loading ? h("p", { className: "os-empty" }, "Loading…") :575+ err ? h("p", { className: "os-err" }, err) :576+ browser && browser.files && browser.files.length ? (function () {577+ var sorted = sortFiles(browser.files, sortMode);578+ return h("div", { className: "os-specs-grid" },579+ h("div", { className: "os-specs-list" },580+ sorted.map(function (f) {581+ return h("button", {582+ key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),583+ onClick: function () { setSelPath(f.path); },584+ },585+ h(Icon, { d: ICO.file, size: 13 }),586+ h("span", { className: "os-spec-item-path" }, f.path),587+ f.changed ? h(Badge, { className: "os-spec-status os-status-" + f.status }, f.status) : null588+ );589+ })590+ ),591+ selFile ? h("div", { className: "os-specs-detail" },592+ h("div", { className: "os-specs-detail-head" },593+ h("span", { className: "os-specs-detail-path" }, selFile.path),594+ selFile.token ? h(CopyChip, { text: source.name + "/" + selFile.token }) : null595+ ),596+ (selFile.ctime || selFile.mtime) ? h("div", { className: "os-specs-meta" },597+ selFile.ctime ? h("span", { title: selFile.ctime }, "created ", isoTimeAgo(selFile.ctime)) : null,598+ selFile.mtime ? h("span", { title: selFile.mtime }, "edited ", isoTimeAgo(selFile.mtime)) : null599+ ) : null,600+ mode === "current" ? h(SpecContentView, { md: selFile.after }) :601+ h("div", { className: "os-spec-diff" },602+ h("div", { className: "os-diff-cols" },603+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "before"), h(Markdown, { md: selFile.before })),604+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "after"), h(Markdown, { md: selFile.after }))605+ ),606+ selFile.diff ? h("pre", { className: "os-diff-unified" }, selFile.diff) : null607+ )608+ ) : h("p", { className: "os-empty" }, "Select a spec.")609+ );610+ })() : h("p", { className: "os-empty" }, mode === "dirty" ? "No changes between HEAD and worktree." : mode === "refs" ? "No differences between refs." : "No specs found.")611+ );612+ }613+614+ // ── Error boundary (class-based, catches render crashes) ─────────────615+ var ErrorBoundary = (function (_super) {616+ function EB(props) {617+ _super.call(this, props);618+ this.state = { hasError: false, error: null };619+ }620+ EB.prototype = Object.create(_super.prototype);621+ EB.prototype.constructor = EB;622+ EB.getDerivedStateFromError = function (error) {623+ return { hasError: true, error: error };624+ };625+ EB.prototype.componentDidCatch = function (error) {626+ console.error("[openspec] render error:", error);627+ };628+ EB.prototype.render = function () {629+ if (this.state.hasError) {630+ return h("div", { className: "os-err" },631+ "Something went wrong rendering this view: " + (this.state.error && this.state.error.message || String(this.state.error)),632+ h("button", { className: "os-icon-btn", style: { marginLeft: "0.5rem" }, onClick: function () { this.setState({ hasError: false, error: null }); }.bind(this) }, "Retry")633+ );634+ }635+ return this.props.children;636+ };637+ return EB;638+ })(React.Component);639+640+ // ── Hash deep-linking ────────────────────────────────────────────────641+ function parseHash() {642+ var h2 = window.location.hash.replace(/^#/, "").trim();643+ if (!h2) return { project: null, token: null };644+ var parts = h2.split("/");645+ return { project: decodeURIComponent(parts[0]) || null, token: parts[1] ? decodeURIComponent(parts[1]) : null };646+ }647+ function setHash(project, token) {648+ var v = project ? (token ? project + "/" + token : project) : "";649+ if (("#" + v) !== window.location.hash) window.location.hash = v;650+ }651+652+ // ── Main page component ──────────────────────────────────────────────653+ function OpenSpecPage() {654+ var _s2 = useState([]), _a2 = useState(null), _l3 = useState(true), _e4 = useState("");655+ var sources = _s2[0], setSources = _s2[1], activeId = _a2[0], setActiveId = _a2[1];656+ var loading = _l3[0], setLoading = _l3[1], err = _e4[0], setErr = _e4[1];657+ var _dlg = useState(null), _anchor = useState({ project: null, token: null });658+ var dlg = _dlg[0], setDlg = _dlg[1], anchor = _anchor[0], setAnchor = _anchor[1];659+ var _view = useState("board"), _selItem = useState(null);660+ var view = _view[0], setView = _view[1], selItem = _selItem[0], setSelItem = _selItem[1];661+662+ function loadSources(silentAfter) {663+ if (!silentAfter) setLoading(true);664+ setErr("");665+ getSources().then(function (res) {666+ setSources(res.sources || []);667+ setLoading(false);668+ }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });669+ }670+ useEffect(function () { loadSources(); }, []);671+672+ // Apply hash anchor on load + when sources change. Auto-select the673+ // first source when no hash project is present so the user doesn't674+ // have to manually pick from the dropdown to see anything.675+ useEffect(function () {676+ if (!sources.length) return;677+ var ha = parseHash();678+ setAnchor(ha);679+ if (ha.project) {680+ var proj = ha.project.toLowerCase();681+ var match = sources.find(function (s) {682+ return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;683+ });684+ if (match && match.id !== activeId) setActiveId(match.id);685+ return;686+ }687+ if (!activeId) setActiveId(sources[0].id);688+ }, [sources]);689+690+ // When anchor has a token, switch view / open detail.691+ useEffect(function () {692+ if (!anchor.token || !activeId || !sources.length) return;693+ var src = sources.find(function (s) { return s.id === activeId; });694+ if (!src || !src.openspec) return;695+ // Check changes + ideas696+ var item = (src.openspec.changes || []).find(function (c) { return c.token === anchor.token; })697+ || (src.openspec.ideas || []).find(function (c) { return c.token === anchor.token; });698+ if (item) { setView("board"); setSelItem(item); return; }699+ // Check specs700+ var spec = (src.openspec.specs || []).find(function (s) { return s.token === anchor.token; });701+ if (spec) { setView("specs"); }702+ }, [anchor, activeId, sources]);703+704+ var active = sources.find(function (s) { return s.id === activeId; });705+706+ function selectSource(id) { setActiveId(id); var s = sources.find(function (x) { return x.id === id; }); setHash(s ? s.name : null, null); setView("board"); setSelItem(null); }707+ function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token); }708+ function closeItem() { setSelItem(null); if (active) setHash(active.name, null); }709+710+ function onAdd() { setDlg({ mode: "add" }); }711+ function onEdit() { if (active) setDlg({ mode: "edit", activeId: active.id, path: active.path, name: active.name }); }712+ function onRemove() {713+ if (!active) return;714+ if (!window.confirm("Remove source '" + active.name + "'?")) return;715+ removeSource(active.id).then(function () {716+ setSources(sources.filter(function (s) { return s.id !== active.id; }));717+ setActiveId(null); setHash(null, null);718+ }).catch(function (e) { setErr(apiErr(e)); });719+ }720+ function onSaved(res) {721+ setDlg(null);722+ getSources().then(function (r) {723+ setSources(r.sources || []);724+ if (res && res.source) setActiveId(res.source.id);725+ }).catch(function (e) { setErr(apiErr(e)); });726+ }727+728+ // Hash change listener (browser back/forward + manual URL edits).729+ useEffect(function () {730+ function onHash() {731+ var ha = parseHash();732+ setAnchor(ha);733+ if (ha.project && sources.length) {734+ var proj = ha.project.toLowerCase();735+ var match = sources.find(function (s) {736+ return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;737+ });738+ if (match) setActiveId(match.id);739+ }740+ }741+ window.addEventListener("hashchange", onHash);742+ return function () { window.removeEventListener("hashchange", onHash); };743+ }, [sources]);744+745+ if (loading) return h("div", { className: "os-page os-loading" }, "Loading…");746+ return h(ErrorBoundary, null,747+ h("div", { className: "os-page" },748+ h(Toolbar, {749+ sources: sources, activeId: activeId, onSelect: selectSource,750+ onRefresh: function () { loadSources(true); }, onAdd: onAdd, onEdit: onEdit, onRemove: onRemove,751+ }),752+ err ? h("p", { className: "os-err" }, err) : null,753+ sources.length === 0 ? h("div", { className: "os-empty-state" },754+ h("p", null, "No OpenSpec sources registered."),755+ h(Button, { onClick: onAdd }, h(Icon, { d: ICO.plus, size: 14 }), " Add your first source")756+ ) : null,757+ active && active.valid ? h("div", { className: "os-view-switch" },758+ h(Button, { variant: view === "board" ? "default" : "outline", size: "sm", onClick: function () { setView("board"); } }, "Changes"),759+ h(Button, { variant: view === "specs" ? "default" : "outline", size: "sm", onClick: function () { setView("specs"); } }, "Specs")760+ ) : null,761+ active && !active.valid ? h("p", { className: "os-err" }, active.error || "Source path is invalid.") : null,762+ active && active.valid && view === "board" ? h(Board, { source: active, onSelectItem: selectItem }) : null,763+ active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token }) : null,764+ selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem }) : null,765+ dlg ? h(SourceDialog, {766+ mode: dlg.mode, initialPath: dlg.path, initialName: dlg.name, activeId: dlg.activeId,767+ onClose: function () { setDlg(null); }, onSaved: onSaved,768+ }) : null769+ )770+ );771+ }772+773+ // ── Register ─────────────────────────────────────────────────────────774+ if (window.__HERMES_PLUGINS__ && window.__HERMES_PLUGINS__.register) {775+ window.__HERMES_PLUGINS__.register("openspec", OpenSpecPage);776+ }777+})();
A dashboard/dist/style.css +172 -0
@@ -0,0 +1,172 @@
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+.os-toolbar-actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }12+13+/* Shrink Select dropdowns to fit their text content instead of stretching14+ (the SDK Select trigger has w-full, which over-sizes in flex toolbars). */15+.os-page div.relative:has(> button[role="combobox"]) { width: fit-content; }16+17+.os-icon-btn {18+ display: inline-flex; align-items: center; justify-content: center;19+ width: 2rem; height: 2rem; border-radius: var(--radius-sm, 0.375rem);20+ border: 1px solid var(--color-border, #333); background: transparent;21+ color: var(--color-muted-foreground, #aaa); cursor: pointer; transition: background 0.12s;22+}23+.os-icon-btn:hover:not(:disabled) { background: var(--color-secondary, rgba(255,255,255,0.06)); color: var(--color-foreground, #eee); }24+.os-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }25+26+/* View switch */27+.os-view-switch { display: flex; gap: 0.25rem; }28+29+/* Board */30+.os-board { display: flex; flex-direction: column; gap: 0.75rem; }31+.os-board-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }32+.os-repo-name { font-weight: 600; font-size: 0.95rem; color: var(--color-foreground, #eee); }33+.os-repo-path { font-size: 0.75rem; color: var(--color-muted-foreground, #777); font-family: var(--theme-font-mono, monospace); }34+35+.os-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 0.75rem; }36+.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); }37+.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); }38+.os-col-list { display: flex; flex-direction: column; gap: 0.375rem; padding: 0.5rem; overflow-y: auto; min-height: 0; flex: 1; }39+.os-col-empty { color: var(--color-muted-foreground, #555); font-size: 0.75rem; text-align: center; padding: 0.5rem; }40+41+.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; }42+.os-card:hover { border-color: var(--color-primary, #555); }43+.os-card-title { font-size: 0.82rem; font-weight: 500; line-height: 1.3; color: var(--color-foreground, #eee); }44+.os-card-foot { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }45+.os-card-copy { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border: none; background: transparent; color: var(--color-muted-foreground, #777); cursor: pointer; border-radius: var(--radius-sm, 0.25rem); }46+.os-card-copy:hover { color: var(--color-foreground, #eee); background: var(--color-secondary, rgba(255,255,255,0.06)); }47+.os-card-artifacts { display: inline-flex; align-items: center; gap: 0.25rem; }48+.os-card-dot { display: inline-block; width: 0.4375rem; height: 0.4375rem; border-radius: 50%; background: var(--c, transparent); }49+.os-card-dot:nth-child(1) { --c: #60a5fa; }50+.os-card-dot:nth-child(2) { --c: #34d399; }51+.os-card-dot:nth-child(3) { --c: #a78bfa; }52+.os-card-dot:nth-child(4) { --c: #fbbf24; }53+.os-card-dot-off { --c: var(--color-muted-foreground, #555); opacity: 0.4; }54+.os-card-progress { display: flex; align-items: center; gap: 0.25rem; }55+.os-card-progress-track { width: 2.5rem; height: 0.25rem; border-radius: 0.125rem; background: var(--color-border, rgba(255,255,255,0.15)); overflow: hidden; }56+.os-card-progress-fill { display: block; height: 100%; border-radius: 0.125rem; background: var(--color-primary, #60a5fa); }57+.os-card-stats { font-size: 0.68rem; color: var(--color-muted-foreground, #aaa); }58+59+.os-tone-ideas { border-left: 3px solid #c084fc; }60+.os-tone-draft { border-left: 3px solid var(--color-muted-foreground, #6b7280); }61+.os-tone-todo { border-left: 3px solid #38bdf8; }62+.os-tone-progress { border-left: 3px solid #fb923c; }63+.os-tone-done { border-left: 3px solid #2dd4bf; }64+.os-tone-archived { border-left: 3px solid var(--color-muted-foreground, #4b5563); opacity: 0.7; }65+66+/* Chip */67+.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; }68+.os-chip:hover { background: var(--color-secondary, rgba(255,255,255,0.06)); }69+.os-chip-ico { display: inline-flex; opacity: 0.6; }70+71+/* Modal */72+.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; }73+.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); }74+.os-modal-wide { max-width: 56rem; }75+.os-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border, #2a2a2a); }76+.os-modal-title { font-weight: 600; font-size: 0.95rem; color: var(--color-foreground, #eee); }77+.os-modal-body { padding: 1rem; max-height: calc(100vh - 12rem); overflow-y: auto; }78+79+.os-field { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }80+.os-field label { font-size: 0.75rem; color: var(--color-muted-foreground, #aaa); }81+.os-dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }82+83+.os-err { color: #f87171; font-size: 0.8rem; }84+.os-empty { color: var(--color-muted-foreground, #777); font-size: 0.85rem; padding: 0.5rem 0; }85+.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); }86+87+.os-detail-chip { margin-bottom: 0.75rem; }88+89+/* Custom tabs (replaces Radix Tabs — not all hosts expose TabsContent) */90+.os-tabs-bar { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--color-border, #2a2a2a); margin-bottom: 0.75rem; }91+.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; }92+.os-tab-btn:hover { color: var(--color-foreground, #eee); }93+.os-tab-btn-active { color: var(--color-foreground, #eee); border-bottom-color: var(--color-primary, var(--midground, #eee)); }94+.os-tab-panel { padding-top: 0.25rem; }95+96+/* Tasks */97+.os-tasks { display: flex; flex-direction: column; gap: 0.75rem; }98+.os-tasks-progress { display: flex; align-items: center; gap: 0.5rem; }99+.os-tasks-bar { flex: 1; height: 0.375rem; border-radius: 0.1875rem; background: var(--color-secondary, rgba(255,255,255,0.06)); overflow: hidden; }100+.os-tasks-bar-fill { height: 100%; border-radius: 0.1875rem; background: var(--color-primary, #60a5fa); transition: width 0.2s; }101+.os-tasks-count { font-size: 0.7rem; color: var(--color-muted-foreground, #888); white-space: nowrap; }102+.os-tasks-section { display: flex; flex-direction: column; gap: 0.125rem; }103+.os-tasks-section-title { font-size: 0.82rem; font-weight: 600; color: var(--color-foreground, #eee); margin: 0; }104+.os-task-item { display: flex; align-items: flex-start; gap: 0.375rem; padding: 0.1875rem 0; font-size: 0.78rem; line-height: 1.4; }105+.os-task-check { flex-shrink: 0; width: 1rem; text-align: center; font-size: 0.72rem; color: var(--color-muted-foreground, #888); }106+.os-task-text { flex: 1; }107+.os-task-done .os-task-check { color: var(--color-primary, #60a5fa); }108+.os-task-done .os-task-text { color: var(--color-muted-foreground, #888); text-decoration: line-through; }109+110+/* Markdown */111+.os-md { font-size: 0.85rem; line-height: 1.6; color: var(--color-foreground, #eee); }112+.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); }113+.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; }114+.os-md p { margin: 0.375rem 0; }115+.os-md ul, .os-md ol { margin: 0.375rem 0; padding-left: 1.25rem; }116+.os-md li { margin: 0.125rem 0; }117+.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); }118+.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; }119+.os-md .os-pre code { font-family: var(--theme-font-mono, monospace); font-size: 0.78rem; color: var(--color-foreground, #eee); background: unset; }120+.os-md .os-hr { border: none; border-top: 1px solid var(--color-border, #2a2a2a); margin: 0.75rem 0; }121+.os-md a { color: var(--color-primary, #60a5fa); text-decoration: underline; }122+123+.os-spec-block { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--color-border, #2a2a2a); }124+.os-spec-block:last-child { border-bottom: none; }125+.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; }126+127+/* Specs view */128+.os-specs { display: flex; flex-direction: column; gap: 0.75rem; }129+.os-specs-toolbar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }130+.os-specs-title { font-weight: 600; font-size: 0.9rem; color: var(--color-foreground, #eee); }131+.os-specs-modes { display: flex; gap: 0.25rem; }132+.os-refs-inputs { display: flex; gap: 0.375rem; align-items: center; }133+.os-ref-input { width: 9rem; font-size: 0.75rem; }134+.os-specs-changed { font-size: 0.78rem; color: #fbbf24; }135+136+.os-specs-grid { display: grid; grid-template-columns: 16rem 1fr; gap: 0.75rem; min-height: calc(100vh - 18rem); }137+.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); }138+.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); }139+.os-spec-item:hover { background: var(--color-secondary, rgba(255,255,255,0.05)); }140+.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); }141+.os-spec-item-changed { color: #fbbf24; }142+.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; }143+.os-spec-status { font-size: 0.6rem; text-transform: uppercase; }144+.os-status-added { color: #34d399; } .os-status-modified { color: #fbbf24; } .os-status-deleted { color: #f87171; }145+146+.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); }147+.os-specs-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }148+.os-specs-detail-path { font-family: var(--theme-font-mono, monospace); font-size: 0.8rem; font-weight: 500; color: var(--color-foreground, #eee); }149+.os-specs-meta { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; font-size: 0.7rem; color: var(--color-muted-foreground, #888); }150+151+/* Structured spec content */152+.os-spec-content { display: flex; flex-direction: column; gap: 1rem; }153+.os-spec-purpose { border-left: 3px solid var(--color-primary, #60a5fa); padding-left: 0.625rem; }154+.os-spec-purpose-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted-foreground, #777); margin-bottom: 0.25rem; }155+.os-spec-purpose p { margin: 0; font-size: 0.85rem; line-height: 1.5; color: var(--color-foreground, #eee); }156+.os-spec-req { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); overflow: hidden; }157+.os-spec-req-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.625rem; background: var(--color-secondary, rgba(255,255,255,0.04)); }158+.os-spec-req-num { font-size: 0.65rem; font-weight: 700; font-family: var(--theme-font-mono, monospace); color: var(--color-primary, #60a5fa); background: color-mix(in srgb, var(--color-primary, #60a5fa) 12%, transparent); padding: 0.0625rem 0.3125rem; border-radius: var(--radius-sm, 0.25rem); }159+.os-spec-req-name { font-size: 0.82rem; font-weight: 600; color: var(--color-foreground, #eee); }160+.os-spec-req-desc { margin: 0; padding: 0.5rem 0.625rem; font-size: 0.8rem; line-height: 1.5; color: var(--color-muted-foreground, #bbb); }161+.os-spec-scn { margin: 0 0.625rem 0.5rem; padding: 0.5rem; border-left: 2px solid var(--color-border, #333); border-radius: 0 0.25rem 0.25rem 0; background: color-mix(in srgb, var(--color-secondary, rgba(255,255,255,0.03)) 50%, transparent); }162+.os-spec-scn-name { font-size: 0.75rem; font-weight: 600; color: var(--color-foreground, #ddd); margin-bottom: 0.375rem; }163+.os-spec-step { display: flex; align-items: flex-start; gap: 0.375rem; padding: 0.125rem 0; font-size: 0.76rem; line-height: 1.4; }164+.os-spec-step-label { flex-shrink: 0; font-size: 0.62rem; font-weight: 700; font-family: var(--theme-font-mono, monospace); min-width: 2.5rem; text-align: right; }165+.os-spec-step-text { flex: 1; color: var(--color-foreground, #ddd); }166+.os-spec-step-when .os-spec-step-label { color: #38bdf8; }167+.os-spec-step-then .os-spec-step-label { color: #2dd4bf; }168+.os-spec-step-and .os-spec-step-label { color: var(--color-muted-foreground, #888); }169+.os-diff-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }170+.os-diff-col { border: 1px solid var(--color-border, #2a2a2a); border-radius: var(--radius-sm, 0.375rem); padding: 0.5rem; overflow-x: auto; }171+.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; }172+.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; }
A dashboard/manifest.json +14 -0
A dashboard/plugin_api.py +771 -0
@@ -0,0 +1,771 @@
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_dates(root: Path) -> dict[str, dict[str, str]]:480+ """Return ``{rel_path: {"mtime": iso, "ctime": iso}}`` for each spec.481+482+ Uses a single ``git log`` call (newest-first). The first time a file483+ appears is its most recent commit (mtime); the last time is the oldest484+ commit (ctime / creation date). Falls back to an empty dict if git is485+ unavailable.486+ """487+ text = _git_output(488+ root,489+ ["log", "--no-merges", "--format=%cI", "--name-only", "--", "openspec/specs/"],490+ timeout=10,491+ )492+ if not text:493+ return {}494+ prefix = "openspec/specs/"495+ dates: dict[str, dict[str, str]] = {}496+ current_date: Optional[str] = None497+ for line in text.splitlines():498+ if not line:499+ continue500+ if line.startswith(prefix):501+ rel = line[len(prefix):]502+ if not rel.endswith(".md") or not current_date:503+ continue504+ if rel not in dates:505+ dates[rel] = {"mtime": current_date, "ctime": current_date}506+ else:507+ dates[rel]["ctime"] = current_date508+ else:509+ current_date = line.strip()510+ return dates511+512+513+def _read_ref_spec(root: Path, ref: str, spec_path: str) -> Optional[str]:514+ ref = (ref or "").strip()515+ if not ref:516+ return None517+ if spec_path.startswith("/") or ".." in Path(spec_path).parts:518+ return None519+ return _git_output(root, ["show", f"{ref}:openspec/specs/{spec_path}"], timeout=8)520+521+522+def _spec_title_from_content(content: Optional[str], fallback: str) -> str:523+ for line in (content or "").splitlines():524+ stripped = line.strip()525+ if stripped.startswith("# "):526+ return stripped[2:].strip() or fallback527+ return fallback528+529+530+def _spec_diff(before: Optional[str], after: Optional[str], path: str) -> str:531+ return "\n".join(532+ difflib.unified_diff(533+ (before or "").splitlines(),534+ (after or "").splitlines(),535+ fromfile=f"before/{path}",536+ tofile=f"after/{path}",537+ lineterm="",538+ )539+ )540+541+542+def _spec_status(before: Optional[str], after: Optional[str]) -> str:543+ if before is None and after is None:544+ return "missing"545+ if before is None:546+ return "added"547+ if after is None:548+ return "deleted"549+ if before != after:550+ return "modified"551+ return "unchanged"552+553+554+def _spec_browser(root: Path, before: str = "", after: str = "", dirty: bool = False) -> dict[str, Any]:555+ before_ref = (before or "").strip()556+ after_ref = (after or "").strip()557+ dirty_mode = bool(dirty)558+559+ if dirty_mode:560+ before_ref = before_ref or "HEAD"561+ after_label = "working tree"562+ before_paths = _list_ref_specs(root, before_ref)563+ after_paths = _list_worktree_specs(root)564+ mode = "dirty"565+ elif before_ref or after_ref:566+ if not before_ref or not after_ref:567+ raise HTTPException(status_code=400, detail="both before and after are required for ref diff")568+ after_label = after_ref569+ before_paths = _list_ref_specs(root, before_ref)570+ after_paths = _list_ref_specs(root, after_ref)571+ mode = "refs"572+ else:573+ after_label = "working tree"574+ before_paths = []575+ after_paths = _list_worktree_specs(root)576+ mode = "current"577+578+ files: list[dict[str, Any]] = []579+ # Pre-fetch git commit dates for all specs in one call (current/dirty modes).580+ git_dates = _spec_git_dates(root) if mode in ("current", "dirty") else {}581+ for rel in sorted(set(before_paths) | set(after_paths)):582+ if mode == "current":583+ # No comparison — just showing the current worktree state.584+ after_content = _read_worktree_spec(root, rel)585+ gd = git_dates.get(rel, {})586+ files.append({587+ "path": rel,588+ "token": _change_token(f"spec:{rel}"),589+ "title": _spec_title_from_content(after_content, rel),590+ "status": "current" if after_content is not None else "missing",591+ "changed": False,592+ "mtime": gd.get("mtime") or _worktree_spec_mtime(root, rel),593+ "ctime": gd.get("ctime"),594+ "before": None,595+ "after": after_content,596+ "diff": "",597+ })598+ continue599+ before_content = _read_ref_spec(root, before_ref, rel) if before_ref else None600+ if dirty_mode:601+ after_content = _read_worktree_spec(root, rel)602+ gd = git_dates.get(rel, {})603+ mtime = gd.get("mtime") or _worktree_spec_mtime(root, rel)604+ ctime = gd.get("ctime")605+ else:606+ after_content = _read_ref_spec(root, after_ref, rel)607+ mtime = None608+ ctime = None609+ status = _spec_status(before_content, after_content)610+ # In dirty mode, skip unchanged specs — only show what's actually611+ # different between HEAD and the worktree.612+ if dirty_mode and status in ("unchanged", "missing"):613+ continue614+ files.append({615+ "path": rel,616+ "token": _change_token(f"spec:{rel}"),617+ "title": _spec_title_from_content(after_content if after_content is not None else before_content, rel),618+ "status": status,619+ "changed": status not in {"unchanged", "missing"},620+ "mtime": mtime,621+ "ctime": ctime,622+ "before": before_content,623+ "after": after_content,624+ "diff": _spec_diff(before_content, after_content, rel) if status not in {"unchanged", "missing"} else "",625+ })626+627+ return {628+ "mode": mode,629+ "branch": _current_branch(root),630+ "before": before_ref or None,631+ "after": after_label,632+ "files": files,633+ "changedCount": sum(1 for item in files if item["changed"]),634+ }635+636+637+# ---------------------------------------------------------------------------638+# Source resolution639+# ---------------------------------------------------------------------------640+641+class _SourceCreate(BaseModel):642+ path: str643+ name: Optional[str] = None644+645+646+def _resolve_source(source_id: str) -> tuple[dict[str, Any], Path]:647+ if _registry is None:648+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")649+ source = _registry.get_source(source_id)650+ if source is None:651+ raise HTTPException(status_code=404, detail="Source not found")652+ root, error = _source_root(source["path"])653+ if root is None:654+ raise HTTPException(status_code=400, detail=error or "Source is invalid")655+ return source, root656+657+658+# ---------------------------------------------------------------------------659+# Legacy config migration (one-time, idempotent)660+# ---------------------------------------------------------------------------661+662+_migration_done = False663+664+665+def _registered_sources() -> list[dict[str, Any]]:666+ global _migration_done667+ if _registry is None:668+ return []669+ if not _migration_done:670+ _migration_done = True671+ try:672+ from hermes_cli.config import load_config673+ dashboard = (load_config() or {}).get("dashboard") or {}674+ legacy = dashboard.get("openspec_sources")675+ if isinstance(legacy, list) and legacy:676+ _registry.migrate_from_config_sources(legacy)677+ except Exception:678+ pass679+ return _registry.list_sources()680+681+682+# ---------------------------------------------------------------------------683+# Routes684+# ---------------------------------------------------------------------------685+686+@router.get("/sources")687+def list_sources():688+ return {"sources": [_source_payload(s) for s in _registered_sources()]}689+690+691+@router.post("/sources")692+def add_source(body: _SourceCreate):693+ if _registry is None:694+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")695+ raw_path = str(body.path or "").strip()696+ if not raw_path:697+ raise HTTPException(status_code=400, detail="path is required")698+ root, error = _source_root(raw_path)699+ if root is None:700+ raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")701+ try:702+ source = _registry.add_source(raw_path, str(body.name or "").strip())703+ except ValueError:704+ raise HTTPException(status_code=409, detail="Source already registered")705+ return {"ok": True, "source": _source_payload(source)}706+707+708+@router.delete("/sources/{source_id}")709+def remove_source(source_id: str):710+ if _registry is None:711+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")712+ if not _registry.remove_source(source_id):713+ raise HTTPException(status_code=404, detail="Source not found")714+ return {"ok": True}715+716+717+@router.put("/sources/{source_id}")718+def update_source(source_id: str, body: _SourceCreate):719+ if _registry is None:720+ raise HTTPException(status_code=503, detail="OpenSpec registry not available")721+ raw_path = str(body.path or "").strip()722+ if not raw_path:723+ raise HTTPException(status_code=400, detail="path is required")724+ root, error = _source_root(raw_path)725+ if root is None:726+ raise HTTPException(status_code=400, detail=error or "No openspec/ directory found")727+ try:728+ source = _registry.update_source(source_id, path=raw_path, name=str(body.name or "").strip())729+ except ValueError:730+ raise HTTPException(status_code=409, detail="Source already registered")731+ if source is None:732+ raise HTTPException(status_code=404, detail="Source not found")733+ return {"ok": True, "source": _source_payload(source)}734+735+736+@router.get("/sources/{source_id}/changes/{change_name}")737+def change_detail(source_id: str, change_name: str):738+ _source, root = _resolve_source(source_id)739+ detail = _change_detail(root, change_name)740+ if detail is None:741+ raise HTTPException(status_code=404, detail="Change not found")742+ return detail743+744+745+@router.get("/sources/{source_id}/ideas/{idea_name}")746+def idea_detail(source_id: str, idea_name: str):747+ _source, root = _resolve_source(source_id)748+ detail = _idea_detail(root, idea_name)749+ if detail is None:750+ raise HTTPException(status_code=404, detail="Idea not found")751+ return detail752+753+754+@router.get("/sources/{source_id}/spec-browser")755+def spec_browser(756+ source_id: str,757+ before: str = "",758+ after: str = "",759+ dirty: bool = False,760+):761+ _source, root = _resolve_source(source_id)762+ return _spec_browser(root, before=before, after=after, dirty=dirty)763+764+765+@router.get("/sources/{source_id}/specs")766+def spec_detail(source_id: str, path: str = Query(...)):767+ _source, root = _resolve_source(source_id)768+ detail = _spec_detail(root, path)769+ if detail is None:770+ raise HTTPException(status_code=404, detail="Spec not found")771+ return detail
A plugin.yaml +10 -0
A registry.py +236 -0
@@ -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
A schemas.py +163 -0
@@ -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+}
A tools.py +350 -0
@@ -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"))