diff --git a/eslint.config.js b/eslint.config.js index 0bb9f9b..23ca8d8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,10 @@ // references that used to trip `no-undef` were fixed by the shared-module // refactor (api.js / dom.js / util.js / popover.js) in the same branch. export default [ + { + // Third-party minified bundles (mermaid.min.js) are not ours to lint. + ignores: ["holoctl/server/static/js/vendor/**"], + }, { files: ["holoctl/server/static/js/**/*.js"], languageOptions: { diff --git a/holoctl/CHANGELOG.md b/holoctl/CHANGELOG.md index 510b6c0..9ee51a7 100644 --- a/holoctl/CHANGELOG.md +++ b/holoctl/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to holoctl follow [Keep a Changelog](https://keepachangelog. ## [Unreleased] +## [0.22.0] — 2026-06-15 + +### Added + +- **Live spec authoring ("plano vivo no board")** — a `kind=spec` ticket body now works as a live plan document: the agent authors it from chat while the user watches the dashboard detail page update in real time; approval and status changes stay in chat (`board_move`). + - New MCP write tools: `holoctl.board_set_body` (full body replace — skeleton/restructure) and `holoctl.board_update_section` (replace/append one `# H1` section — the token-efficient default for live authoring). Both recalculate DoD checkbox counts and are listed under `permissions.ask` in the compiled Claude settings. + - Mermaid diagrams: ```` ```mermaid ```` fences render as SVG in the dashboard. Server side emits `
` with the source HTML-escaped (the `html:False` XSS control extends to this path); client side lazy-loads a vendored `mermaid.min.js` (UMD v11, ~3.2MB added to the wheel — fetched only when a page actually contains a diagram) with `securityLevel:'strict'`.
+  - Ticket detail page live-updates over the existing SSE stream: header, description and activity rail swap in place (≤2s after an edit, "Plan updated" toast), diagrams re-render after the swap, and an active inline edit defers the swap. New fragment endpoint `GET /api/project/{alias}/board/{ticket_id}/detail-html`.
+  - `holoctl-spec-flow` skill + `/spec` command rewritten for the flow: materialize the spec **early** → ensure `hctl serve` is up and hand the user the live URL → update only changed sections per discussion milestone → `review` gate with verbal approval in chat → decompose via boardmaster. Includes an authoring-efficiency guide (section-level edits, telegraphic prose, ≤15-node mermaid recipes).
+
 ## [0.21.0] — 2026-06-10
 
 Code-review follow-up release (PR #48): board integrity under concurrent
diff --git a/holoctl/lib/board.py b/holoctl/lib/board.py
index ddc1161..88d12bd 100644
--- a/holoctl/lib/board.py
+++ b/holoctl/lib/board.py
@@ -60,6 +60,32 @@ def _count_acceptance(body: str | None) -> tuple[int, int]:
     return total, done
 
 
+def _replace_section(body: str, heading: str, content: str) -> tuple[str, bool]:
+    """Replace the content under ``# {heading}`` in a markdown body.
+
+    Returns ``(new_body, replaced)`` — ``replaced`` is False when the
+    section was absent and got appended instead. Only H1 headings split
+    sections (same convention as the dashboard renderer), so `##`+ inside
+    a section is preserved as section content.
+    """
+    want = heading.strip().lstrip("#").strip()
+    block = content.strip()
+    parts = re.split(r"^(# .+)$", body, flags=re.MULTILINE)
+    for i in range(1, len(parts), 2):
+        title = parts[i][2:].strip()
+        if title.lower() != want.lower():
+            continue
+        # parts[i + 1] is everything until the next H1 (or EOF).
+        if i + 1 < len(parts):
+            parts[i + 1] = f"\n\n{block}\n\n" if block else "\n\n"
+        else:
+            parts.append(f"\n\n{block}\n\n" if block else "\n\n")
+        return "".join(parts).rstrip("\n") + "\n", True
+    base = body.rstrip("\n")
+    section = f"# {want}\n\n{block}\n" if block else f"# {want}\n"
+    return (f"{base}\n\n{section}" if base.strip() else section), False
+
+
 def _now() -> str:
     """ISO 8601 UTC timestamp with `Z` suffix, e.g. `2026-05-06T13:42:18Z`.
 
@@ -950,8 +976,13 @@ def note(self, ticket_id: str, text: str) -> dict:
         _log_activity(self._root, {"type": "ticket.note", "ticket": ticket_id, "actor": "cli"})
         return {"id": ticket_id, "note": clean, "ts": now}
 
-    def set_body(self, ticket_id: str, body: str) -> dict:
-        """Replace the body of a ticket .md, preserving frontmatter."""
+    def _apply_body(self, ticket_id: str, make_body) -> None:
+        """Rewrite a ticket body via make_body(old_body), preserving frontmatter.
+
+        Shared write path for set_body/update_section: refreshes the
+        denormalized DoD counts in the index (the new body can change the
+        checkbox set) and bumps `updated` timestamps.
+        """
         with self._locked():
             data = self._load_mut()
             ticket = next((t for t in data["tickets"] if t["id"] == ticket_id), None)
@@ -966,14 +997,13 @@ def set_body(self, ticket_id: str, body: str) -> dict:
                 raise FileNotFoundError(f"Ticket file missing: {full_path}")
 
             existing = full_path.read_text(encoding="utf-8")
-            fm, _ = parse_frontmatter(existing)
+            fm, old_body = parse_frontmatter(existing)
+            new_body = make_body(old_body)
             now = _now()
             fm["updated"] = now
-            full_path.write_text(serialize_frontmatter(fm, body), encoding="utf-8")
+            full_path.write_text(serialize_frontmatter(fm, new_body), encoding="utf-8")
 
-            # Replacing the body can change the DoD checkbox set — refresh the
-            # denormalized counts in the index.
-            acc_total, acc_done = _count_acceptance(body)
+            acc_total, acc_done = _count_acceptance(new_body)
             ticket["acceptance_total"] = acc_total
             ticket["acceptance_done"] = acc_done
             ticket["updated"] = now
@@ -981,8 +1011,32 @@ def set_body(self, ticket_id: str, body: str) -> dict:
             self._save(data)
         _log_activity(self._root, {"type": "ticket.body_updated", "ticket": ticket_id, "actor": "cli"})
 
+    def set_body(self, ticket_id: str, body: str) -> dict:
+        """Replace the body of a ticket .md, preserving frontmatter."""
+        self._apply_body(ticket_id, lambda _old: body)
         return {"id": ticket_id, "bytes": len(body)}
 
+    def update_section(self, ticket_id: str, heading: str, content: str) -> dict:
+        """Replace the content of one `# H1` section of the body.
+
+        Appends `# {heading}` at the end when the section doesn't exist yet.
+        The H1-only split mirrors the dashboard's section handling, so the
+        live-spec section convention round-trips cleanly. Heading match is
+        case-insensitive and ignores surrounding whitespace.
+        """
+        if not heading or not heading.strip().lstrip("#").strip():
+            raise ValueError("Section heading is empty.")
+        replaced = {}
+
+        def make(old_body: str) -> str:
+            new_body, did_replace = _replace_section(old_body, heading, content)
+            replaced["value"] = did_replace
+            return new_body
+
+        self._apply_body(ticket_id, make)
+        clean = heading.strip().lstrip("#").strip()
+        return {"id": ticket_id, "heading": clean, "replaced": replaced["value"]}
+
     def next_id(self) -> str:
         data = self._load()
         return self._generate_id(data["meta"]["nextId"])
diff --git a/holoctl/lib/templates.py b/holoctl/lib/templates.py
index 7a09d8e..6741898 100644
--- a/holoctl/lib/templates.py
+++ b/holoctl/lib/templates.py
@@ -133,14 +133,14 @@ def _cmd_spec_md(config: dict) -> str:
     p = config["project"]
     return f"""---
 name: spec
-description: "Spec-Driven Development entry point — turn an external board item (or a fresh idea) into a spec + child tasks ready to execute"
+description: "Spec-Driven Development entry point — turn an external board item (or a fresh idea) into a live-authored spec the user watches in the dashboard, then decompose into child tasks on approval"
 arguments: "[]"
-allowed-tools: [Bash, Read, Glob, mcp__holoctl__board_create, mcp__holoctl__board_batch, mcp__holoctl__board_show, mcp__holoctl__board_children, mcp__holoctl__board_list]
+allowed-tools: [Bash, Read, Glob, mcp__holoctl__board_create, mcp__holoctl__board_batch, mcp__holoctl__board_show, mcp__holoctl__board_children, mcp__holoctl__board_list, mcp__holoctl__board_move, mcp__holoctl__board_set_body, mcp__holoctl__board_update_section]
 ---
 
 # /spec
 
-Turn a request from an external board (Trello/Linear/Azure DevOps/Jira/GitHub/Slack — or just a pasted user story) into a structured **spec** in `.holoctl/`, then decompose it into parallel-safe child tasks ready for execution.
+Turn a request from an external board (Trello/Linear/Azure DevOps/Jira/GitHub/Slack — or just a pasted user story) into a structured **spec** in `.holoctl/`, authored **live** while you discuss it (the user watches the plan grow in the dashboard), then decompose it into parallel-safe child tasks after verbal approval in chat.
 
 ## Step 1 — Source intake
 
@@ -153,19 +153,9 @@ def _cmd_spec_md(config: dict) -> str:
 
 If no argument is given: assume the user is pasting a story / request in the conversation. Set `source_provider="manual"`.
 
-## Step 2 — Discuss to refine
+## Step 2 — Materialize the spec EARLY
 
-Reach agreement on (one batched question, never piecewise):
-
-- **Scope** — what's in, what's out
-- **Acceptance criteria** — 3-7 verifiable items
-- **Files / modules** — paths the work will touch (use `Glob` to confirm they exist)
-- **Edge cases** worth surfacing
-- **Risks / unknowns** worth flagging
-
-Don't ask if you can already infer from the source content. Default to executing, ask only when ambiguity is real.
-
-## Step 3 — Materialize the spec
+Create the spec with what's already known — **before** the deep discussion:
 
 ```
 mcp__holoctl__board_create({{
@@ -173,10 +163,6 @@ def _cmd_spec_md(config: dict) -> str:
   "kind": "spec",
   "agent": "architect",
   "priority": "",
-  "acceptance": ["", "", ...],
-  "context": "",
-  "out_of_scope": "",
-  "files": ["", ""],
   "source_provider": "",
   "source_ref": "",
   "source_url": "",
@@ -184,7 +170,38 @@ def _cmd_spec_md(config: dict) -> str:
 }})
 ```
 
-Capture the returned `SPEC_ID` ({p['prefix']}-NNN).
+Capture the returned `SPEC_ID` ({p['prefix']}-NNN). Then move it to writing state and lay down the section skeleton (hidden in the dashboard until each section is written):
+
+```
+mcp__holoctl__board_move({{"id": "", "status": "doing"}})
+mcp__holoctl__board_set_body({{"id": "", "body": "# Context\\n\\n# Goals\\n\\n# Architecture\\n\\n# Diagrams\\n\\n# Decisions\\n\\n# Risks\\n\\n# Open questions\\n\\n# Proposed ticket breakdown\\n"}})
+```
+
+## Step 2b — Serve the dashboard & hand over the live link
+
+1. Health check: `curl -s --max-time 2 http://127.0.0.1:4242/api/projects` (default `hctl serve` port; if the user runs a custom port, use the one that answers).
+2. No answer → start it in the background (`hctl serve` is blocking): `nohup hctl serve >/dev/null 2>&1 &`, wait ~2s, re-check.
+3. Resolve the project alias from the `/api/projects` payload (default: root directory name).
+4. Tell the user: `→ Watch the plan live: http://localhost:4242/project//board/`
+
+## Step 3 — Discuss & author live
+
+Discuss scope, acceptance criteria (3-7 verifiable items), files/modules (`Glob` to confirm), edge cases and risks — one batched question, never piecewise. After each **milestone** (decision closed, question answered — not every message), reflect it into the spec body, **one section at a time**:
+
+```
+mcp__holoctl__board_update_section({{"id": "", "heading": "Architecture", "content": ""}})
+```
+
+Authoring rules (see the `holoctl-spec-flow` skill for the full guide):
+
+- Send only the changed section — `board_set_body` is only for the skeleton or a full restructure.
+- Acceptance criteria go in the body as `- [ ]` checkboxes; bullets over paragraphs.
+- Diagrams are ```mermaid fences under `Diagrams` (≤ ~15 nodes, one diagram per concept) — the dashboard renders them live.
+- Keep `Open questions` a living list; resolved items move to `Decisions`.
+
+## Step 3b — Review gate
+
+Plan complete (no open questions, breakdown drafted) → `mcp__holoctl__board_move({{"id": "", "status": "review"}})` and ask for **verbal approval in chat**. Changes requested → edit sections, move back to `doing`, repeat. Only proceed after approval.
 
 ## Step 4 — Hand off to boardmaster for decomposition
 
diff --git a/holoctl/server/markdown.py b/holoctl/server/markdown.py
index d237da4..ea4cfa9 100644
--- a/holoctl/server/markdown.py
+++ b/holoctl/server/markdown.py
@@ -10,6 +10,8 @@
 import re
 
 from markdown_it import MarkdownIt
+from markdown_it.common.utils import escapeHtml
+from markdown_it.renderer import RendererHTML
 from mdit_py_plugins.tasklists import tasklists_plugin
 
 _PLACEHOLDER_PATTERNS = (
@@ -28,6 +30,25 @@
 _md = MarkdownIt("gfm-like", {"html": False, "linkify": False}).use(tasklists_plugin, enabled=True)
 
 
+def _render_fence(self, tokens, idx, options, env):
+    """Emit ```mermaid fences as `
` for client-side rendering.
+
+    The diagram source is HTML-escaped, extending the `html: False` guarantee
+    above to this custom path: no raw HTML from the fence ever reaches the
+    DOM. mermaid.js reads the node's textContent (the browser decodes the
+    entities back), so escaping is lossless for the diagram itself.
+    Non-mermaid fences keep the default `
` rendering.
+    """
+    token = tokens[idx]
+    lang = (token.info or "").strip().split(maxsplit=1)
+    if lang and lang[0].lower() == "mermaid":
+        return f'
{escapeHtml(token.content)}
\n' + return RendererHTML.fence(self, tokens, idx, options, env) + + +_md.add_render_rule("fence", _render_fence) + + def _is_placeholder_only(content: str) -> bool: real = [line.strip() for line in content.splitlines() if line.strip()] if not real: diff --git a/holoctl/server/mcp.py b/holoctl/server/mcp.py index 87046d4..79ca76a 100644 --- a/holoctl/server/mcp.py +++ b/holoctl/server/mcp.py @@ -207,6 +207,26 @@ def _tool_board_set(args: dict) -> Any: return board.set(tid, field, _coerce_set_value(value)) +def _tool_board_set_body(args: dict) -> Any: + board = _board() + tid = args.get("id") + body = args.get("body") + # An empty body is a valid replacement — only reject a missing arg. + if not tid or body is None: + raise ValueError("missing required args: id, body") + return board.set_body(tid, str(body)) + + +def _tool_board_update_section(args: dict) -> Any: + board = _board() + tid = args.get("id") + heading = args.get("heading") + content = args.get("content") + if not tid or not heading or content is None: + raise ValueError("missing required args: id, heading, content") + return board.update_section(tid, str(heading), str(content)) + + def _tool_memory_list_topics(args: dict) -> Any: from ..lib.memory import Memory mem = Memory(_project_root()) @@ -683,6 +703,45 @@ def _tool_curate_silence(args: dict) -> Any: "handler": _tool_board_set, "write": True, }, + { + "name": "holoctl.board_set_body", + "description": ( + "Replace the full markdown body of a ticket, preserving frontmatter. " + "Use for the initial section skeleton or a full restructure; for a " + "single-section edit prefer board_update_section (cheaper). " + "Acceptance checkbox counts are recalculated from the new body." + ), + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "body": {"type": "string", "description": "full new markdown body (no frontmatter)"}, + }, + "required": ["id", "body"], + }, + "handler": _tool_board_set_body, + "write": True, + }, + { + "name": "holoctl.board_update_section", + "description": ( + "Replace the content of one `# H1` section of a ticket body " + "(case-insensitive heading match); appends the section when absent. " + "Primary tool for live spec authoring: send only the changed section, " + "not the whole document." + ), + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "heading": {"type": "string", "description": "H1 title without the leading '# '"}, + "content": {"type": "string", "description": "new markdown content for the section"}, + }, + "required": ["id", "heading", "content"], + }, + "handler": _tool_board_update_section, + "write": True, + }, { "name": "holoctl.memory_list_topics", "description": "List memory topics with their scope and description.", diff --git a/holoctl/server/routes/project_detail.py b/holoctl/server/routes/project_detail.py index 0db8fdb..52010f4 100644 --- a/holoctl/server/routes/project_detail.py +++ b/holoctl/server/routes/project_detail.py @@ -11,29 +11,25 @@ router = APIRouter() -@router.get("/project/{alias}/board/{ticket_id}", response_class=HTMLResponse) -def project_ticket(alias: str, ticket_id: str): +def _build_detail_ctx(alias: str, ticket_id: str) -> tuple[dict, dict] | None: + """Load project + ticket and build the detail view context. + + Returns ``(project, ctx)`` or None when either is missing. Shared by the + full detail page and the SSE swap fragment so both render identically. + """ from ..projects import get_project from ...lib.board import Board from ...lib.markdown import parse_frontmatter project = get_project(alias) if not project: - return HTMLResponse( - render("base.html", title="Not Found", - content=render("partials/_empty_state.html", msg="Not found")), - status_code=404, - ) + return None project_root = Path(project["path"]) board = Board(project_root, project["config"]) ticket = board.get(ticket_id) if not ticket: - return HTMLResponse( - render("base.html", title="Not Found", - content=render("partials/_empty_state.html", msg="Ticket not found")), - status_code=404, - ) + return None ticket_file = project_root / ".holoctl" / "board" / ticket["file"] if ticket_file.exists(): @@ -48,6 +44,19 @@ def project_ticket(alias: str, ticket_id: str): project_root=project_root, statuses=project["config"]["board"]["statuses"], ) + return project, ctx + + +@router.get("/project/{alias}/board/{ticket_id}", response_class=HTMLResponse) +def project_ticket(alias: str, ticket_id: str): + built = _build_detail_ctx(alias, ticket_id) + if not built: + return HTMLResponse( + render("base.html", title="Not Found", + content=render("partials/_empty_state.html", msg="Not found")), + status_code=404, + ) + project, ctx = built return render( "project/detail.html", @@ -64,3 +73,13 @@ def project_ticket(alias: str, ticket_id: str): tab_base=f"/project/{alias}", **ctx, ) + + +@router.get("/api/project/{alias}/board/{ticket_id}/detail-html", response_class=HTMLResponse) +def api_detail_html(alias: str, ticket_id: str): + """Detail fragment for the SSE detail-page swap (mirrors api_board_html).""" + built = _build_detail_ctx(alias, ticket_id) + if not built: + return HTMLResponse(render("partials/_empty_state.html", msg="Ticket not found"), status_code=404) + _project, ctx = built + return HTMLResponse(render("partials/detail/_content.html", **ctx)) diff --git a/holoctl/server/static/css/markdown.css b/holoctl/server/static/css/markdown.css index 5e72491..04d965b 100644 --- a/holoctl/server/static/css/markdown.css +++ b/holoctl/server/static/css/markdown.css @@ -142,3 +142,14 @@ .detail-section-body li.task-list-item input[type="checkbox"]:checked + * { color: var(--text-2); } + +/* Mermaid diagrams (```mermaid fences →
 rendered
+   client-side by static/js/mermaid-init.js). */
+.detail-section-body pre.mermaid,
+pre.mermaid {
+  background: transparent;
+  border: none;
+  text-align: center;
+  overflow-x: auto;
+}
+pre.mermaid svg { max-width: 100%; height: auto; }
diff --git a/holoctl/server/static/js/detail-sse.js b/holoctl/server/static/js/detail-sse.js
new file mode 100644
index 0000000..868444c
--- /dev/null
+++ b/holoctl/server/static/js/detail-sse.js
@@ -0,0 +1,95 @@
+import { showToast } from './toast.js';
+import { renderMermaid } from './mermaid-init.js';
+
+// ── SSE Live Detail Updates ──
+//
+// Counterpart of sse.js for /project//board/ pages: the
+// board index is the only file the events endpoint watches, but every body
+// edit (Board.set_body / update_section) bumps the ticket's index entry, so
+// a changed entry is the signal to refetch this page's fragment. This is
+// what makes a `kind=spec` ticket a live plan document while an agent
+// authors it from chat.
+
+export function initDetailSSE() {
+  const content = document.querySelector('[data-detail-page] #detail-content');
+  if (!content) return;
+
+  const match = window.location.pathname.match(/\/project\/([^/]+)\/board\/([^/]+)$/);
+  if (!match) return;
+  const alias = match[1];
+  const ticketId = content.getAttribute('data-ticket-id') || match[2];
+
+  const source = new EventSource(`/api/project/${alias}/events`);
+  // Serialized index entry of this ticket, as last seen. Comparing the whole
+  // entry (not just `updated`) catches status/priority edits inside the same
+  // second-granularity timestamp.
+  let lastEntry = null;
+  let lastUpdated = '';
+  let inflight = false;
+  let pending = false;
+
+  async function refresh() {
+    const target = document.getElementById('detail-content');
+    if (!target) return;
+    // Don't yank the DOM out from under an active inline edit; the change
+    // is picked up on the next event (or the deferred retry below).
+    if (target.contains(document.activeElement) && document.activeElement !== document.body) {
+      pending = true;
+      return;
+    }
+    if (inflight) return;
+    inflight = true;
+    try {
+      const resp = await fetch(`/api/project/${alias}/board/${ticketId}/detail-html`, { cache: 'no-store' });
+      if (!resp.ok) return;
+      const html = (await resp.text()).trim();
+      target.innerHTML = html;
+      // Keep the seed marker fresh so an SSE reconnect doesn't refetch a
+      // fragment we already rendered.
+      target.setAttribute('data-updated', lastUpdated);
+      renderMermaid(target);
+      showToast('Plan updated', { reloadOnClick: true });
+    } catch {
+      // Fall through; next event retries.
+    } finally {
+      inflight = false;
+    }
+  }
+
+  source.addEventListener('board-update', (e) => {
+    let entry = null;
+    try {
+      const index = JSON.parse(e.data);
+      entry = (index.tickets || []).find((t) => t.id === ticketId) || null;
+    } catch {
+      return;
+    }
+    if (!entry) {
+      // Gone from the index — deleted while we were watching.
+      if (lastEntry !== null) {
+        showToast('Ticket deleted', { reloadOnClick: true });
+        source.close();
+      }
+      return;
+    }
+    const serialized = JSON.stringify(entry);
+    lastUpdated = entry.updated || '';
+    if (lastEntry === null) {
+      lastEntry = serialized;
+      // First event mirrors what's already on screen — unless the ticket
+      // changed in the window between server render and SSE connect.
+      const renderedAt = content.getAttribute('data-updated') || '';
+      if (!renderedAt || renderedAt === (entry.updated || '')) return;
+    } else if (serialized === lastEntry && !pending) {
+      return;
+    }
+    lastEntry = serialized;
+    pending = false;
+    refresh();
+  });
+
+  source.onerror = () => {
+    source.close();
+    setTimeout(() => initDetailSSE(), 5000);
+  };
+}
diff --git a/holoctl/server/static/js/index.js b/holoctl/server/static/js/index.js
index a8fe8c3..21d77a4 100644
--- a/holoctl/server/static/js/index.js
+++ b/holoctl/server/static/js/index.js
@@ -17,6 +17,8 @@ import { initViewSwitcher } from './view-switcher.js';
 import { initListSelection } from './list-selection.js';
 import { initInlineEdit } from './inline-edit.js';
 import { initMetaSearch } from './meta-search.js';
+import { initMermaid } from './mermaid-init.js';
+import { initDetailSSE } from './detail-sse.js';
 
 // Keyboard activation for elements with role="button". Native