Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
10 changes: 10 additions & 0 deletions holoctl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre class="mermaid">` 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
Expand Down
68 changes: 61 additions & 7 deletions holoctl/lib/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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)
Expand All @@ -966,23 +997,46 @@ 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
data["meta"]["updated"] = now
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"])
Expand Down
57 changes: 37 additions & 20 deletions holoctl/lib/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[<external-board-url-or-ref>]"
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

Expand All @@ -153,38 +153,55 @@ 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({{
"title": "<spec title>",
"kind": "spec",
"agent": "architect",
"priority": "<pN>",
"acceptance": ["<refined criterion 1>", "<refined criterion 2>", ...],
"context": "<consolidated discussion + scope + edge cases>",
"out_of_scope": "<what NOT to do>",
"files": ["<file 1>", "<file 2>"],
"source_provider": "<provider or 'manual'>",
"source_ref": "<ref or null>",
"source_url": "<url or null>",
"source_label": "<label or null>"
}})
```

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": "<SPEC_ID>", "status": "doing"}})
mcp__holoctl__board_set_body({{"id": "<SPEC_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/<alias>/board/<SPEC_ID>`

## 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": "<SPEC_ID>", "heading": "Architecture", "content": "<bullets>"}})
```

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": "<SPEC_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

Expand Down
21 changes: 21 additions & 0 deletions holoctl/server/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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 `<pre class="mermaid">` 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 `<pre><code>` rendering.
"""
token = tokens[idx]
lang = (token.info or "").strip().split(maxsplit=1)
if lang and lang[0].lower() == "mermaid":
return f'<pre class="mermaid">{escapeHtml(token.content)}</pre>\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:
Expand Down
59 changes: 59 additions & 0 deletions holoctl/server/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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.",
Expand Down
43 changes: 31 additions & 12 deletions holoctl/server/routes/project_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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",
Expand All @@ -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))
Loading
Loading