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