diff --git a/.b00t/tasks.json b/.b00t/tasks.json
index 0d5917a..0a7b6d4 100644
--- a/.b00t/tasks.json
+++ b/.b00t/tasks.json
@@ -284,6 +284,155 @@
"priority": 3,
"tags": [],
"created_at": "2026-09-19T03:50:39Z"
+ },
+ {
+ "id": 35,
+ "title": "kr0ki commit aed6788 on PR #34: EDIT regression fixed (format/route lost from agent renders); revisionGraph.js prompt/edit/fork DAG + vue-flow panel (time travel/fork per node); editable prompts with regenerate; chart_store.py jj-snapshotted chart persistence (/charts/* endpoints, KR0KI_CHARTS_DIR volume, jujutsu in image); live round-trip verified save/list/load/history/restore against pod; gates 26py+15vitest+19cargo+check",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T05:53:52Z"
+ },
+ {
+ "id": 36,
+ "title": "kr0ki: PR #35 rebased onto merged main (commits aed6788/249837b re-applied as d226f95 chain), MERGEABLE; gates green 38py+15vitest+check",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T07:07:56Z"
+ },
+ {
+ "id": 37,
+ "title": "kr0ki commit fc48d01 on PR #35: fix 'No active tool call found' — ask_user+propose_draft_change now emit full TOOL_CALL lifecycle before interrupts; 6/6 zod-union valid; live verified",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T07:26:05Z"
+ },
+ {
+ "id": 38,
+ "title": "kr0ki commit 9c93e1c on PR #35: storyb00k UX — autoResumeInterrupts off (run-in-progress collision), inline question UI (no gate click), dark-theme inputs/bubbles; deployed",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T07:51:58Z"
+ },
+ {
+ "id": 39,
+ "title": "kr0ki commit on PR #35: nav tab storyb00k->Agent (internal codename out of UI), editor hero paragraph trimmed to one line; deployed",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T07:58:33Z"
+ },
+ {
+ "id": 40,
+ "title": "kr0ki commit on PR #35: answer flow uses chat.resume() (fixes 'Thread has N pending interrupt(s) not addressed by resume'); sidebar copy trimmed; deployed bundle index-Di7ix3cM.js",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T08:19:19Z"
+ },
+ {
+ "id": 41,
+ "title": "kr0ki: PROJECT MEMORY fix for repeat questions (commit on PR #35); live test shows follow-up questions only for genuinely new info; deployed cb11f5f25217",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T08:49:46Z"
+ },
+ {
+ "id": 42,
+ "title": "kr0ki: PLAN-KR0KI-005 written (gallery catalog & type-discovery agent) — intent-first UX, discover question series, catalog.rs + /api/catalog, per-type skills",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T08:53:16Z"
+ },
+ {
+ "id": 43,
+ "title": "kr0ki commit dcc0103 on PR #35: Plan 005 implemented — catalog.rs (24 types, /api/catalog), gallery filter+Agent handoff, discover mode with recommend_diagram_type + type lock-in, per-type skills; live E2E accepted; 40py+15vitest+19cargo",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T09:41:03Z"
+ },
+ {
+ "id": 44,
+ "title": "kr0ki commit on PR #35: rec-* interrupts render answer UI (was falling into Approve-draft -> 400); server tolerates approval payloads on questions; deployed 6c63dd459212",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T09:52:03Z"
+ },
+ {
+ "id": 45,
+ "title": "kr0ki commit on PR #35: gallery filter bar redesign (tray, label, gradient active pill, counts, a11y, empty-state); deployed",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T11:43:19Z"
+ },
+ {
+ "id": 46,
+ "title": "kr0ki commit on PR #35: gallery type-card artifacts keyed by typeId (PlantUML cards no longer all light up); headline 'Diagram-as-code, rendered.'; deployed",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:02:53Z"
+ },
+ {
+ "id": 47,
+ "title": "kr0ki commit on PR #35: fast-track Diagram-now button (skips questions after 2 answers, best-judgement render) + dashboard generation spinner; live verified no-ask render; deployed 3812b5a6366b",
+ "status": "pending",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:39:12Z"
+ },
+ {
+ "id": 48,
+ "title": "kr0ki review: make locked diagram type prompt refine-only",
+ "status": "done",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:56:26Z",
+ "updated_at": "2026-09-19T13:06:55Z"
+ },
+ {
+ "id": 49,
+ "title": "kr0ki review: render recommendation pair for show-me-both selection",
+ "status": "done",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:56:26Z",
+ "updated_at": "2026-09-19T13:06:55Z"
+ },
+ {
+ "id": 50,
+ "title": "kr0ki review: forward gallery selected type into StoryB00k agent request",
+ "status": "done",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:56:26Z",
+ "updated_at": "2026-09-19T13:06:55Z"
+ },
+ {
+ "id": 51,
+ "title": "kr0ki review: bind gallery type cards to type-specific sample fixtures",
+ "status": "done",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:56:26Z",
+ "updated_at": "2026-09-19T13:06:55Z"
+ },
+ {
+ "id": 52,
+ "title": "kr0ki review: honor catalog type query deep links in gallery",
+ "status": "done",
+ "priority": 3,
+ "tags": [],
+ "created_at": "2026-09-19T12:56:26Z",
+ "updated_at": "2026-09-19T13:06:55Z"
}
],
"version": "1"
diff --git a/.gitmodules b/.gitmodules
index 6618177..2a78ac8 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -5,4 +5,6 @@
path = vendor/kubediagrams
url = https://github.com/PromptExecution/KubeDiagrams.git
[submodule "vendor/assistant-ui"]
+ path = vendor/assistant-ui
+ url = https://github.com/elasticdotventures/assistant-ui.git
branch = b00t-vue-package
diff --git a/Cargo.lock b/Cargo.lock
index 6f23632..6202791 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -378,6 +378,27 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -1073,6 +1094,15 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libredox"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "litemap"
version = "0.8.3"
@@ -1221,6 +1251,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
[[package]]
name = "oxilangtag"
version = "0.1.6"
@@ -1397,7 +1433,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -1487,6 +1523,16 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "redox_users"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
+dependencies = [
+ "libredox",
+ "thiserror",
+]
+
[[package]]
name = "regex"
version = "1.13.1"
@@ -1610,6 +1656,27 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "rpassword"
+version = "7.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
+dependencies = [
+ "libc",
+ "rtoolbox",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rtoolbox"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1815,6 +1882,15 @@ dependencies = [
"lazy_static",
]
+[[package]]
+name = "shellexpand"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
+dependencies = [
+ "dirs",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2275,14 +2351,16 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ufo-types"
-version = "0.14.0"
-source = "git+https://github.com/PromptExecution/ufo-types?tag=v0.14.0#f6db42afb62e5f80fde05054c68952d7d9d73062"
+version = "0.15.0"
+source = "git+https://github.com/PromptExecution/ufo-types?tag=v0.15.0#a17bf7444bba77fd049a25e943f44c8b0a8afa77"
dependencies = [
"anyhow",
"chrono",
+ "rpassword",
"schemars",
"serde",
"serde_json",
+ "shellexpand",
"thiserror",
]
@@ -2510,7 +2588,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
diff --git a/LINKS.md b/LINKS.md
new file mode 100644
index 0000000..e36289f
--- /dev/null
+++ b/LINKS.md
@@ -0,0 +1,23 @@
+https://github.com/AI4MBSE/MBSE-AI-SysML-V2
+
+https://arxiv.org/html/2609.16252v1
+Models as Governed Interfaces for AI-Native MBSE:
+Read-Side Adequacy and Write-Side Admissibility
+
+
+
+https://github.com/ieshan/ag-ui-vue
+
+
+https://docs.ag-ui.com/introduction
+
+
+https://histoire.dev/guide/
+
+
+https://tse1.mm.bing.net/th/id/OIP.GrSkv6SiYsIVdJweY2fqcgHaEt?r=0&pid=ImgDet&w=179&h=113&c=7&dpr=1.3&o=7&rm=3
+https://www.researchgate.net/profile/Rupert-England-2/publication/318373765/figure/fig1/AS:515291479068672@1499866470127/SysML-Diagrams-based-on-extended-UML-v2-subset.png
+https://arxiv.org/pdf/2508.16181v1
+LLM-Assisted Semantic Alignment and Integration
+in Collaborative Model-Based Systems Engineering
+Using SysML v2
diff --git a/containers/kr0ki-storyb00k-agent/Containerfile b/containers/kr0ki-storyb00k-agent/Containerfile
index 33babed..84f403d 100644
--- a/containers/kr0ki-storyb00k-agent/Containerfile
+++ b/containers/kr0ki-storyb00k-agent/Containerfile
@@ -14,6 +14,7 @@ COPY containers/kr0ki-storyb00k-agent/llm_client.py ./llm_client.py
COPY containers/kr0ki-storyb00k-agent/draft_graph.py ./draft_graph.py
COPY containers/kr0ki-storyb00k-agent/session_log.py ./session_log.py
COPY containers/kr0ki-storyb00k-agent/chart_store.py ./chart_store.py
+COPY containers/kr0ki-storyb00k-agent/project_store.py ./project_store.py
COPY containers/kr0ki-storyb00k-agent/skills/ ./skills/
COPY containers/kr0ki-mcp/manifest_dispatch.py ./manifest_dispatch.py
diff --git a/containers/kr0ki-storyb00k-agent/project_store.py b/containers/kr0ki-storyb00k-agent/project_store.py
new file mode 100644
index 0000000..d1786cf
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/project_store.py
@@ -0,0 +1,166 @@
+"""Per-thread PROJECT state for storyb00k.
+
+A project is the conceptual unit the user works on within a session:
+ {
+ "threadId": ...,
+ "title": short name (first prompt, editable),
+ "goal": the user's initial desire,
+ "requirements": "Locked in:" summary the agent produced (None until locked),
+ "qa": [ {"question", "answer", "ts"} ... ], # refinement history
+ "prompts": [ {"role", "text", "ts", "run"} ... ], # full prompt log
+ "thinking": [ {"text", "ts", "run"} ... ], # LLM reasoning captured server-side
+ "createdAt", "updatedAt",
+ }
+
+Persistence: JSON file beside the chart store (//project.json)
+so a project and its charts travel together and survive restarts. Also kept in
+memory for cheap reads. Nothing here talks to the LLM.
+"""
+
+import json
+import os
+import threading
+import time
+from pathlib import Path
+
+CHARTS_DIR = Path(os.environ.get("KR0KI_CHARTS_DIR", "/var/lib/kr0ki/charts"))
+MAX_TEXT = 20_000
+MAX_LOG = 500 # entries per list
+
+_lock = threading.Lock()
+_memory = {} # thread_id -> project dict
+
+
+def _thread_dir(thread_id):
+ from chart_store import slugify
+ root = CHARTS_DIR.resolve()
+ target = (root / slugify(thread_id)).resolve()
+ if not str(target).startswith(str(root) + os.sep):
+ raise ValueError(f"thread path escapes charts root: {thread_id!r}")
+ return target
+
+
+def _path(thread_id):
+ return _thread_dir(thread_id) / "project.json"
+
+
+def get_project(thread_id, create=True):
+ """Load (or lazily create) the project for a thread."""
+ with _lock:
+ project = _memory.get(thread_id)
+ if project is None:
+ path = _path(thread_id)
+ if path.is_file():
+ try:
+ project = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ project = None
+ if project is None:
+ if not create:
+ return None
+ project = {
+ "threadId": thread_id,
+ "title": "Untitled project",
+ "goal": None,
+ "requirements": None,
+ "locked": False,
+ "qa": [],
+ "prompts": [],
+ "thinking": [],
+ "createdAt": time.time(),
+ "updatedAt": time.time(),
+ }
+ _memory[thread_id] = project
+ assert project is not None
+ return project
+
+
+def save_project(thread_id, project):
+ project["updatedAt"] = time.time()
+ with _lock:
+ _memory[thread_id] = project
+ try:
+ path = _path(thread_id)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(project, indent=2, ensure_ascii=False), encoding="utf-8")
+ except (OSError, ValueError):
+ pass # in-memory copy remains authoritative for the live session
+ return project
+
+
+def set_goal(thread_id, goal):
+ project = get_project(thread_id)
+ if project.get("goal") is None:
+ project["goal"] = goal[:MAX_TEXT]
+ project["title"] = goal.strip().splitlines()[0][:80] or "Untitled project"
+ return save_project(thread_id, project)
+
+
+def add_prompt(thread_id, role, text, run=None):
+ """Full prompt/response log — user prompts, assistant narration, verbatim."""
+ project = get_project(thread_id)
+ entry = {"role": role, "text": (text or "")[:MAX_TEXT], "ts": time.time()}
+ if run:
+ entry["run"] = run
+ project["prompts"].append(entry)
+ del project["prompts"][:-MAX_LOG]
+ if role == "user":
+ set_goal(thread_id, text or "")
+ else:
+ save_project(thread_id, project)
+ return entry
+
+
+def add_thinking(thread_id, text, run=None):
+ """Capture the LLM's reasoning server-side (always, even if the browser
+ never renders it) so sessions are fully auditable."""
+ project = get_project(thread_id)
+ entry = {"text": (text or "")[:MAX_TEXT], "ts": time.time()}
+ if run:
+ entry["run"] = run
+ project["thinking"].append(entry)
+ del project["thinking"][:-MAX_LOG]
+ return save_project(thread_id, project)
+
+
+def add_qa(thread_id, question, answer):
+ project = get_project(thread_id)
+ project["qa"].append({"question": question[:MAX_TEXT], "answer": answer[:MAX_TEXT], "ts": time.time()})
+ del project["qa"][:-MAX_LOG]
+ return save_project(thread_id, project)
+
+
+def set_requirements(thread_id, requirements):
+ project = get_project(thread_id)
+ project["requirements"] = (requirements or "")[:MAX_TEXT]
+ project["locked"] = bool(requirements)
+ return save_project(thread_id, project)
+
+
+def rename(thread_id, title):
+ project = get_project(thread_id)
+ project["title"] = (title or "").strip()[:120] or project["title"]
+ return save_project(thread_id, project)
+
+
+def list_projects():
+ out = []
+ if not CHARTS_DIR.is_dir():
+ return out
+ for entry in sorted(CHARTS_DIR.iterdir()):
+ path = entry / "project.json"
+ if entry.is_dir() and path.is_file():
+ try:
+ p = json.loads(path.read_text(encoding="utf-8"))
+ out.append({
+ "threadId": p.get("threadId", entry.name),
+ "title": p.get("title"),
+ "goal": p.get("goal"),
+ "locked": p.get("locked", False),
+ "qaCount": len(p.get("qa", [])),
+ "promptCount": len(p.get("prompts", [])),
+ "updatedAt": p.get("updatedAt"),
+ })
+ except (OSError, ValueError):
+ continue
+ return out
diff --git a/containers/kr0ki-storyb00k-agent/server.py b/containers/kr0ki-storyb00k-agent/server.py
index fa03b3f..2203d0c 100644
--- a/containers/kr0ki-storyb00k-agent/server.py
+++ b/containers/kr0ki-storyb00k-agent/server.py
@@ -18,6 +18,7 @@
import os
import sys
import time
+import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -25,6 +26,7 @@
import llm_client
from draft_graph import DraftGraph
import chart_store
+import project_store
import session_log
try:
from manifest_dispatch import apply_binding, fetch_manifest, find_tool, http_call
@@ -38,6 +40,7 @@
MAX_OUTPUT_CHARS = int(os.environ.get("KR0KI_STORYB00K_MAX_OUTPUT_CHARS", "20000"))
THREAD_TTL_SECS = int(os.environ.get("KR0KI_STORYB00K_THREAD_TTL_SECS", str(6 * 3600)))
_drafts = {} # thread_id -> {"graph": DraftGraph, "last_used": epoch}
+_pending_questions = {} # thread_id -> ask_user question awaiting an answer
ALLOWED_ORIGINS = frozenset(
origin.strip()
for origin in os.environ.get(
@@ -48,13 +51,106 @@
)
SYSTEM_PREAMBLE = (
- "You are storyb00k, a read-only narrator for a live SysML/Kroki model service. "
- "Use the provided tools to fetch model facts and render diagrams. Prefer tool "
- "evidence over guessing: if you lack a fact, call a tool before answering. When "
- "the user asks for a change, propose it with the propose_draft_change tool — "
- "never claim to have modified the authoritative model. Keep narration concise."
+ "You are storyb00k, the planning front-end for a live SysML/Kroki model service "
+ "and chart workspace. You work on PROJECTS: one project = one diagram goal the "
+ "user is refining with you.\n\n"
+ "WORKFLOW — every request goes through a planning stage first:\n"
+ "1. THINK: use your thinking channel to restate the goal, list what you know, "
+ "and list what is ambiguous (diagram type? scope? level of detail? naming? layout?).\n"
+ "2. ASK: if anything material is ambiguous, use the ask_user tool to put ONE "
+ "multiple-choice question to the user. Each answer refines the shared "
+ "understanding. Ask as many rounds as needed — but batch what you can and never "
+ "re-ask something already answered.\n"
+ "3. LOCK: once you can restate the user's desire precisely, summarize the "
+ "requirements in one short paragraph and call it 'Locked in:' — the user then "
+ "sees exactly what will be built. Only after locking in, fetch model facts and "
+ "render the diagram.\n"
+ "4. RENDER: use the tools to read the live model and render. Prefer tool "
+ "evidence over guessing: if you lack a fact, call a tool before answering.\n\n"
+ "FAST-TRACK OVERRIDES (user-authorized best judgement — no more questions):\n"
+ "- If PROJECT MEMORY contains 'Fast-track: diagram now', or the user's latest "
+ "message says 'diagram now', you MUST NOT call ask_user or "
+ "recommend_diagram_type again. Pick the best-judgement type and parameters "
+ "from everything known so far, state your choices in one short paragraph "
+ "(prefixed 'Best judgement:'), treat it as the lock-in, and render immediately "
+ "in the same run.\n"
+ "- Discovery still applies the max-3-questions budget; from the third question "
+ "onward prefer fast-tracking over asking.\n\n"
+ "When the user asks for a change to the authoritative model, propose it with the "
+ "propose_draft_change tool — never claim to have modified the authoritative "
+ "model. Keep narration concise."
)
+# Multiple-choice refinement question, surfaced to the user as an interrupt.
+ask_user_tool = {
+ "type": "function",
+ "function": {
+ "name": "ask_user",
+ "description": (
+ "Ask the user ONE multiple-choice question to refine the project "
+ "requirements. Use this during planning whenever the goal is ambiguous; "
+ "keep asking until you can state the desire precisely (locked in)."
+ ),
+ "parameters": {
+ "type": "object",
+ "required": ["question", "options"],
+ "properties": {
+ "question": {"type": "string", "description": "The question to put to the user."},
+ "options": {
+ "type": "array",
+ "minItems": 2,
+ "maxItems": 6,
+ "items": {"type": "string"},
+ "description": "2–6 concise candidate answers.",
+ },
+ "allowFreeText": {"type": "boolean", "description": "Whether the user may answer in their own words. Default true."},
+ },
+ },
+ },
+}
+
+# Type-discovery tool (Plan 005 §2.2): when the user has NOT named a diagram
+# syntax, the agent discovers the right TYPE by intent before rendering.
+recommend_diagram_type_tool = {
+ "type": "function",
+ "function": {
+ "name": "recommend_diagram_type",
+ "description": (
+ "Recommend diagram type(s) for the user's goal. Call this with "
+ "mode='confirm' AFTER the discovery questions are answered, offering "
+ "exactly one primary and (optionally) one alternative type with a "
+ "one-line rationale. The user can also pick 'show me both' to see "
+ "sample renders of both types side by side."
+ ),
+ "parameters": {
+ "type": "object",
+ "required": ["mode", "recommendations"],
+ "properties": {
+ "mode": {"type": "string", "enum": ["confirm"], "description": "Always 'confirm': commit to recommendations after discovery."},
+ "recommendations": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 2,
+ "items": {
+ "type": "object",
+ "required": ["typeId", "rationale"],
+ "properties": {
+ "typeId": {"type": "string", "description": "Catalog type id (e.g. 'sequence', 'flowchart')"},
+ "rationale": {"type": "string", "description": "One line: why this type fits the intent."},
+ },
+ },
+ },
+ "question": {"type": "string", "description": "The confirm question, e.g. 'Go with a sequence diagram?'"},
+ "options": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Choices built from the recommended type names plus 'show me both' when two types are offered.",
+ },
+ },
+ },
+ },
+}
+
def load_skills():
return {path.stem: path.read_text() for path in SKILLS_DIR.glob("*.md")}
@@ -99,6 +195,47 @@ def truncate(text, limit=MAX_OUTPUT_CHARS):
return text[:limit] + f"\n… [truncated {len(text) - limit} chars]"
+def normalize_type_id(value):
+ """Normalize a UI label only for comparison with catalog type ids."""
+ return "-".join(str(value or "").strip().lower().split())
+
+
+def selected_recommendation_type(answer, recommendations):
+ """Return the canonical recommendation id for a normal confirmation choice."""
+ answer_id = normalize_type_id(answer)
+ for recommendation in recommendations:
+ type_id = normalize_type_id(recommendation.get("typeId"))
+ if type_id and type_id == answer_id:
+ return recommendation["typeId"]
+ return None
+
+
+def render_recommendation_samples(recommendations):
+ """Render the catalog fixtures for a comparison choice without involving the LLM."""
+ catalog = json.loads(urllib.request.urlopen(f"{KR0KI_URL}/api/catalog", timeout=5).read())
+ examples = json.loads(urllib.request.urlopen(f"{KR0KI_URL}/api/examples", timeout=5).read())
+ types = {entry["id"]: entry for entry in catalog.get("types", [])}
+ fixtures = {entry["id"]: entry for entry in examples}
+ panels = []
+ for recommendation in recommendations[:2]:
+ type_id = recommendation.get("typeId")
+ diagram_type = types.get(type_id, {})
+ fixture = fixtures.get(diagram_type.get("exampleId"))
+ if not fixture:
+ continue
+ route = fixture.get("route") or f"/render/{fixture['format']}"
+ content_type, result = http_call(
+ "POST", f"{KR0KI_URL}{route}?output=svg", fixture["source"].encode()
+ )
+ panel = panel_from_tool_result(
+ "render_diagram", {"format": fixture["format"], "source": fixture["source"]}, content_type, result
+ )
+ panel["title"] = diagram_type.get("name", type_id)
+ panel["source"]["route"] = fixture.get("route")
+ panels.append(panel)
+ return panels
+
+
def panel_from_tool_result(name, arguments, content_type, result):
"""Preserve binary render output rather than corrupting PNG bytes as UTF-8."""
# Tool-name → editor format/route mapping: the k8s tools take a `manifest`
@@ -181,6 +318,16 @@ def text_message(self, content, role="assistant"):
self.try_write({"type": "TEXT_MESSAGE_CONTENT", "threadId": self.thread_id, "runId": self.run_id, "messageId": message_id, "delta": content})
self.try_write({"type": "TEXT_MESSAGE_END", "threadId": self.thread_id, "runId": self.run_id, "messageId": message_id})
+ def thinking_message(self, content):
+ """Emit the model's chain of thought as THINKING_* events. The
+ @ag-ui/client maps these to a `reasoning` part on the assistant item,
+ so the transcript can show what the model was thinking."""
+ if not content:
+ return
+ self.try_write({"type": "THINKING_TEXT_MESSAGE_START", "threadId": self.thread_id, "runId": self.run_id})
+ self.try_write({"type": "THINKING_TEXT_MESSAGE_CONTENT", "threadId": self.thread_id, "runId": self.run_id, "delta": content})
+ self.try_write({"type": "THINKING_TEXT_MESSAGE_END", "threadId": self.thread_id, "runId": self.run_id})
+
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format, *_args):
@@ -224,6 +371,14 @@ def do_GET(self):
self._json(200, {"sessions": session_log.list_sessions()})
elif self.path == "/charts":
self._json(200, {"charts": chart_store.list_charts()})
+ elif self.path == "/projects":
+ self._json(200, {"projects": project_store.list_projects()})
+ elif self.path.startswith("/projects/"):
+ thread_id = self.path[len("/projects/"):] or "default"
+ project = project_store.get_project(thread_id, create=False)
+ if project is None:
+ return self._json(404, {"error": "project_not_found"})
+ self._json(200, project)
elif self.path.startswith("/charts/history/"):
thread_id = self.path[len("/charts/history/"):] or "default"
self._json(200, {"thread": thread_id, "log": chart_store.history(thread_id)})
@@ -259,6 +414,11 @@ def do_POST(self):
payload = json.loads(self.rfile.read(length)) if length else {}
except (ValueError, json.JSONDecodeError):
return self._json(400, {"error": "invalid_json"})
+ if self.path == "/projects/rename":
+ thread_id = payload.get("threadId", "default")
+ project_store.rename(thread_id, payload.get("title", ""))
+ renamed = project_store.get_project(thread_id)
+ return self._json(200, {"status": "ok", "title": renamed.get("title") if renamed else None})
if self.path == "/charts/save":
try:
result = chart_store.save_chart(
@@ -277,10 +437,67 @@ def do_POST(self):
if not commit_id:
return self._json(400, {"error": "commitId required"})
return self._json(200, chart_store.restore(thread_id, commit_id))
+ if self.path == "/projects/fasttrack":
+ thread_id = payload.get("threadId", "default")
+ project = project_store.get_project(thread_id)
+ if project is not None:
+ project["fastTrack"] = bool(payload.get("enabled", True))
+ project_store.save_project(thread_id, project)
+ return self._json(200, {"status": "ok"})
+ if self.path == "/projects/lock-type":
+ thread_id = payload.get("threadId", "default")
+ type_id = normalize_type_id(payload.get("typeId"))
+ if not type_id:
+ return self._json(400, {"error": "type_id_required"})
+ project = project_store.get_project(thread_id) or {"threadId": thread_id}
+ project["lockedType"] = type_id
+ project_store.save_project(thread_id, project)
+ return self._json(200, project)
if self.path == "/respond-to-interrupt":
- draft = _drafts.get(payload.get("threadId", "default"))
- if draft is None:
+ thread_id = payload.get("threadId", "default")
+ draft = _drafts.get(thread_id)
+ question = _pending_questions.get(thread_id)
+ is_answer = question is not None and payload.get("interruptId") == question.get("id")
+ if draft is None and not is_answer:
return self._json(404, {"error": "draft_session_not_found"})
+ if is_answer and question is not None:
+ # Planning refinement / type-confirm answer: record it in the
+ # project QA history, hand it back to the caller (the UI
+ # resumes the run with the answer recorded client-side).
+ # Tolerate an approval-style payload ({approved: true}) from a
+ # generic client: treat it as choosing the primary option.
+ if payload.get("answer") is None and payload.get("approved") is not None:
+ options = question.get("options") or []
+ payload = {**payload, "answer": options[0] if options else "yes"}
+ answer = str(payload.get("answer") or "").strip()[:2000]
+ if not answer:
+ return self._json(400, {"error": "answer_required"})
+ _pending_questions.pop(thread_id, None)
+ project_store.add_qa(thread_id, question.get("question", ""), answer)
+ if question.get("kind") == "type-confirm":
+ recommendations = question.get("recommendations") or []
+ if normalize_type_id(answer) == "show-me-both":
+ # A comparison is deliberately not a type lock. Render
+ # the catalog fixtures now so the UI can show the
+ # promised visual comparison instead of treating this
+ # label as an invalid diagram type on the next run.
+ try:
+ panels = render_recommendation_samples(recommendations)
+ except Exception as error: # noqa: BLE001 — answer remains valid if rendering is unavailable
+ return self._json(502, {"error": "comparison_render_failed", "detail": str(error)})
+ return self._json(200, {
+ "status": "ok", "answered": question.get("id"), "question": question.get("question"),
+ "answer": answer, "comparisonTypes": [r.get("typeId") for r in recommendations],
+ "comparisonPanels": panels,
+ })
+ # Lock the canonical typeId from the recommendation, not a
+ # display label supplied by the interrupt client.
+ selected_type = selected_recommendation_type(answer, recommendations)
+ project = project_store.get_project(thread_id)
+ if project is not None and selected_type:
+ project["lockedType"] = selected_type
+ project_store.save_project(thread_id, project)
+ return self._json(200, {"status": "ok", "answered": question.get("id"), "question": question.get("question"), "answer": answer})
try:
(draft["graph"].apply if payload.get("approved") else draft["graph"].decline)(payload["interruptId"])
except KeyError:
@@ -292,6 +509,13 @@ def do_POST(self):
run_id = payload.get("runId", "unknown")
thread_id = payload.get("threadId", "default")
draft = thread_draft(thread_id)
+ # Project capture: every user prompt is logged verbatim; the first
+ # prompt of a thread becomes the project goal.
+ for msg in payload.get("messages") or []:
+ if msg.get("role") == "user":
+ text = (msg.get("content") or "").strip()
+ if text:
+ project_store.add_prompt(thread_id, "user", text, run=run_id)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
@@ -315,8 +539,95 @@ def _run(self, stream, payload, thread_id, draft, run_log):
manifest = fetch_manifest(KR0KI_URL)
tools = [{"type": "function", "function": {"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]}} for tool in manifest]
tools.append(local_draft_tool())
- messages = [{"role": "system", "content": SYSTEM_PREAMBLE + "\n\n" + "\n\n".join(load_skills().values())}]
+ tools.append(ask_user_tool)
+ tools.append(recommend_diagram_type_tool)
+ project = project_store.get_project(thread_id) or {}
+ # Requirement memory: answered questions and any locked-in summary ride
+ # in the system prompt. The client replays the same messages on resume
+ # (and fresh sends may drop the answer context entirely), so without
+ # this the model re-asks questions the user already answered.
+ memory_lines = []
+ if project.get("requirements"):
+ memory_lines.append(f"Locked in requirements (do not re-litigate): {project['requirements']}")
+ for qa in project.get("qa", []):
+ memory_lines.append(f"Already answered — Q: {qa['question']} A: {qa['answer']}")
+ # Plan 005 §2: two question modes. If the user hasn't named a syntax and
+ # no type is locked, discovery comes FIRST — intent → type, max 3
+ # questions, then recommend via the tool (never ask "which syntax?").
+ if project.get("lockedType"):
+ memory_lines.append(f"Type chosen: {project['lockedType']} — do not switch without asking.")
+ # Fast-track (Plan 005 UX): after 2+ answered questions the user can
+ # authorize best-judgement rendering — the agent must not ask again.
+ if project.get("fastTrack"):
+ memory_lines.append("Fast-track: diagram now — the user has authorized best judgement; do NOT ask any further questions, render immediately.")
+ qa_memory = ""
+ if memory_lines:
+ qa_memory = (
+ "\n\nPROJECT MEMORY (the user has already answered these; NEVER ask "
+ "the same or an equivalent question again — treat each answer as a "
+ "hard requirement):\n" + "\n".join(f"- {line}" for line in memory_lines)
+ )
+ # Fast-track (Plan 005 UX) / refine-vs-discover mode selection.
+ discovery_mode = not project.get("lockedType")
+ try:
+ guide = urllib.request.urlopen(f"{KR0KI_URL}/api/catalog", timeout=5).read().decode("utf-8")
+ catalog_guide = json.loads(guide).get("discoveryGuide", "")
+ except Exception: # noqa: BLE001 — catalog hiccups must not kill the run
+ catalog_guide = ""
+ # Fast-track overrides the mode entirely: no questions allowed.
+ if project.get("fastTrack"):
+ mode_rules = (
+ "\n\nQUESTION MODES — FAST-TRACK MODE (user-authorized best judgement): "
+ "the user has authorized you to render NOW. Do NOT call ask_user or "
+ "recommend_diagram_type — asking is a contract violation. State your "
+ "best-judgement choices (prefix 'Best judgement:'), treat it as the "
+ "lock-in, and render immediately in this run."
+ )
+ elif discovery_mode:
+ mode_rules = (
+ "\n\nQUESTION MODES — pick exactly one per run:\n"
+ "- DISCOVER MODE (active now): the user has NOT named a diagram syntax. "
+ "Ask what they want to CONVEY, not which syntax: audience/purpose first, "
+ "then which intent shape fits (use the vocabulary below), optionally "
+ "fidelity. Max 3 questions. Then you MUST call recommend_diagram_type with one "
+ "primary + one alternative type id and one-line rationales — locking in "
+ "without that tool call is a contract violation; the user confirms via "
+ "its interrupt or picks 'show me both' (render both samples). NEVER ask "
+ "'which syntax/format do you want?'\n"
+ "- REFINE MODE: the type is already locked — refine participants, "
+ "scope, and level of detail only.\n"
+ + (f"\nTYPE VOCABULARY:\n{catalog_guide}\n" if catalog_guide else "")
+ )
+ else:
+ mode_rules = (
+ "\n\nQUESTION MODES — REFINE MODE (active now): the diagram type is locked. "
+ "Refine only its participants, scope, and level of detail. Do NOT call "
+ "recommend_diagram_type and do not re-enter discovery unless the user asks to switch types."
+ )
+ qa_memory += mode_rules
+ # Plan 005 §3: per-type skill loaded ONLY when the type is locked —
+ # replaces the generic skill dump so context stays small and the syntax
+ # guidance matches the chosen diagram.
+ locked_type = (project.get("lockedType") or "").strip().lower()
+ type_skill_path = SKILLS_DIR / "types" / f"{locked_type}.md"
+ if locked_type and type_skill_path.is_file():
+ skills_text = type_skill_path.read_text()
+ else:
+ skills_text = "\n\n".join(load_skills().values())
+ messages = [{"role": "system", "content": SYSTEM_PREAMBLE + qa_memory + "\n\n" + skills_text}]
messages += messages_from_payload(payload)
+ # Also inject the answers as an explicit tool-result conversation turn so
+ # the model sees them in the message flow, not only the system prompt.
+ for qa in project.get("qa", []):
+ already = any(
+ isinstance(m.get("content"), str) and qa["answer"] in m["content"]
+ for m in messages if m.get("role") == "user"
+ )
+ if not already:
+ messages.append({
+ "role": "user",
+ "content": f"(answer to your question \"{qa['question']}\"): {qa['answer']}",
+ })
client = llm_client.OpenAICompatibleClient.from_env()
usage_total = {"promptTokens": 0, "completionTokens": 0}
@@ -332,6 +643,21 @@ def _run(self, stream, payload, thread_id, draft, run_log):
usage={"promptTokens": usage.get("prompt_tokens"), "completionTokens": usage.get("completion_tokens")},
model=response.get("model"))
message = response["choices"][0]["message"]
+ if message.get("reasoning_content"):
+ # Deep-think models expose their chain of thought here; surface
+ # it through AG-UI THINKING_* events (rendered by the client as
+ # reasoning parts) instead of swallowing it. Also captured in
+ # the project log so sessions stay auditable server-side.
+ stream.thinking_message(message["reasoning_content"])
+ project_store.add_thinking(thread_id, message["reasoning_content"], run=stream.run_id)
+ if message.get("content"):
+ project_store.add_prompt(thread_id, "assistant", message["content"], run=stream.run_id)
+ # Locked-in detection: the workflow contract says the agent
+ # prefixes its requirement summary with "Locked in:".
+ content_text = message["content"]
+ marker = "Locked in:"
+ if marker in content_text and not (project_store.get_project(thread_id) or {}).get("locked"):
+ project_store.set_requirements(thread_id, content_text.split(marker, 1)[1].strip())
messages.append({
"role": "assistant",
"content": message.get("content") or "",
@@ -355,8 +681,114 @@ def _run(self, stream, payload, thread_id, draft, run_log):
arguments = {}
tool_call_id = call.get("id", name)
parent_message_id = f"msg-tool-{tool_call_id}"
+ if name == "recommend_diagram_type":
+ # Plan 005 §2.2: the agent commits to 1–2 type recommendations.
+ # The confirm answer locks the type into the project.
+ recs = arguments.get("recommendations") or []
+ rec_id = f"rec-{uuid.uuid4().hex[:12]}"
+ stream.try_write({"type": "TOOL_CALL_START", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "toolCallName": name, "parentMessageId": parent_message_id})
+ stream.try_write({"type": "TOOL_CALL_ARGS", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "delta": call["function"]["arguments"] or "{}"})
+ stream.try_write({"type": "TOOL_CALL_END", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id})
+ stream.try_write({
+ "type": "TOOL_CALL_RESULT", "threadId": thread_id, "runId": stream.run_id,
+ "messageId": parent_message_id, "toolCallId": tool_call_id,
+ "content": f"Recommended: {', '.join(r.get('typeId', '?') for r in recs) or 'none'}", "role": "tool",
+ })
+ _pending_questions[thread_id] = {
+ "id": rec_id,
+ "question": arguments.get("question", "Go with the recommended type?"),
+ "options": arguments.get("options") or [r.get("typeId") for r in recs],
+ "allowFreeText": True,
+ "toolCallId": tool_call_id,
+ "kind": "type-confirm",
+ "recommendations": recs,
+ "ts": time.time(),
+ }
+ run_log.event("plan.recommend", {"id": rec_id, "recommendations": recs})
+ stream.try_write(lifecycle_event("RUN_FINISHED", thread_id, stream.run_id, outcome={
+ "type": "interrupt",
+ "interrupts": [{
+ "id": rec_id,
+ "reason": arguments.get("question", "Go with the recommended type?"),
+ "responseSchema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string"},
+ "options": {"type": "array", "items": {"type": "string"}, "default": arguments.get("options") or [r.get("typeId") for r in recs]},
+ },
+ "required": ["answer"],
+ },
+ }],
+ }))
+ return
+ if name == "ask_user":
+ # Planning refinement: surface the question as an interrupt.
+ # The client answers via /respond-to-interrupt; the answer
+ # lands in _pending_answers so the next /run call (which
+ # carries the full conversation again) sees it via the
+ # messages the client appends. We park the run here.
+ question_id = f"ask-{uuid.uuid4().hex[:12]}"
+ options = arguments.get("options") or []
+ # Contract: TOOL_CALL_START → ARGS → END → TOOL_CALL_RESULT.
+ # Skipping START/ARGS made the client reject END ("No active
+ # tool call found") and abort the run before the interrupt.
+ stream.try_write({
+ "type": "TOOL_CALL_START", "threadId": thread_id, "runId": stream.run_id,
+ "toolCallId": tool_call_id, "toolCallName": name, "parentMessageId": parent_message_id,
+ })
+ stream.try_write({
+ "type": "TOOL_CALL_ARGS", "threadId": thread_id, "runId": stream.run_id,
+ "toolCallId": tool_call_id, "delta": call["function"]["arguments"] or "{}",
+ })
+ stream.try_write({
+ "type": "TOOL_CALL_END", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id,
+ })
+ stream.try_write({
+ "type": "TOOL_CALL_RESULT", "threadId": thread_id, "runId": stream.run_id,
+ "messageId": parent_message_id, "toolCallId": tool_call_id,
+ "content": f"Question put to user: {arguments.get('question', '')}", "role": "tool",
+ })
+ _pending_questions[thread_id] = {
+ "id": question_id,
+ "question": arguments.get("question", ""),
+ "options": options,
+ "allowFreeText": bool(arguments.get("allowFreeText", True)),
+ "toolCallId": tool_call_id,
+ "ts": time.time(),
+ }
+ run_log.event("plan.question", {"id": question_id, "question": arguments.get("question", ""), "options": options})
+ # Interrupt outcome shape per RunFinishedInterruptOutcomeSchema.
+ stream.try_write(lifecycle_event("RUN_FINISHED", thread_id, stream.run_id, outcome={
+ "type": "interrupt",
+ "interrupts": [{
+ "id": question_id,
+ "reason": arguments.get("question", ""),
+ "responseSchema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string", "description": "Chosen option or free text"},
+ "optionIndex": {"type": "integer"},
+ "options": {"type": "array", "items": {"type": "string"}, "default": options, "description": "Candidate answers offered"},
+ "allowFreeText": {"type": "boolean", "default": bool(arguments.get("allowFreeText", True))},
+ },
+ "required": ["answer"],
+ },
+ }],
+ }))
+ return
if name == "propose_draft_change":
proposal_id = draft.propose(arguments.get("subject", ""), arguments.get("predicate", ""), arguments.get("object", ""))
+ # Contract: the assistant emitted a tool_call, so the client
+ # needs the START → ARGS → END → RESULT lifecycle before the
+ # interrupt, same as ask_user.
+ stream.try_write({"type": "TOOL_CALL_START", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "toolCallName": name, "parentMessageId": parent_message_id})
+ stream.try_write({"type": "TOOL_CALL_ARGS", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, "delta": call["function"]["arguments"] or "{}"})
+ stream.try_write({"type": "TOOL_CALL_END", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id})
+ stream.try_write({
+ "type": "TOOL_CALL_RESULT", "threadId": thread_id, "runId": stream.run_id,
+ "messageId": parent_message_id, "toolCallId": tool_call_id,
+ "content": f"Proposal {proposal_id} pending user approval.", "role": "tool",
+ })
stream.try_write({"type": "STATE_DELTA", "threadId": thread_id, "runId": stream.run_id, "delta": [{"op": "add", "path": "/drafts/-", "value": {"id": proposal_id, "status": "pending", **arguments}}]})
# Interrupt outcome shape per RunFinishedInterruptOutcomeSchema.
stream.try_write(lifecycle_event("RUN_FINISHED", thread_id, stream.run_id, outcome={
diff --git a/containers/kr0ki-storyb00k-agent/skills/types/activity.md b/containers/kr0ki-storyb00k-agent/skills/types/activity.md
new file mode 100644
index 0000000..8491687
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/skills/types/activity.md
@@ -0,0 +1,28 @@
+# Skill: activity / flow diagrams (PlantUML activity, D2 flowchart)
+
+Good at: process steps, decision branches, swimlanes. Bad at: message order
+between participants (prefer sequence).
+
+Gotchas:
+- PlantUML activity: `start`/`stop`, `if (...) then ... else ... endif`,
+ `fork` for parallel lanes, `partition` or `|Swimlane|` for ownership.
+- D2 flowchart: `a -> b: label`; shape via `a: {shape: decision}`; direction
+ `direction: down` keeps long flows readable.
+- Keep one decision per diamond — merge branches explicitly.
+
+Minimal correct sample (PlantUML):
+
+```plantuml
+@startuml
+start
+:submit request;
+if (approved?) then (yes)
+ :provision access;
+else (no)
+ :notify requester;
+endif
+stop
+@enduml
+```
+
+Render: POST source to `/render/plantuml` (or `/render/d2` for D2).
diff --git a/containers/kr0ki-storyb00k-agent/skills/types/component.md b/containers/kr0ki-storyb00k-agent/skills/types/component.md
new file mode 100644
index 0000000..6919f4a
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/skills/types/component.md
@@ -0,0 +1,29 @@
+# Skill: component diagrams (PlantUML, C4-PlantUML)
+
+Good at: system structure, interfaces between building blocks. Bad at: time
+order (prefer sequence).
+
+Gotchas:
+- PlantUML: `component`, `interface`, `package` for grouping; `..>` dashed
+ dependency arrows read better than solid for "uses".
+- C4-PlantUML (via `c4plantuml` format): `C4_Context`, `Container`,
+ `Component` macros; `System_Boundary` for the scope box.
+- Label arrows with the protocol/contract ("REST", "gRPC"), not verbs.
+
+Minimal correct sample:
+
+```plantuml
+@startuml
+package "Web Tier" {
+ [Frontend]
+ [API Gateway]
+}
+component "Auth Service" as auth
+database "Postgres" as db
+[Frontend] ..> [API Gateway] : HTTPS
+[API Gateway] ..> auth : gRPC
+[API Gateway] ..> db : SQL
+@enduml
+```
+
+Render: POST source to `/render/plantuml` (or `/render/c4plantuml`).
diff --git a/containers/kr0ki-storyb00k-agent/skills/types/erd.md b/containers/kr0ki-storyb00k-agent/skills/types/erd.md
new file mode 100644
index 0000000..d5e2473
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/skills/types/erd.md
@@ -0,0 +1,30 @@
+# Skill: entity-relationship diagrams (PlantUML ER, DBML)
+
+Good at: entities, cardinality, foreign keys. Bad at: behaviour.
+
+Gotchas:
+- PlantUML ER: `entity` blocks + `||--o{` crow's-foot lines (one-to-many),
+ `||--||` one-to-one, `}o--o{` many-to-many.
+- DBML: `Table { column type [pk] }`, `Ref: a.id > b.author_id`.
+- Name relationships with a verb phrase on the line label.
+
+Minimal correct sample (PlantUML):
+
+```plantuml
+@startuml
+entity "author" as a {
+ * id : number
+ --
+ * name : text
+}
+entity "book" as b {
+ * id : number
+ --
+ * author_id : number
+ * title : text
+}
+a ||--o{ b : writes
+@enduml
+```
+
+Render: POST source to `/render/plantuml` (or `/render/dbml` for DBML).
diff --git a/containers/kr0ki-storyb00k-agent/skills/types/sequence.md b/containers/kr0ki-storyb00k-agent/skills/types/sequence.md
new file mode 100644
index 0000000..6c85db4
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/skills/types/sequence.md
@@ -0,0 +1,28 @@
+# Skill: sequence diagrams (PlantUML)
+
+Good at: who-calls-whom in time order, request/response handoffs, protocol
+exchanges. Bad at: decision branches (prefer activity), structure (prefer
+component/class).
+
+Gotchas:
+- Participants: `actor`, `participant`, `boundary`, `control`, `entity`,
+ `database` — pick the shape that matches the thing's role.
+- Sync (solid arrow `->`) vs return (dashed `-->`); async is `->>` / `-->>`.
+- Group with `== sections ==` and `alt/else/end` for branches.
+- `autonumber` for protocol-style numbering.
+
+Minimal correct sample:
+
+```plantuml
+@startuml
+actor User
+participant "Web App" as app
+database "API" as api
+User -> app: submit form
+app -> api: POST /orders
+api --> app: 201 Created
+app --> User: confirmation
+@enduml
+```
+
+Render: POST source to `/render/plantuml` (outputs svg/png).
diff --git a/containers/kr0ki-storyb00k-agent/test_planning_flow.py b/containers/kr0ki-storyb00k-agent/test_planning_flow.py
new file mode 100644
index 0000000..c1af435
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/test_planning_flow.py
@@ -0,0 +1,351 @@
+"""End-to-end test of the planning flow over HTTP: /run with an ask_user
+tool call (LLM mocked) → interrupt frame → /respond-to-interrupt answer →
+project QA recorded. Runs the real ThreadingHTTPServer on an ephemeral port.
+"""
+
+import json
+import tempfile
+import threading
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest.mock import patch
+
+import server
+
+
+def _post(url, payload):
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return resp.status, json.loads(resp.read())
+
+
+def _post_stream(url, payload):
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return resp.read().decode("utf-8")
+
+
+class PlanningFlowTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="kr0ki-plan-e2e-")
+ for mod in (server, server.chart_store, server.project_store, server.session_log):
+ if hasattr(mod, "CHARTS_DIR"):
+ patcher = patch.object(mod, "CHARTS_DIR", Path(self.tmp) / "charts")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ if hasattr(mod, "DEBUG_LOG_DIR"):
+ patcher = patch.object(mod, "DEBUG_LOG_DIR", Path(self.tmp) / "debug")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ server.project_store._memory.clear()
+ server._pending_questions.clear()
+ server._drafts.clear()
+
+ # Mock LLM: first call asks the user a multiple-choice question.
+ self.llm = patch("llm_client.OpenAICompatibleClient")
+ mock_cls = self.llm.start()
+ self.addCleanup(self.llm.stop)
+ self.client = mock_cls.from_env.return_value
+ self.client.chat_completion.return_value = {
+ "model": "test-model",
+ "choices": [{
+ "message": {
+ "content": "Let me think about what you want.",
+ "reasoning_content": "Goal is ambiguous: which diagram type?",
+ "tool_calls": [{
+ "id": "call-1",
+ "function": {
+ "name": "ask_user",
+ "arguments": json.dumps({
+ "question": "Which diagram type?",
+ "options": ["d2", "mermaid", "k8s-topology"],
+ }),
+ },
+ }],
+ },
+ }],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5},
+ }
+
+ httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
+ self.port = httpd.server_address[1]
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ self.addCleanup(httpd.shutdown)
+
+ def test_ask_user_interrupt_and_answer(self):
+ base = f"http://127.0.0.1:{self.port}"
+ # 1. Run: agent asks a question → interrupt frame, run finishes.
+ raw = _post_stream(f"{base}/run", {
+ "threadId": "plan-1", "runId": "r1",
+ "messages": [{"role": "user", "content": "Draw my cluster"}],
+ })
+ frames = [json.loads(line[5:]) for line in raw.splitlines() if line.startswith("data: ")]
+ types = [f["type"] for f in frames]
+ self.assertIn("THINKING_TEXT_MESSAGE_CONTENT", types, types)
+ # Tool-call lifecycle must be complete before the interrupt — the
+ # client's state machine rejects END/RESULT without a prior START.
+ self.assertEqual(
+ [t for t in types if t.startswith("TOOL_CALL")],
+ ["TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"],
+ types,
+ )
+ self.assertIn("RUN_FINISHED", types)
+ finished = next(f for f in frames if f["type"] == "RUN_FINISHED")
+ self.assertEqual(finished["outcome"]["type"], "interrupt")
+ interrupt = finished["outcome"]["interrupts"][0]
+ self.assertEqual(interrupt["reason"], "Which diagram type?")
+ self.assertEqual(len(interrupt["responseSchema"]["properties"]["options"]["enum"]) if "enum" in interrupt["responseSchema"].get("properties", {}).get("options", {}) else 0, 0) # options live in the pending question, not the schema
+
+ # 2. Answer: /respond-to-interrupt records the QA and returns the answer.
+ status, body = _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-1", "interruptId": interrupt["id"], "answer": "d2",
+ })
+ self.assertEqual(status, 200)
+ self.assertEqual(body["answer"], "d2")
+
+ # 3. Project captured the prompt, thinking, and QA.
+ project = server.project_store.get_project("plan-1")
+ self.assertEqual(project["goal"], "Draw my cluster")
+ self.assertEqual(project["qa"], [{"question": "Which diagram type?", "answer": "d2", "ts": project["qa"][0]["ts"]}])
+ self.assertTrue(any("ambiguous" in t["text"] for t in project["thinking"]))
+ self.assertTrue(any(p["role"] == "user" and p["text"] == "Draw my cluster" for p in project["prompts"]))
+ self.assertTrue(any(p["role"] == "assistant" for p in project["prompts"]))
+
+ # 4. GET /projects and /projects/plan-1 expose the project.
+ status, body = _post(f"{base}/projects/rename", {"threadId": "plan-1", "title": "Cluster map"})
+ self.assertEqual(body["title"], "Cluster map")
+
+ def test_repeat_run_does_not_reask_answered_questions(self):
+ """After a QA round, the next run's LLM context carries the answer —
+ in the system prompt memory and as an explicit user turn — so the
+ agent cannot re-ask the same question."""
+ base = f"http://127.0.0.1:{self.port}"
+ # Round 1: the agent asks (mock returns ask_user tool call).
+ _post_stream(f"{base}/run", {
+ "threadId": "plan-mem", "runId": "r1",
+ "messages": [{"role": "user", "content": "Draw my cluster"}],
+ })
+ # Answer via the interrupt endpoint.
+ _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-mem",
+ "interruptId": server._pending_questions["plan-mem"]["id"],
+ "answer": "d2",
+ })
+ # Round 2: capture the messages the LLM receives on the resumed run.
+ captured = {}
+
+ def capture_completion(messages, tools):
+ captured["messages"] = messages
+ return {"model": "t", "choices": [{"message": {"content": "Locked in: a d2 diagram."}}], "usage": {}}
+
+ self.client.chat_completion.side_effect = capture_completion
+ _post_stream(f"{base}/run", {
+ "threadId": "plan-mem", "runId": "r2",
+ "messages": [{"role": "user", "content": "Draw my cluster"}],
+ })
+ system = captured["messages"][0]["content"]
+ self.assertIn("PROJECT MEMORY", system)
+ self.assertIn("Already answered", system)
+ self.assertIn("d2", system)
+ user_turns = [m["content"] for m in captured["messages"] if m["role"] == "user"]
+ self.assertTrue(any('(answer to your question "Which diagram type?")' in t and "d2" in t for t in user_turns),
+ user_turns)
+
+ def test_discover_mode_recommends_and_locks_type(self):
+ """No syntax named → agent runs discovery, recommends via the tool,
+ and the confirm answer locks the type in the project."""
+ base = f"http://127.0.0.1:{self.port}"
+ # Round 1: discovery question (mock ask_user).
+ self.client.chat_completion.side_effect = None
+ self.client.chat_completion.return_value = {
+ "model": "t",
+ "choices": [{
+ "message": {
+ "content": "",
+ "tool_calls": [{
+ "id": "c-disc",
+ "function": {
+ "name": "ask_user",
+ "arguments": json.dumps({
+ "question": "Who is this diagram for?",
+ "options": ["developers", "managers", "mixed"],
+ }),
+ },
+ }],
+ },
+ }],
+ "usage": {},
+ }
+ raw = _post_stream(f"{base}/run", {
+ "threadId": "plan-disc", "runId": "r1",
+ "messages": [{"role": "user", "content": "I want to show how our release process works"}],
+ })
+ frames = [json.loads(l[5:]) for l in raw.splitlines() if l.startswith("data: {")]
+ interrupt = next(f for f in frames if f["type"] == "RUN_FINISHED")["outcome"]["interrupts"][0]
+ _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-disc", "interruptId": interrupt["id"], "answer": "mixed",
+ })
+ # Round 2: the agent recommends a type via recommend_diagram_type.
+ self.client.chat_completion.return_value = {
+ "model": "t",
+ "choices": [{
+ "message": {
+ "content": "",
+ "tool_calls": [{
+ "id": "c-rec",
+ "function": {
+ "name": "recommend_diagram_type",
+ "arguments": json.dumps({
+ "mode": "confirm",
+ "recommendations": [
+ {"typeId": "activity", "rationale": "process flow with swimlanes"},
+ {"typeId": "flowchart", "rationale": "simpler branch view"},
+ ],
+ "question": "Go with an activity diagram?",
+ "options": ["activity", "flowchart", "show me both"],
+ }),
+ },
+ }],
+ },
+ }],
+ "usage": {},
+ }
+ raw = _post_stream(f"{base}/run", {
+ "threadId": "plan-disc", "runId": "r2",
+ "messages": [{"role": "user", "content": "I want to show how our release process works"}],
+ })
+ frames = [json.loads(l[5:]) for l in raw.splitlines() if l.startswith("data: {")]
+ rec = next(f for f in frames if f["type"] == "RUN_FINISHED")["outcome"]["interrupts"][0]
+ # Options ride the responseSchema default (zod-stripped otherwise).
+ self.assertEqual(rec["responseSchema"]["properties"]["options"]["default"], ["activity", "flowchart", "show me both"])
+ # Confirm → type locked into the project.
+ _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-disc", "interruptId": rec["id"], "answer": "activity",
+ })
+ project = server.project_store.get_project("plan-disc")
+ self.assertEqual(project["lockedType"], "activity")
+ # Round 3: system prompt carries the locked type → refine mode.
+ captured = {}
+
+ def capture(messages, tools):
+ captured["system"] = messages[0]["content"]
+ return {"model": "t", "choices": [{"message": {"content": "Locked in: activity diagram of the release process."}}], "usage": {}}
+
+ self.client.chat_completion.side_effect = capture
+ _post_stream(f"{base}/run", {
+ "threadId": "plan-disc", "runId": "r3",
+ "messages": [{"role": "user", "content": "I want to show how our release process works"}],
+ })
+ self.assertIn("REFINE MODE", captured["system"])
+ self.assertIn("Type chosen: activity", captured["system"])
+ self.assertNotIn("DISCOVER MODE (active now)", captured["system"])
+
+ def test_type_confirmation_uses_canonical_recommendation_id_and_compares_both(self):
+ base = f"http://127.0.0.1:{self.port}"
+ recommendations = [
+ {"typeId": "activity", "rationale": "workflow"},
+ {"typeId": "flowchart", "rationale": "simpler flow"},
+ ]
+ server._pending_questions["plan-choice"] = {
+ "id": "rec-choice", "kind": "type-confirm", "question": "Choose",
+ "options": ["Activity", "Flowchart", "Show me both"], "recommendations": recommendations,
+ }
+ status, _ = _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-choice", "interruptId": "rec-choice", "answer": "Activity",
+ })
+ self.assertEqual(status, 200)
+ self.assertEqual(server.project_store.get_project("plan-choice")["lockedType"], "activity")
+
+ server._pending_questions["plan-both"] = {
+ "id": "rec-both", "kind": "type-confirm", "question": "Choose",
+ "options": ["Activity", "Flowchart", "Show me both"], "recommendations": recommendations,
+ }
+ with patch.object(server, "render_recommendation_samples", return_value=[{"kind": "render"}, {"kind": "render"}]) as render:
+ status, body = _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-both", "interruptId": "rec-both", "answer": "show me both",
+ })
+ self.assertEqual(status, 200)
+ render.assert_called_once_with(recommendations)
+ self.assertEqual(body["comparisonTypes"], ["activity", "flowchart"])
+ self.assertEqual(len(body["comparisonPanels"]), 2)
+ self.assertNotIn("lockedType", server.project_store.get_project("plan-both"))
+
+ def test_approval_payload_on_question_tolerated(self):
+ """A generic client that posts {approved: true} to a rec-* question
+ gets the primary option as the answer instead of a 400."""
+ base = f"http://127.0.0.1:{self.port}"
+ self.client.chat_completion.side_effect = None
+ self.client.chat_completion.return_value = {
+ "model": "t",
+ "choices": [{
+ "message": {
+ "content": "",
+ "tool_calls": [{
+ "id": "c-rec2",
+ "function": {
+ "name": "recommend_diagram_type",
+ "arguments": json.dumps({
+ "mode": "confirm",
+ "recommendations": [{"typeId": "flowchart", "rationale": "r"}],
+ "question": "Go with a flowchart?",
+ "options": ["Flowchart", "Show me both"],
+ }),
+ },
+ }],
+ },
+ }],
+ "usage": {},
+ }
+ _post_stream(f"{base}/run", {
+ "threadId": "plan-tol", "runId": "r1",
+ "messages": [{"role": "user", "content": "draw it"}],
+ })
+ status, body = _post(f"{base}/respond-to-interrupt", {
+ "threadId": "plan-tol",
+ "interruptId": server._pending_questions["plan-tol"]["id"],
+ "approved": True,
+ })
+ self.assertEqual(status, 200)
+ self.assertEqual(body["answer"], "Flowchart")
+ project = server.project_store.get_project("plan-tol")
+ self.assertEqual(project["lockedType"], "flowchart")
+
+ def test_fast_track_skips_further_questions(self):
+ """POST /projects/fasttrack arms best-judgement mode: the next run's
+ system prompt carries the fast-track override and the answered QA."""
+ base = f"http://127.0.0.1:{self.port}"
+ status, _ = _post(f"{base}/projects/fasttrack", {"threadId": "plan-ft", "enabled": True})
+ self.assertEqual(status, 200)
+ captured = {}
+
+ def capture(messages, tools):
+ captured["system"] = messages[0]["content"]
+ return {"model": "t", "choices": [{"message": {"content": "Best judgement: a flowchart."}}], "usage": {}}
+
+ self.client.chat_completion.side_effect = capture
+ _post_stream(f"{base}/run", {
+ "threadId": "plan-ft", "runId": "r1",
+ "messages": [{"role": "user", "content": "diagram now"}],
+ })
+ self.assertIn("Fast-track: diagram now", captured["system"])
+ self.assertIn("MUST NOT call ask_user", captured["system"])
+ self.assertIn("FAST-TRACK MODE", captured["system"])
+
+ def test_answer_requires_pending_question(self):
+ req = urllib.request.Request(
+ f"http://127.0.0.1:{self.port}/respond-to-interrupt",
+ data=json.dumps({"threadId": "no-question", "interruptId": "ask-none", "answer": "x"}).encode(),
+ headers={"Content-Type": "application/json"},
+ )
+ try:
+ urllib.request.urlopen(req, timeout=10)
+ self.fail("expected 404")
+ except urllib.error.HTTPError as err:
+ self.assertEqual(err.code, 404)
+ self.assertEqual(json.loads(err.read())["error"], "draft_session_not_found")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/containers/kr0ki-storyb00k-agent/test_project_store.py b/containers/kr0ki-storyb00k-agent/test_project_store.py
new file mode 100644
index 0000000..c1b1724
--- /dev/null
+++ b/containers/kr0ki-storyb00k-agent/test_project_store.py
@@ -0,0 +1,79 @@
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import project_store
+
+
+class ProjectStoreTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="kr0ki-project-test-")
+ patcher = patch.object(project_store, "CHARTS_DIR", Path(self.tmp) / "charts")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ project_store._memory.clear()
+
+ def test_goal_and_title_from_first_prompt(self):
+ project_store.add_prompt("t1", "user", "Render the cluster topology\nwith annotations")
+ p = project_store.get_project("t1")
+ self.assertEqual(p["goal"], "Render the cluster topology\nwith annotations")
+ self.assertEqual(p["title"], "Render the cluster topology")
+
+ def test_title_not_overwritten_by_later_prompts(self):
+ project_store.add_prompt("t2", "user", "First goal")
+ project_store.add_prompt("t2", "user", "Second goal")
+ p = project_store.get_project("t2")
+ self.assertEqual(p["goal"], "First goal")
+ self.assertEqual(p["prompts"][1]["role"], "user")
+
+ def test_full_prompt_log(self):
+ project_store.add_prompt("t3", "user", "hello")
+ project_store.add_prompt("t3", "assistant", "hi there", run="run-1")
+ p = project_store.get_project("t3")
+ self.assertEqual([e["role"] for e in p["prompts"]], ["user", "assistant"])
+ self.assertEqual(p["prompts"][1]["run"], "run-1")
+
+ def test_thinking_capture(self):
+ project_store.add_thinking("t4", "the user probably wants d2", run="run-9")
+ p = project_store.get_project("t4")
+ self.assertEqual(p["thinking"][0]["text"], "the user probably wants d2")
+
+ def test_qa_and_lock(self):
+ project_store.add_qa("t5", "Which diagram type?", "d2")
+ project_store.set_requirements("t5", "A d2 diagram of the cluster.")
+ p = project_store.get_project("t5")
+ self.assertEqual(p["qa"][0]["answer"], "d2")
+ self.assertTrue(p["locked"])
+ self.assertEqual(p["requirements"], "A d2 diagram of the cluster.")
+
+ def test_rename(self):
+ project_store.add_prompt("t6", "user", "goal")
+ project_store.rename("t6", "Cluster map")
+ self.assertEqual(project_store.get_project("t6")["title"], "Cluster map")
+
+ def test_persistence_round_trip(self):
+ project_store.add_prompt("t7", "user", "persist me")
+ project_store._memory.clear()
+ p = project_store.get_project("t7", create=False)
+ self.assertIsNotNone(p)
+ self.assertEqual(p["prompts"][0]["text"], "persist me")
+
+ def test_get_missing_no_create(self):
+ self.assertIsNone(project_store.get_project("nope", create=False))
+
+ def test_list_projects(self):
+ project_store.add_prompt("list-a", "user", "alpha")
+ project_store.add_prompt("list-b", "user", "beta")
+ names = {p["threadId"] for p in project_store.list_projects()}
+ self.assertIn("list-a", names)
+ self.assertIn("list-b", names)
+
+ def test_traversal_rejected(self):
+ with self.assertRaises(ValueError):
+ project_store.add_prompt("../evil", "user", "x")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/crates/kr0ki-core/Cargo.toml b/crates/kr0ki-core/Cargo.toml
index 0c44d67..c97bf2c 100644
--- a/crates/kr0ki-core/Cargo.toml
+++ b/crates/kr0ki-core/Cargo.toml
@@ -27,7 +27,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" }
# CHANGELOG guidance ("pin an exact version or a git tag"). `ontology` /
# `sysml_model` / `view` are default (not feature-gated) — no extra features
# needed here.
-ufo-types = { git = "https://github.com/PromptExecution/ufo-types", tag = "v0.14.0" }
+ufo-types = { git = "https://github.com/PromptExecution/ufo-types", tag = "v0.15.0" }
# kr0ki#13 — box 5 (b00t-graph Turtle -> D2 route): the typed TypeRelationshipGraph /
# CytoscapeGraph the D2 emitter targets. No tagged release exists yet (checked
# 2026-09-16); pinned by git rev per this issue's own operator decision, to be
diff --git a/crates/kr0ki-core/src/b00t_graph.rs b/crates/kr0ki-core/src/b00t_graph.rs
index 7456f62..5c87afc 100644
--- a/crates/kr0ki-core/src/b00t_graph.rs
+++ b/crates/kr0ki-core/src/b00t_graph.rs
@@ -257,10 +257,18 @@ pub(crate) fn d2_quote(id: &str) -> String {
format!("\"{}\"", id.replace('\\', "\\\\").replace('"', "\\\""))
}
-/// D2 labels after `:` are plain text up to the newline; only the newline
-/// itself (which would otherwise end the statement early) needs neutralizing.
+/// D2 labels after `:` are *not* safe as bare trailing text: a label
+/// beginning with `{` opens a nested map block (Kroki then rejects the
+/// whole diagram, e.g. "maps must be terminated with }"), and other D2
+/// syntax characters (`:`, `;`, `#`, …) can likewise be misread as
+/// structure rather than label content. Always emit the label as a quoted
+/// D2 string — the same escaping [`d2_quote`] uses for ids — so arbitrary
+/// caller-supplied text (a ReqIF requirement title, an RDF `rdfs:label`, a
+/// SysML element name) can never alter diagram structure. A raw newline
+/// would still break the line-oriented statement format this module
+/// builds, so it's flattened to a space before quoting.
pub(crate) fn d2_escape_label(label: &str) -> String {
- label.replace('\n', " ")
+ d2_quote(&label.replace('\n', " "))
}
#[cfg(test)]
@@ -408,10 +416,9 @@ mod tests {
});
let d2 = D2Emitter::emit(&graph.to_cytoscape());
assert!(d2.contains("\"b00t:shard/datum/*\":"));
- // The label is plain trailing text after `: ` in D2's simple key-colon-value
- // form (not itself a quoted string), so an embedded `"` needs no escaping —
- // only the ID (which D2-quotes to allow arbitrary characters) does.
- assert!(d2.contains("shard \"star\""));
+ // The label is itself a quoted D2 string now, so an embedded `"`
+ // comes out backslash-escaped, not bare.
+ assert!(d2.contains(r#": "shard \"star\"""#));
assert_eq!(
d2.lines().count(),
1,
@@ -419,6 +426,30 @@ mod tests {
);
}
+ #[test]
+ fn d2_emitter_quotes_labels_containing_d2_structural_characters() {
+ // A label beginning with `{` would otherwise open a nested D2 map
+ // block and break the whole diagram (Kroki: "maps must be
+ // terminated with }"). An `rdfs:label` is arbitrary caller text and
+ // must never be able to do this.
+ let mut graph = TypeRelationshipGraph::default();
+ graph.nodes.push(TypeNode {
+ id: "n1".to_string(),
+ label: "normal requirement { {shape: circle}".to_string(),
+ kind: "shard".to_string(),
+ parent_id: None,
+ z_layer: None,
+ semantic_type: None,
+ });
+ let d2 = D2Emitter::emit(&graph.to_cytoscape());
+ assert_eq!(d2, "\"n1\": \"normal requirement { {shape: circle}\"\n");
+ assert_eq!(
+ d2.lines().count(),
+ 1,
+ "malicious braces must not open a nested D2 block across lines"
+ );
+ }
+
#[test]
fn empty_turtle_yields_an_empty_graph() {
let graph = parse_turtle_to_type_graph(b"").unwrap();
diff --git a/crates/kr0ki-core/src/catalog.rs b/crates/kr0ki-core/src/catalog.rs
new file mode 100644
index 0000000..a97df16
--- /dev/null
+++ b/crates/kr0ki-core/src/catalog.rs
@@ -0,0 +1,425 @@
+//! Intent-first diagram-type catalog (Plan 005 §1).
+//!
+//! The taxonomy is hardcoded here in Rust — never in JS or Python (architectural
+//! rule) — and exported to the playbook via `/api/catalog` and to the agent's
+//! skills via generated text. Every entry has a distinct, addressible `id` and
+//! intent-oriented `use_cases` drawn from the controlled vocabulary below: the
+//! ergonomics contract is that users know what they want to *convey*, not which
+//! syntax fits, so browsing and discovery both go intent → type.
+
+/// Controlled vocabulary for intent tags. Gallery filter offers exactly these;
+/// multi-select is OR.
+pub const USE_CASES: &[&str] = &[
+ "process flow",
+ "interaction",
+ "data model",
+ "network",
+ "state machine",
+ "architecture",
+ "chart",
+ "sketch",
+ "timeline",
+ "hardware",
+];
+
+pub struct DiagramType {
+ /// Addressible name, stable across releases (deep links: ?type=).
+ pub id: &'static str,
+ /// kr0ki render format the sample renders through.
+ pub syntax: &'static str,
+ /// Type-specific playbook fixture rendered in this card.
+ pub example_id: &'static str,
+ /// Human name shown on the card.
+ pub name: &'static str,
+ /// Intent tags from [`USE_CASES`].
+ pub use_cases: &'static [&'static str],
+ /// One sentence: when to choose this type.
+ pub blurb: &'static str,
+ /// Sample prompt for the gallery's Agent button (names the type).
+ pub sample_prompt: &'static str,
+}
+
+/// The full taxonomy. Order is the gallery's default (All) order: grouped by
+/// use case family — flow, interaction, structure, data, sketch/hardware.
+pub const TYPES: &[DiagramType] = &[
+ // ---- flow ----
+ DiagramType {
+ id: "flowchart",
+ syntax: "d2",
+ example_id: "d2-rust-flow",
+ name: "Flowchart",
+ use_cases: &["process flow", "sketch"],
+ blurb: "Steps and decision branches in a process, drawn as boxes and arrows.",
+ sample_prompt: "Draw a flowchart of the process I'm about to describe. Ask me about the steps and decision points first.",
+ },
+ DiagramType {
+ id: "activity",
+ syntax: "plantuml",
+ example_id: "plantuml-activity",
+ name: "Activity diagram",
+ use_cases: &["process flow"],
+ blurb: "A workflow with branches, merges, and swimlanes for who does what.",
+ sample_prompt: "Draw an activity diagram (PlantUML) of the workflow I describe. Ask me about the steps, decisions, and who performs them first.",
+ },
+ DiagramType {
+ id: "use-case",
+ syntax: "plantuml",
+ example_id: "plantuml-use-case",
+ name: "Use case diagram",
+ use_cases: &["process flow", "interaction"],
+ blurb: "Actors outside a system boundary and the goals they can achieve.",
+ sample_prompt: "Draw a use case diagram (PlantUML) for the system I describe. Ask me about the actors and their goals first.",
+ },
+ DiagramType {
+ id: "state",
+ syntax: "plantuml",
+ example_id: "plantuml-state",
+ name: "State machine",
+ use_cases: &["state machine", "process flow"],
+ blurb: "The states one thing can be in and the events that move it between them.",
+ sample_prompt: "Draw a state machine diagram (PlantUML) for the thing I describe. Ask me about its states and transitions first.",
+ },
+ // ---- interaction ----
+ DiagramType {
+ id: "sequence",
+ syntax: "plantuml",
+ example_id: "plantuml-sequence",
+ name: "Sequence diagram",
+ use_cases: &["interaction", "process flow"],
+ blurb: "Who talks to whom, in what order, over time — request/response flows.",
+ sample_prompt: "Draw a sequence diagram (PlantUML) of the interaction I describe. Ask me about the participants and the order of messages first.",
+ },
+ DiagramType {
+ id: "component",
+ syntax: "plantuml",
+ example_id: "plantuml-component",
+ name: "Component diagram",
+ use_cases: &["architecture"],
+ blurb: "Software building blocks and the interfaces between them.",
+ sample_prompt: "Draw a component diagram (PlantUML) of the system I describe. Ask me about the components and their interfaces first.",
+ },
+ DiagramType {
+ id: "c4-context",
+ syntax: "c4plantuml",
+ example_id: "c4-context",
+ name: "C4 context diagram",
+ use_cases: &["architecture"],
+ blurb: "Your system as a box in the middle, surrounded by users and dependencies.",
+ sample_prompt: "Draw a C4 context diagram (C4-PlantUML) for the system I describe. Ask me about the system, its users, and external dependencies first.",
+ },
+ DiagramType {
+ id: "class",
+ syntax: "plantuml",
+ example_id: "plantuml-class",
+ name: "Class diagram",
+ use_cases: &["data model", "architecture"],
+ blurb: "Types, their fields, and inheritance/association relationships.",
+ sample_prompt: "Draw a class diagram (PlantUML) for the domain I describe. Ask me about the classes and their relationships first.",
+ },
+ // ---- data ----
+ DiagramType {
+ id: "erd",
+ syntax: "plantuml",
+ example_id: "plantuml-erd",
+ name: "Entity-relationship diagram",
+ use_cases: &["data model"],
+ blurb: "Database entities and how they reference each other (1:N, M:N).",
+ sample_prompt: "Draw an entity-relationship diagram (PlantUML) for the data I describe. Ask me about the entities and their relationships first.",
+ },
+ DiagramType {
+ id: "dbml",
+ syntax: "dbml",
+ example_id: "dbml-cache-entry",
+ name: "Database schema (DBML)",
+ use_cases: &["data model"],
+ blurb: "Tables, columns, and foreign keys in a database-flavoured notation.",
+ sample_prompt: "Draw a DBML database schema for the data model I describe. Ask me about the tables, columns, and foreign keys first.",
+ },
+ DiagramType {
+ id: "chart",
+ syntax: "vegalite",
+ example_id: "vegalite-cache-outcomes",
+ name: "Data chart (Vega-Lite)",
+ use_cases: &["chart"],
+ blurb: "Bars, lines, and points for quantitative data.",
+ sample_prompt: "Draw a Vega-Lite chart for the data I describe. Ask me about the data shape and the comparison I want to show first.",
+ },
+ // ---- network / hardware ----
+ DiagramType {
+ id: "network",
+ syntax: "nwdiag",
+ example_id: "nwdiag-topology",
+ name: "Network diagram",
+ use_cases: &["network"],
+ blurb: "Segments, nodes, and addresses — how machines connect.",
+ sample_prompt: "Draw a network diagram (nwdiag) of the infrastructure I describe. Ask me about the segments, nodes, and addressing first.",
+ },
+ DiagramType {
+ id: "k8s-topology",
+ syntax: "k8s-topology",
+ example_id: "k8s-topology-web-service",
+ name: "Kubernetes topology",
+ use_cases: &["network", "architecture"],
+ blurb: "How workloads and services in a cluster relate, from a manifest.",
+ sample_prompt: "Render the Kubernetes topology (k8s-topology) for the manifest I provide. Ask me for the manifest first if I haven't pasted one.",
+ },
+ DiagramType {
+ id: "rack",
+ syntax: "rackdiag",
+ example_id: "rackdiag-deployment",
+ name: "Rack diagram",
+ use_cases: &["hardware", "network"],
+ blurb: "Server racks with units stacked in order.",
+ sample_prompt: "Draw a rack diagram (rackdiag) of the hardware I describe. Ask me about the rack units first.",
+ },
+ DiagramType {
+ id: "packet",
+ syntax: "packetdiag",
+ example_id: "packetdiag-cache-key",
+ name: "Packet structure",
+ use_cases: &["hardware"],
+ blurb: "Byte-level packet or register layouts.",
+ sample_prompt: "Draw a packet structure diagram (packetdiag) for the protocol I describe. Ask me about the fields and widths first.",
+ },
+ DiagramType {
+ id: "wiring",
+ syntax: "wireviz",
+ example_id: "wireviz-harness",
+ name: "Wiring diagram",
+ use_cases: &["hardware"],
+ blurb: "Cables and pin-to-pin connections between connectors.",
+ sample_prompt: "Draw a wiring diagram (WireViz) for the harness I describe. Ask me about the connectors and cables first.",
+ },
+ DiagramType {
+ id: "bytefield",
+ syntax: "bytefield",
+ example_id: "bytefield-cache-key",
+ name: "Bytefield",
+ use_cases: &["hardware"],
+ blurb: "Memory and protocol layouts, byte by byte.",
+ sample_prompt: "Draw a bytefield diagram for the layout I describe. Ask me about the fields and byte boundaries first.",
+ },
+ // ---- sketch ----
+ DiagramType {
+ id: "block",
+ syntax: "blockdiag",
+ example_id: "blockdiag-pipeline",
+ name: "Block diagram",
+ use_cases: &["sketch", "architecture"],
+ blurb: "Simple labelled blocks and arrows — the whiteboard classic.",
+ sample_prompt: "Draw a block diagram (blockdiag) of what I describe. Ask me about the blocks and connections first.",
+ },
+ DiagramType {
+ id: "sketch",
+ syntax: "ditaa",
+ example_id: "ditaa-artifact-flow",
+ name: "ASCII sketch (ditaa)",
+ use_cases: &["sketch"],
+ blurb: "Hand-drawn look from ASCII-art boxes.",
+ sample_prompt: "Turn my ASCII sketch into a ditaa diagram. Ask me to paste the ASCII first.",
+ },
+ DiagramType {
+ id: "svgbob",
+ syntax: "svgbob",
+ example_id: "svgbob-ascii-pipeline",
+ name: "ASCII diagram (svgbob)",
+ use_cases: &["sketch"],
+ blurb: "Crisp rendering of ASCII-art diagrams.",
+ sample_prompt: "Render my ASCII art with svgbob. Ask me to paste the ASCII first.",
+ },
+ DiagramType {
+ id: "timing",
+ syntax: "plantuml",
+ example_id: "plantuml-timing",
+ name: "Timing diagram",
+ use_cases: &["timeline", "state machine"],
+ blurb: "Signal levels and state over a shared time axis.",
+ sample_prompt: "Draw a timing diagram (PlantUML) for the signals I describe. Ask me about the signals and their timing first.",
+ },
+ DiagramType {
+ id: "wavedrom",
+ syntax: "wavedrom",
+ example_id: "wavedrom-cache-timing",
+ name: "Waveform (WaveDrom)",
+ use_cases: &["timeline", "hardware"],
+ blurb: "Digital signal waveforms with clock edges.",
+ sample_prompt: "Draw a WaveDrom waveform for the signals I describe. Ask me about the signal names and clock cycles first.",
+ },
+ DiagramType {
+ id: "gantt",
+ syntax: "plantuml",
+ example_id: "plantuml-gantt",
+ name: "Gantt chart",
+ use_cases: &["timeline", "process flow"],
+ blurb: "Tasks on a calendar with dependencies and milestones.",
+ sample_prompt: "Draw a Gantt chart (PlantUML) for the plan I describe. Ask me about the tasks, durations, and dependencies first.",
+ },
+ DiagramType {
+ id: "deployment",
+ syntax: "plantuml",
+ example_id: "plantuml-deployment",
+ name: "Deployment diagram",
+ use_cases: &["architecture", "network"],
+ blurb: "Artifacts placed on nodes — what runs where.",
+ sample_prompt: "Draw a deployment diagram (PlantUML) for the system I describe. Ask me about the nodes and artifacts first.",
+ },
+];
+
+/// All use-case tags actually referenced by [`TYPES`], in [`USE_CASES`] order.
+/// The gallery filter renders exactly these (no empty filters).
+pub fn used_use_cases() -> Vec<&'static str> {
+ USE_CASES
+ .iter()
+ .copied()
+ .filter(|tag| TYPES.iter().any(|t| t.use_cases.contains(tag)))
+ .collect()
+}
+
+pub fn by_id(id: &str) -> Option<&'static DiagramType> {
+ TYPES.iter().find(|t| t.id == id)
+}
+
+pub fn by_use_case(tag: &str) -> Vec<&'static DiagramType> {
+ TYPES
+ .iter()
+ .filter(|t| t.use_cases.contains(&tag))
+ .collect()
+}
+
+/// The type-discovery series (Plan 005 §2.2) as agent-consumable text: the
+/// intent vocabulary and one line per use case so the LLM asks grounded
+/// questions instead of inventing categories.
+pub fn discovery_guide() -> String {
+ let mut out = String::from(
+ "DIAGRAM TYPE VOCABULARY for discovery questions — map the user's intent \
+to these families, then recommend a type by id:\n",
+ );
+ for tag in USE_CASES {
+ let types = by_use_case(tag);
+ let names: Vec<&str> = types.iter().map(|t| t.id).collect();
+ out.push_str(&format!("- {tag}: {}\n", names.join(", ")));
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::examples;
+
+ #[test]
+ fn ids_are_unique_and_addressible() {
+ let mut ids: Vec<&str> = TYPES.iter().map(|t| t.id).collect();
+ ids.sort_unstable();
+ let len = ids.len();
+ ids.dedup();
+ assert_eq!(ids.len(), len, "duplicate typeId in catalog");
+ for id in &ids {
+ assert!(
+ id.chars()
+ .all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()),
+ "typeId {id} must be deep-link-safe (lowercase/digits/dashes)"
+ );
+ }
+ }
+
+ #[test]
+ fn every_use_case_is_in_the_vocabulary() {
+ for t in TYPES {
+ for tag in t.use_cases {
+ assert!(
+ USE_CASES.contains(tag),
+ "{} uses tag '{tag}' outside the controlled vocabulary",
+ t.id
+ );
+ }
+ assert!(!t.use_cases.is_empty(), "{} has no use cases", t.id);
+ }
+ }
+
+ #[test]
+ fn every_type_resolves_to_a_known_format_and_names_itself_in_the_prompt() {
+ // Formats kr0ki actually renders (from the live fixture catalog).
+ let known = [
+ "d2",
+ "plantuml",
+ "c4plantuml",
+ "dbml",
+ "vegalite",
+ "nwdiag",
+ "k8s-topology",
+ "rackdiag",
+ "packetdiag",
+ "wireviz",
+ "bytefield",
+ "blockdiag",
+ "ditaa",
+ "svgbob",
+ "wavedrom",
+ ];
+ for t in TYPES {
+ assert!(
+ known.contains(&t.syntax),
+ "{} renders through unknown format {}",
+ t.id,
+ t.syntax
+ );
+ let lower = t.sample_prompt.to_lowercase();
+ assert!(
+ lower.contains(
+ t.name
+ .to_lowercase()
+ .split('(')
+ .next()
+ .unwrap()
+ .trim()
+ .split(' ')
+ .next()
+ .unwrap()
+ ),
+ "{} sample_prompt must name the type explicitly",
+ t.id
+ );
+ }
+ }
+
+ #[test]
+ fn every_type_points_to_a_type_specific_fixture() {
+ for diagram_type in TYPES {
+ let fixture = examples::ALL
+ .iter()
+ .find(|example| example.id == diagram_type.example_id)
+ .unwrap_or_else(|| panic!("{} fixture missing", diagram_type.id));
+ assert_eq!(
+ fixture.format, diagram_type.syntax,
+ "{} fixture syntax",
+ diagram_type.id
+ );
+ }
+ }
+
+ #[test]
+ fn used_use_cases_exclude_empty_tags() {
+ let used = used_use_cases();
+ assert!(used.contains(&"process flow"));
+ assert!(used.contains(&"interaction"));
+ for tag in used {
+ assert!(!by_use_case(tag).is_empty());
+ }
+ }
+
+ #[test]
+ fn by_id_round_trip_and_deep_link_shape() {
+ assert_eq!(by_id("sequence").unwrap().syntax, "plantuml");
+ assert!(by_id("no-such-type").is_none());
+ }
+
+ #[test]
+ fn discovery_guide_lists_every_vocabulary_family() {
+ let guide = discovery_guide();
+ for tag in USE_CASES {
+ assert!(guide.contains(tag), "guide missing family {tag}");
+ }
+ assert!(guide.contains("sequence"));
+ }
+}
diff --git a/crates/kr0ki-core/src/examples.rs b/crates/kr0ki-core/src/examples.rs
index 870d349..5f527e3 100644
--- a/crates/kr0ki-core/src/examples.rs
+++ b/crates/kr0ki-core/src/examples.rs
@@ -32,7 +32,8 @@ pub struct PlaybookExample {
pub route: Option<&'static str>,
}
-/// The full catalog: one example per [`crate::format::DiagramFormat::ALL`] entry.
+/// The full catalog: at least one example per [`crate::format::DiagramFormat`] entry,
+/// plus type-specific fixtures where several catalog types share a renderer.
pub const ALL: &[PlaybookExample] = &[
PlaybookExample {
id: "plantuml-request",
@@ -44,6 +45,106 @@ pub const ALL: &[PlaybookExample] = &[
outputs: &["svg", "png"],
route: None,
},
+ PlaybookExample {
+ id: "plantuml-activity",
+ format: "plantuml",
+ title: "Release activity flow",
+ input_kind: "PlantUML activity diagram",
+ description: "Release decisions and hand-offs, including an approval branch.",
+ source: "@startuml\nstart\n:Build release;\nif (Approved?) then (yes)\n :Deploy;\nelse (no)\n :Revise;\nendif\nstop\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-use-case",
+ format: "plantuml",
+ title: "Release use cases",
+ input_kind: "PlantUML use case diagram",
+ description: "An operator starts releases and an approver authorizes them.",
+ source: "@startuml\nleft to right direction\nactor Operator\nactor Approver\nrectangle ReleaseService {\n usecase \"Start release\" as Start\n usecase \"Approve release\" as Approve\n}\nOperator --> Start\nApprover --> Approve\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-state",
+ format: "plantuml",
+ title: "Release state machine",
+ input_kind: "PlantUML state diagram",
+ description: "A release moves from draft through approval to deployment.",
+ source: "@startuml\n[*] --> Draft\nDraft --> PendingApproval\nPendingApproval --> Approved\nPendingApproval --> Draft : revise\nApproved --> Deployed\nDeployed --> [*]\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-sequence",
+ format: "plantuml",
+ title: "Release approval sequence",
+ input_kind: "PlantUML sequence diagram",
+ description: "The operator, service, and approver exchange messages in time order.",
+ source: "@startuml\nactor Operator\nparticipant ReleaseService\nparticipant Approver\nOperator -> ReleaseService : submit release\nReleaseService -> Approver : request approval\nApprover --> ReleaseService : approve\nReleaseService --> Operator : deployed\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-component",
+ format: "plantuml",
+ title: "Release components",
+ input_kind: "PlantUML component diagram",
+ description: "The release API delegates deployment and notification responsibilities.",
+ source: "@startuml\n[Release API] --> [Deployment worker]\n[Release API] --> [Notification service]\n[Deployment worker] --> [Cluster]\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-class",
+ format: "plantuml",
+ title: "Release domain classes",
+ input_kind: "PlantUML class diagram",
+ description: "A release has artifacts and an approval decision.",
+ source: "@startuml\nclass Release {\n version\n status\n}\nclass Artifact {\n digest\n}\nclass Approval {\n approvedBy\n}\nRelease \"1\" o-- \"*\" Artifact\nRelease \"1\" --> \"1\" Approval\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-erd",
+ format: "plantuml",
+ title: "Release data relationships",
+ input_kind: "PlantUML entity relationship diagram",
+ description: "Release records reference their artifacts and approval records.",
+ source: "@startuml\nentity release {\n * id : uuid\n version : text\n}\nentity artifact {\n * digest : text\n}\nentity approval {\n * approver : text\n}\nrelease ||--o{ artifact\nrelease ||--|| approval\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-timing",
+ format: "plantuml",
+ title: "Release timing",
+ input_kind: "PlantUML timing diagram",
+ description: "Approval and deployment states share a time axis.",
+ source: "@startuml\nrobust \"approval\" as A\nrobust \"deployment\" as D\n@0\nA is Pending\nD is Idle\n@5\nA is Approved\n@10\nD is Deploying\n@15\nD is Live\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-gantt",
+ format: "plantuml",
+ title: "Release Gantt plan",
+ input_kind: "PlantUML Gantt chart",
+ description: "Build, approval, and deployment scheduled as dependent tasks.",
+ source: "@startgantt\nProject starts 2026-01-01\n[Build] lasts 2 days\n[Approve] starts at [Build]'s end and lasts 1 day\n[Deploy] starts at [Approve]'s end and lasts 1 day\n@endgantt\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
+ PlaybookExample {
+ id: "plantuml-deployment",
+ format: "plantuml",
+ title: "Release deployment",
+ input_kind: "PlantUML deployment diagram",
+ description: "A release artifact runs on the deployment worker in the cluster.",
+ source: "@startuml\nnode Cluster {\n node \"Deployment worker\" {\n artifact \"release image\"\n }\n}\ncloud Registry\nRegistry --> \"release image\"\n@enduml\n",
+ outputs: &["svg", "png"],
+ route: None,
+ },
PlaybookExample {
id: "c4-context",
format: "c4plantuml",
@@ -312,22 +413,22 @@ mod tests {
use crate::format::DiagramFormat;
#[test]
- fn covers_every_diagram_format_exactly_once() {
+ fn covers_every_diagram_format() {
for &format in DiagramFormat::ALL {
- let matches: Vec<_> = ALL
+ let count = ALL
.iter()
.filter(|example| example.format == format.kroki_slug())
- .collect();
- assert_eq!(
- matches.len(),
- 1,
- "expected exactly one example for {}, found {}",
- format.kroki_slug(),
- matches.len()
+ .count();
+ assert!(
+ count >= 1,
+ "expected an example for {}, found none",
+ format.kroki_slug()
);
}
- let format_routed = ALL.iter().filter(|e| e.route.is_none()).count();
- assert_eq!(format_routed, DiagramFormat::ALL.len());
+ let mut ids: Vec<_> = ALL.iter().map(|example| example.id).collect();
+ ids.sort_unstable();
+ ids.dedup();
+ assert_eq!(ids.len(), ALL.len(), "fixture ids must be unique");
}
#[test]
diff --git a/crates/kr0ki-core/src/lib.rs b/crates/kr0ki-core/src/lib.rs
index 28318ab..92bdafd 100644
--- a/crates/kr0ki-core/src/lib.rs
+++ b/crates/kr0ki-core/src/lib.rs
@@ -26,6 +26,7 @@
pub mod b00t_graph;
pub mod cache;
+pub mod catalog;
pub mod docgen;
pub mod examples;
pub mod flatten;
@@ -35,6 +36,11 @@ pub mod k8s_recognizer;
pub mod mcp_tool;
pub mod probe;
pub mod render;
+/// Canonical home is `ufo_types::mbse::requirements` (kr0ki M1,
+/// 2026-09-19) — re-exported here so existing `kr0ki_core::requirements::*`
+/// call sites keep working without a local copy of the types.
+pub use ufo_types::mbse::requirements;
+pub mod requirements_render;
pub mod rust_recognizer;
pub mod sysml_lift;
pub mod sysml_render;
diff --git a/crates/kr0ki-core/src/requirements_render.rs b/crates/kr0ki-core/src/requirements_render.rs
new file mode 100644
index 0000000..657fdab
--- /dev/null
+++ b/crates/kr0ki-core/src/requirements_render.rs
@@ -0,0 +1,187 @@
+//! Diagram adapter for an already-induced requirements view.
+//!
+//! It consumes [`crate::requirements::ViewResult`] rather than a ReqIF or
+//! Flexo payload, preserving the semantic-layer/renderer boundary.
+
+use std::collections::BTreeMap;
+
+use crate::b00t_graph::{d2_escape_label, d2_quote};
+use crate::requirements::ViewResult;
+
+/// Deterministically lower an induced requirement view to D2 source.
+pub fn to_d2(view: &ViewResult) -> String {
+ let mut labels = BTreeMap::new();
+ for requirement in &view.graph.requirements {
+ labels.insert(requirement.id.as_str(), requirement.title.as_str());
+ }
+ for evidence in &view.graph.evidence {
+ labels.insert(evidence.id.as_str(), evidence.label.as_str());
+ }
+ let mut output = String::new();
+ for (id, label) in labels {
+ output.push_str(&format!("{}: {}\n", d2_quote(id), d2_escape_label(label)));
+ }
+ for relation in &view.graph.relations {
+ output.push_str(&format!(
+ "{} -> {}: {:?} ({})\n",
+ d2_quote(&relation.source),
+ d2_quote(&relation.target),
+ relation.kind,
+ match relation.authority {
+ crate::requirements::RelationAuthority::Asserted => "asserted",
+ crate::requirements::RelationAuthority::Inferred(_) => "inferred",
+ crate::requirements::RelationAuthority::Proposed(_) => "proposed",
+ },
+ ));
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::to_d2;
+ use crate::requirements::{
+ BaselineIdentity, Provenance, RelationAuthority, Requirement, RequirementGraph,
+ RequirementRelation, RequirementRelationKind, TraversalDirection, ViewKind, ViewRequest,
+ ViewScope,
+ };
+ use std::collections::BTreeMap;
+
+ #[test]
+ fn emits_only_the_nodes_and_edges_in_the_induced_view() {
+ let baseline = BaselineIdentity {
+ id: "b".into(),
+ revision: "c".into(),
+ import_artifact_sha256: None,
+ exported_baseline_sha256: None,
+ };
+ let provenance = Provenance {
+ source_uri: "x".into(),
+ artifact_sha256: None,
+ locator: None,
+ };
+ let graph = RequirementGraph {
+ baseline: baseline.clone(),
+ requirements: vec![
+ Requirement {
+ id: "r1".into(),
+ title: "One".into(),
+ text: "".into(),
+ baseline: baseline.clone(),
+ provenance: provenance.clone(),
+ attributes: BTreeMap::new(),
+ evidence: vec![],
+ },
+ Requirement {
+ id: "r2".into(),
+ title: "Two".into(),
+ text: "".into(),
+ baseline,
+ provenance: provenance.clone(),
+ attributes: BTreeMap::new(),
+ evidence: vec![],
+ },
+ ],
+ evidence: vec![],
+ relations: vec![RequirementRelation {
+ id: "e".into(),
+ source: "r1".into(),
+ target: "r2".into(),
+ kind: RequirementRelationKind::Contains,
+ authority: RelationAuthority::Asserted,
+ provenance,
+ promotion: None,
+ }],
+ };
+ let view = graph
+ .view(&ViewRequest {
+ kind: ViewKind::Decomposition,
+ scope: ViewScope::Authoritative,
+ root_id: None,
+ max_depth: None,
+ direction: TraversalDirection::Downstream,
+ confirmed_behaviour: None,
+ })
+ .unwrap();
+ assert_eq!(
+ to_d2(&view),
+ "\"r1\": \"One\"\n\"r2\": \"Two\"\n\"r1\" -> \"r2\": Contains (asserted)\n"
+ );
+ }
+
+ #[test]
+ fn adversarial_titles_cannot_alter_d2_diagram_structure() {
+ // A title beginning with `{` would otherwise open a nested D2 map
+ // block (Kroki: "maps must be terminated with }"); one containing
+ // `{shape: circle}` would otherwise be read as D2 structure rather
+ // than label text. A ReqIF/Flexo requirement title is arbitrary
+ // caller-supplied text and must never be able to do either.
+ let baseline = BaselineIdentity {
+ id: "b".into(),
+ revision: "c".into(),
+ import_artifact_sha256: None,
+ exported_baseline_sha256: None,
+ };
+ let provenance = Provenance {
+ source_uri: "x".into(),
+ artifact_sha256: None,
+ locator: None,
+ };
+ let malicious_title = r#"normal requirement { {shape: circle}"#;
+ let graph = RequirementGraph {
+ baseline: baseline.clone(),
+ requirements: vec![
+ Requirement {
+ id: "r1".into(),
+ title: malicious_title.into(),
+ text: "".into(),
+ baseline: baseline.clone(),
+ provenance: provenance.clone(),
+ attributes: BTreeMap::new(),
+ evidence: vec![],
+ },
+ Requirement {
+ id: "r2".into(),
+ title: r#"embedded "quote" and \backslash"#.into(),
+ text: "".into(),
+ baseline: baseline.clone(),
+ provenance: provenance.clone(),
+ attributes: BTreeMap::new(),
+ evidence: vec![],
+ },
+ ],
+ evidence: vec![],
+ relations: vec![RequirementRelation {
+ id: "e".into(),
+ source: "r1".into(),
+ target: "r2".into(),
+ kind: RequirementRelationKind::Contains,
+ authority: RelationAuthority::Asserted,
+ provenance,
+ promotion: None,
+ }],
+ };
+ let view = graph
+ .view(&ViewRequest {
+ kind: ViewKind::Decomposition,
+ scope: ViewScope::Authoritative,
+ root_id: None,
+ max_depth: None,
+ direction: TraversalDirection::Downstream,
+ confirmed_behaviour: None,
+ })
+ .unwrap();
+ let d2 = to_d2(&view);
+ // Exactly one statement per node/edge: the malicious braces did not
+ // open a nested D2 block that spills onto (or swallows) other lines.
+ assert_eq!(d2.lines().count(), 3, "{d2}");
+ assert!(
+ d2.contains(r#""r1": "normal requirement { {shape: circle}""#),
+ "{d2}"
+ );
+ assert!(
+ d2.contains(r#""r2": "embedded \"quote\" and \\backslash""#),
+ "{d2}"
+ );
+ }
+}
diff --git a/crates/kr0ki-core/src/sysml_render.rs b/crates/kr0ki-core/src/sysml_render.rs
index 28d2501..c802e23 100644
--- a/crates/kr0ki-core/src/sysml_render.rs
+++ b/crates/kr0ki-core/src/sysml_render.rs
@@ -194,10 +194,10 @@ mod tests {
assert_eq!(
node_lines,
vec![
- "\"pkg\": pkg",
- "\"svc-a\": svc-a",
- "\"svc-b\": svc-b",
- "\"svc-c\": svc-c",
+ "\"pkg\": \"pkg\"",
+ "\"svc-a\": \"svc-a\"",
+ "\"svc-b\": \"svc-b\"",
+ "\"svc-c\": \"svc-c\"",
]
);
}
@@ -205,15 +205,15 @@ mod tests {
#[test]
fn d2_connection_with_three_ends_becomes_a_two_edge_chain() {
let d2 = to_d2(&sample_relations());
- assert!(d2.contains("\"svc-a\" -> \"svc-b\": connection"));
- assert!(d2.contains("\"svc-b\" -> \"svc-c\": connection"));
- assert!(!d2.contains("\"svc-a\" -> \"svc-c\": connection"));
+ assert!(d2.contains("\"svc-a\" -> \"svc-b\": \"connection\""));
+ assert!(d2.contains("\"svc-b\" -> \"svc-c\": \"connection\""));
+ assert!(!d2.contains("\"svc-a\" -> \"svc-c\": \"connection\""));
}
#[test]
fn d2_domain_edge_is_labeled_with_its_own_kind_not_a_generic_placeholder() {
let d2 = to_d2(&sample_relations());
- assert!(d2.contains("\"svc-a\" -> \"svc-c\": traces_to"));
+ assert!(d2.contains("\"svc-a\" -> \"svc-c\": \"traces_to\""));
}
#[test]
diff --git a/crates/kr0ki-server/src/app.rs b/crates/kr0ki-server/src/app.rs
index 6ec10b3..793d2c3 100644
--- a/crates/kr0ki-server/src/app.rs
+++ b/crates/kr0ki-server/src/app.rs
@@ -75,10 +75,13 @@ pub fn router(state: AppState, auth_token: Option) -> Router {
.route("/capabilities", get(capabilities))
.route("/api/examples", get(examples))
.route("/playbook/api/examples.json", get(examples))
+ .route("/api/catalog", get(catalog))
.route("/playbook", get(playbook_index))
.route("/playbook/", get(playbook_index))
.route("/playbook/*path", get(playbook_asset))
+ .route("/requirements/views", post(requirements_view))
.route("/render/:format", post(render))
+ .route("/render/requirements-view", post(render_requirements_view))
.route("/render/kubediagram", post(render_kubediagram))
.route("/render/k8s-topology", post(render_k8s_topology))
.route(
@@ -215,6 +218,28 @@ async fn formats() -> Json> {
Json(DiagramFormat::ALL.iter().map(|f| f.kroki_slug()).collect())
}
+/// Backend-neutral input shared by the Playb00k HTTP surface, an MCP adapter,
+/// and a future Flexo Web Modeler client. A Flexo/ReqIF adapter owns loading
+/// the baseline; kr0ki receives the normalized graph only.
+#[derive(Debug, serde::Deserialize)]
+struct RequirementsViewInput {
+ graph: kr0ki_core::requirements::RequirementGraph,
+ request: kr0ki_core::requirements::ViewRequest,
+}
+
+/// `POST /requirements/views` — return an induced typed graph, not diagram
+/// source. This is the stable view contract for requirements clients.
+async fn requirements_view(Json(input): Json) -> Response {
+ match input.graph.view(&input.request) {
+ Ok(view) => Json(view).into_response(),
+ Err(error) => error_json(
+ StatusCode::UNPROCESSABLE_ENTITY,
+ "invalid_requirement_view",
+ &error.to_string(),
+ ),
+ }
+}
+
/// `GET /mcp/tools` — the MCP/HTTP capability manifest (mcp-http-parity
/// design, 2026-09-16). One entry per `McpTool`, each carrying both its MCP
/// schema and its HTTP binding — `containers/kr0ki-mcp/bridge.py` fetches
@@ -482,6 +507,26 @@ async fn examples() -> Json<&'static [kr0ki_core::examples::PlaybookExample]> {
Json(kr0ki_core::examples::ALL)
}
+/// `GET /api/catalog` — the intent-first diagram-type taxonomy (Plan 005):
+/// distinct addressible typeIds, use-case tags for the gallery filter, and
+/// per-type sample prompts for the Agent handoff button.
+async fn catalog() -> Json {
+ use kr0ki_core::catalog;
+ Json(serde_json::json!({
+ "useCases": catalog::used_use_cases(),
+ "types": catalog::TYPES.iter().map(|t| serde_json::json!({
+ "id": t.id,
+ "syntax": t.syntax,
+ "exampleId": t.example_id,
+ "name": t.name,
+ "useCases": t.use_cases,
+ "blurb": t.blurb,
+ "samplePrompt": t.sample_prompt,
+ })).collect::>(),
+ "discoveryGuide": catalog::discovery_guide(),
+ }))
+}
+
async fn playbook_index(State(state): State) -> Response {
playbook_file(&state.playbook_dir, "index.html").await
}
@@ -586,6 +631,46 @@ async fn render(
}
}
+/// `POST /render/requirements-view?output=svg|png` — the semantic graph
+/// boundary for ReqIF/Flexo requirements. It first induces a typed view and
+/// only then lowers that view to D2 for the existing cache-aware renderer.
+/// A behaviour view must carry the user's explicit confirmation before it can
+/// become an artifact; recommendations alone are intentionally non-rendering.
+async fn render_requirements_view(
+ State(state): State,
+ axum::extract::Query(params): axum::extract::Query>,
+ Json(input): Json,
+) -> Response {
+ if input.request.kind == kr0ki_core::requirements::ViewKind::Behaviour
+ && input.request.confirmed_behaviour.is_none()
+ {
+ return error_json(
+ StatusCode::CONFLICT,
+ "behaviour_confirmation_required",
+ "confirm the recommended behaviour view before rendering it",
+ );
+ }
+ let view = match input.graph.view(&input.request) {
+ Ok(view) => view,
+ Err(error) => {
+ return error_json(
+ StatusCode::UNPROCESSABLE_ENTITY,
+ "invalid_requirement_view",
+ &error.to_string(),
+ )
+ }
+ };
+ let output = params
+ .get("output")
+ .and_then(|v| OutputKind::from_param(v))
+ .unwrap_or(OutputKind::Svg);
+ let d2 = kr0ki_core::requirements_render::to_d2(&view);
+ match state.service.render(DiagramFormat::D2, output, &d2).await {
+ Ok(rendered) => rendered_response(output, rendered),
+ Err(error) => service_error_response(error),
+ }
+}
+
/// `GET /b00t-graph/{tag}?output=svg|png` — kr0ki#13. Reads a b00t-graph
/// artifact's Turtle dump (`{base}/tags/{tag}/kerml-view.ttl`) from a
/// CSI-mounted local path — no S3 SDK / HTTP fetch, and no signature
diff --git a/crates/kr0ki-server/tests/http.rs b/crates/kr0ki-server/tests/http.rs
index 2a6bfb4..a604f22 100644
--- a/crates/kr0ki-server/tests/http.rs
+++ b/crates/kr0ki-server/tests/http.rs
@@ -143,6 +143,60 @@ async fn mcp_tools_lists_all_twelve_tools_with_bindings() {
.contains(&serde_json::json!("format")));
}
+fn requirement_view_payload(kind: &str, confirmed_behaviour: Option<&str>) -> String {
+ serde_json::json!({
+ "graph": {
+ "baseline": {"id": "BL-1", "revision": "commit-1"},
+ "requirements": [
+ {"id": "r1", "title": "Top", "text": "", "baseline": {"id": "BL-1", "revision": "commit-1"}, "provenance": {"source_uri": "reqif://fixture"}, "attributes": {}},
+ {"id": "r2", "title": "Child", "text": "", "baseline": {"id": "BL-1", "revision": "commit-1"}, "provenance": {"source_uri": "reqif://fixture"}, "attributes": {}}
+ ],
+ "evidence": [],
+ "relations": [{"id": "contains", "source": "r1", "target": "r2", "kind": "contains", "authority": {"status": "asserted"}, "provenance": {"source_uri": "reqif://fixture"}}]
+ },
+ "request": {"kind": kind, "scope": "authoritative", "direction": "downstream", "confirmed_behaviour": confirmed_behaviour}
+ }).to_string()
+}
+
+#[tokio::test]
+async fn requirements_view_returns_typed_induced_graph_not_renderer_source() {
+ let payload = requirement_view_payload("decomposition", None);
+ let response = test_app(test_state("requirements-view"))
+ .oneshot(
+ Request::post("/requirements/views")
+ .header("content-type", "application/json")
+ .body(Body::from(payload))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let (status, body) = body_string(response).await;
+ assert_eq!(status, StatusCode::OK);
+ assert!(body.contains("\"relations\":[{"));
+ assert!(body.contains("\"kind\":\"contains\""));
+ assert!(
+ !body.contains(" -> "),
+ "view response must not contain D2 source"
+ );
+}
+
+#[tokio::test]
+async fn behaviour_render_requires_human_confirmation() {
+ let payload = requirement_view_payload("behaviour", None);
+ let response = test_app(test_state("requirements-behaviour-confirmation"))
+ .oneshot(
+ Request::post("/render/requirements-view")
+ .header("content-type", "application/json")
+ .body(Body::from(payload))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let (status, body) = body_string(response).await;
+ assert_eq!(status, StatusCode::CONFLICT);
+ assert!(body.contains("behaviour_confirmation_required"));
+}
+
#[tokio::test]
async fn model_routes_return_503_when_no_client_is_configured() {
let app = test_app(test_state("model-unconfigured"));
@@ -338,14 +392,17 @@ async fn examples_catalog_covers_every_advertised_format() {
let (status, body) = body_string(resp).await;
assert_eq!(status, StatusCode::OK);
let examples: Vec = serde_json::from_str(&body).unwrap();
- // Format-routed examples (route: null) are 1:1 with DiagramFormat; a
- // custom-route example (e.g. k8s-topology) adds to the catalog without
- // being one of them -- see kr0ki_core::examples::PlaybookExample::route.
- let format_routed = examples
+ // Every standalone renderer needs a fixture. Some renderers deliberately
+ // have several fixtures because a catalog type must show its own syntax.
+ let format_routed: std::collections::BTreeSet<_> = examples
.iter()
.filter(|example| example["route"].is_null())
- .count();
- assert_eq!(format_routed, kr0ki_core::format::DiagramFormat::ALL.len());
+ .map(|example| example["format"].as_str().unwrap())
+ .collect();
+ assert_eq!(
+ format_routed.len(),
+ kr0ki_core::format::DiagramFormat::ALL.len()
+ );
assert!(examples.iter().all(|example| example["source"].is_string()));
assert!(examples.iter().any(|example| example["format"] == "d2"));
assert!(examples
@@ -353,6 +410,46 @@ async fn examples_catalog_covers_every_advertised_format() {
.any(|example| example["route"] == "/render/k8s-topology"));
}
+#[tokio::test]
+async fn catalog_serves_taxonomy_for_gallery_and_discovery() {
+ let app = test_app(test_state("examples"));
+ let resp = app
+ .oneshot(Request::get("/api/catalog").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ let (status, body) = body_string(resp).await;
+ assert_eq!(status, StatusCode::OK);
+ let catalog: serde_json::Value = serde_json::from_str(&body).unwrap();
+ let types = catalog["types"].as_array().unwrap();
+ assert!(
+ types.len() >= 20,
+ "taxonomy must expand PlantUML top-level types"
+ );
+ // Distinct addressible names.
+ let mut ids: Vec<&str> = types.iter().filter_map(|t| t["id"].as_str()).collect();
+ ids.sort_unstable();
+ let len = ids.len();
+ ids.dedup();
+ assert_eq!(ids.len(), len);
+ // Filter vocabulary is non-empty and every type's tags come from it.
+ let use_cases = catalog["useCases"].as_array().unwrap();
+ assert!(use_cases.iter().any(|t| t == "process flow"));
+ for t in types {
+ for tag in t["useCases"].as_array().unwrap() {
+ assert!(use_cases.contains(tag), "tag {tag} outside vocabulary");
+ }
+ assert!(t["samplePrompt"].as_str().unwrap().len() > 20);
+ let example_id = t["exampleId"].as_str().unwrap();
+ assert!(kr0ki_core::examples::ALL
+ .iter()
+ .any(|example| example.id == example_id));
+ }
+ assert!(catalog["discoveryGuide"]
+ .as_str()
+ .unwrap()
+ .contains("sequence"));
+}
+
#[tokio::test]
async fn playbook_serves_built_vue_assets() {
let directory = std::env::temp_dir().join(format!("kr0ki-playbook-{}", std::process::id()));
diff --git a/docs/HANDOFF-2026-09-19-reqif-flexo.md b/docs/HANDOFF-2026-09-19-reqif-flexo.md
new file mode 100644
index 0000000..e2e357e
--- /dev/null
+++ b/docs/HANDOFF-2026-09-19-reqif-flexo.md
@@ -0,0 +1,156 @@
+# HANDOFF — ReqIF / Flexo requirements viewpoints
+
+**Date:** 2026-09-19
+**Audience:** lead developer and project manager
+**Implementation branch:** `feature/flexo-reqif-implementation`
+**Checkpoint branch:** `feature/flexo-reqif-requirements` at `c9be32a`
+
+## Executive state
+
+The first runnable, stateless requirements-view slice is implemented in kr0ki.
+It accepts a normalized requirements graph, produces one of five typed viewpoints,
+and can render that induced graph through the existing D2/Kroki cache. It does **not**
+parse ReqIF, persist a Flexo baseline, or add a requirements database. Those remain
+adapter/upstream work by design.
+
+The branch stack is intentional:
+
+| Branch | Commit | Purpose |
+|---|---:|---|
+| `feature/flexo-reqif-requirements` | `c9be32a` | Full checkpoint of the pre-existing local Playb00k, story-agent, catalog, and initial requirements changes. |
+| `feature/flexo-reqif-implementation` | `f9039be` | Follow-on requirements hardening; branch from here for further work. |
+
+Both commits are local. No PR, deployment, Flexo instance, StrictDoc sidecar, or
+external-repository change has been created from this work.
+
+## What is working
+
+### Normalized requirements model
+
+`crates/kr0ki-core/src/requirements.rs` contains the backend-neutral model:
+
+- immutable `BaselineIdentity`, source `Provenance`, `Requirement`, and `EvidenceRef`;
+- the asserted vocabulary: `contains`, `derives`, `refines`, `requires`, `satisfies`,
+ `verifies`, `implements`, `traces`, `allocated_to`, and `precedes`;
+- asserted, inferred, and proposed relation authority. Non-authoritative edges carry
+ confidence, rationale, evidence, and model identity; `promote_relation()` is the
+ explicit mutation that makes an edge authoritative;
+- graph validation for duplicate identities, unknown endpoints, invalid confidence,
+ and requirements whose baseline differs from the graph baseline.
+
+### Five viewpoints
+
+`RequirementGraph::view(&ViewRequest)` returns a typed induced graph, never renderer
+source. Available `ViewKind` values are:
+
+1. `decomposition` — contains/derives/refines.
+2. `traceability` — satisfies/verifies/implements/traces/allocated-to.
+3. `impact` — bounded upstream/downstream/both traversal; reports real cycle edges.
+4. `behaviour` — deterministic activity/sequence/state recommendation. Rendering needs
+ an explicit `confirmed_behaviour`; a human may choose a different view deliberately.
+5. `verification_coverage` — coverage matrix plus unverified requirements and orphan
+ evidence.
+
+Authoritative viewpoints exclude inferred/proposed edges until promotion. `review`
+scope includes them so a client can present the distinction without treating an
+inference as fact. Output is sorted by id before rendering to make equivalent inputs
+stable.
+
+### HTTP and rendering boundary
+
+| Endpoint | Contract |
+|---|---|
+| `POST /requirements/views` | JSON `{ graph, request }` → typed `ViewResult` JSON. No D2 or Kroki call. |
+| `POST /render/requirements-view?output=svg\|png` | Same JSON → induced graph → D2 → existing cache-aware renderer. A behaviour request without confirmation returns `409 behaviour_confirmation_required`. |
+
+`crates/kr0ki-core/src/requirements_render.rs` is the only graph-to-D2 adapter. This
+keeps ReqIF/Flexo mechanics outside `kr0ki-core` rendering semantics.
+
+## Verification performed
+
+```text
+cargo test -p kr0ki-core requirements # 5 passed
+cargo test -p kr0ki-server --test http requirements_view_returns_typed_induced_graph_not_renderer_source # passed
+cargo clippy -p kr0ki-core -p kr0ki-server -- -D warnings # clean
+```
+
+Tests cover promotion isolation, bounded impact/cycles, behaviour recommendation plus
+human selection, coverage gaps, deterministic D2 emission, typed HTTP response, and
+the confirmation gate. They do not exercise a live Kroki backend, Flexo, ReqIF, or
+StrictDoc.
+
+## Deliberate boundaries — do not erode these
+
+- **kr0ki is not the requirements database.** Flexo MMS is the durable RDF/versioned
+ model store. kr0ki accepts materialized graphs and makes ephemeral views/artifacts.
+- **No handwritten ReqIF XML parser.** The planned adapter must use the Apache-2.0
+ StrictDoc `reqif` package for ReqIF/ReqIFz parsing, unparsing, basic validation, and
+ optional OMG schema validation. It is a Python library, so use a small sidecar/HTTP
+ or MCP boundary; Rust owns the normalized model and viewpoints.
+- **`ufo-types` owns shared semantics.** The current module is a kr0ki integration
+ seam, not permission to fork the canonical semantic model. Move it upstream as
+ `ufo_types::mbse::requirements` before another consumer depends on this local path,
+ then re-export/consume the upstream release here.
+- **`reqif-opa-mcp` owns its existing artifact → policy → ReqIF workflow.** Refactor it
+ by consuming the shared types/contracts; do not duplicate its OPA policy engine in
+ kr0ki.
+- **Use `View`/`Viewpoint`, not “projection”, in new user-facing documentation.**
+ `docs/VOCABULARY.md` remains authoritative.
+
+## Lead developer next actions
+
+1. **Upstream semantic model (first).** Open a focused `ufo-types` change adding
+ `mbse::requirements`; move the structures and tests from `kr0ki-core` with no
+ renderer dependency. Cut/tag a release, update kr0ki, and delete the local mirror.
+2. **ReqIF adapter contract.** In/alongside `reqif-opa-mcp`, expose import, validation,
+ progress, normalized-graph, and deterministic-export contracts over HTTP or MCP.
+ Use StrictDoc parser/unparser; support ReqIFz attachments and invoke schema
+ validation. Preserve original artifact digest and export digest in `BaselineIdentity`.
+3. **Flexo adapter.** Add a thin, upstream-first Flexo extension/service that stores
+ validated input plus deterministic exported baseline per Flexo commit and
+ materializes the normalized graph. Keep commit identity as a required request field.
+4. **Bind Playb00k only after the adapter exists.** The Vue workspace should call the
+ stable contracts: import/validate, baseline list/select, graph/view request, then
+ cached-artifact URL. Do not make browser state authoritative.
+5. **MCP parity.** Add requirements view/render tools to the manifest-driven MCP bridge
+ only after their request schema is accepted; update the existing test that currently
+ asserts twelve tools.
+
+## Project-manager decisions and delivery gates
+
+| Decision / gate | Owner | Needed before | Status |
+|---|---|---|---|
+| Confirm `ufo-types` accepts `mbse::requirements` and name/version strategy | PM + ufo-types maintainer | shared-model work | open |
+| Choose sidecar deployment and trust boundary for Python StrictDoc | PM + platform | ReqIF import MVP | open |
+| Confirm Flexo extension API/storage path and upstream contribution owner | PM + Flexo maintainer | durable baselines | open |
+| Define ReqIF fixture corpus, max file size, attachment retention, and progress UX | PM + QA | import acceptance | open |
+| Define who may promote inferred/proposed relations and required audit identity | PM + governance | write/promotion UX | open |
+| Approve API contracts before Vue/Flexo Web Modeler work | PM + lead | client implementation | open |
+
+Suggested milestones:
+
+1. **M1 — shared types:** upstream release + kr0ki consumes it; contract tests green.
+2. **M2 — validated interchange:** ReqIF/ReqIFz import/export fixture suite and
+ deterministic baseline digest.
+3. **M3 — Flexo baseline:** commit ↔ graph ↔ exported ReqIF equivalence test.
+4. **M4 — client:** Playb00k import/browse/five-view flow and cached artifact opening.
+5. **M5 — interoperability:** Flexo Web Modeler client uses the same requests; only
+ then assess the optional GraalVM LLVM/Rust spike.
+
+## Current risk register
+
+| Risk | Mitigation |
+|---|---|
+| Shared types fork between kr0ki and `ufo-types` | Treat the local module as temporary; upstream before adapter work expands. |
+| ReqIF parser is Python while core is Rust | Isolate it behind HTTP/MCP; do not introduce in-process runtime coupling. |
+| Flexo API/storage gap | Upstream-first spike with an explicit failing contract; fork only after a demonstrated gap. |
+| Inferred edges accidentally influence authoritative artifacts | Default `authoritative` scope and promotion test are already in place; retain this invariant in every adapter/UI. |
+| Scope expansion into a kr0ki store | Do not add persistence fields/routes here; materialization belongs in Flexo adapter. |
+
+
diff --git a/docs/PLAN-KR0KI-005-gallery-catalog-type-discovery.md b/docs/PLAN-KR0KI-005-gallery-catalog-type-discovery.md
new file mode 100644
index 0000000..f1d4faa
--- /dev/null
+++ b/docs/PLAN-KR0KI-005-gallery-catalog-type-discovery.md
@@ -0,0 +1,185 @@
+# PLAN-KR0KI-005 — Gallery catalog & type-discovery agent
+
+Status: proposed (2026-09-19)
+Depends on: PR #35 (revision graphs, projects, planning agent), Plan 004 Phase 0
+Supersedes: the "catalog/discovery" line item in the Plan 004 roadmap
+
+## 0. Outcome
+
+A user who has something to convey — but does not know (or care) which diagram
+syntax fits — can discover the right type two ways, and both paths converge:
+
+1. **Gallery**: browse by *intent* ("process flow", "who talks to whom"),
+ not by syntax. Every diagram type has a distinct, addressible name.
+2. **Agent**: say what they want to convey in plain language; the agent runs a
+ **type-discovery question series** (different from requirement refinement)
+ and recommends a type with a rendered sample before locking anything in.
+
+Core ergonomics assumption (user directive 2026-09-19): **users do not know
+the graph type they need.** They know the idea they want to convey. Both the
+gallery's information architecture and the agent's question series must work
+from intent → type, never assuming type → intent.
+
+## 1. Gallery: intent-first catalog
+
+### 1.1 Catalog data (single source of truth)
+
+New Rust module `crates/kr0ki-core/src/catalog.rs` (exported to the playbook
+via `/api/catalog`; hardcoded taxonomy lives in Rust per the architectural
+rule, never in JS):
+
+```rust
+pub struct DiagramType {
+ pub id: &'static str, // addressible name, e.g. "sequence"
+ pub syntax: &'static str, // kr0ki format: "plantuml", "d2", "graphviz"
+ pub name: &'static str, // "Sequence diagram"
+ pub use_cases: &'static [&'static str], // intent tags, e.g. ["process flow", "interaction"]
+ pub blurb: &'static str, // one sentence: when to choose this
+ pub example_id: Option<&'static str>, // fixture to render in the card
+}
+```
+
+Initial taxonomy (top-level types; PlantUML expanded beyond today's single
+example):
+
+- **Structure**: component, class, object, deployment, ERD (`plantuml`,
+ `erd`, `dbml`), package, state machine
+- **Interaction**: sequence (`plantuml` sequence), communication, timing,
+ network (`nwdiag`), rack (`rackdiag`), packet (`packetdiag`)
+- **Flow**: activity/process flow (`plantuml` activity), flowchart (`d2`,
+ `graphviz`), use case, state transition
+- **Data**: ERD, JSON/YAML, Vega-Lite charts (`vegalite`, `vega`)
+- **Layout/sketch**: block diagram (`blockdiag`, `ditaa`, `svgbob`, `goat`),
+ bytefield, wireviz (wiring), pikchr, nomnoml, umlet, structurizr (C4),
+ c4plantuml, tikz, wavedrom, symbolator, k8s-topology, kubediagram
+
+Each `use_case` tag is drawn from a small controlled vocabulary (also in
+Rust): `process flow`, `interaction`, `data model`, `network`, `timeline`,
+`sketch`, `chart`, `architecture`, `state machine`, `hardware`.
+
+### 1.2 UX
+
+- **Filter bar** above the grid: `All | process flow | interaction | data
+ model | network | …` — **defaults to "All"** and auto-selects "All" whenever
+ a search/filter is cleared. Multi-tag OR matching.
+- Each card: rendered thumbnail (the fixture, cached), the type name, its
+ syntax badge, blurb, and **two buttons**: `Test` (existing behavior) and
+ `Agent` (new).
+- Card is deep-linkable: `/playbook/?type=sequence` (distinct addressible
+ name in the URL fragment/query).
+
+### 1.3 The `Agent` button (gallery → agent handoff)
+
+Adjacent to `Test`. Clicking it:
+
+1. Switches to Agent view.
+2. Pre-populates the composer with a sample prompt that **explicitly names the
+ type**, e.g. for `sequence`:
+ > "Draw a **sequence diagram** for: . Start by asking me
+ > about the participants and the flow."
+3. Keeps focus in the composer — the user edits before sending. Nothing is
+ auto-sent.
+4. Carries `syntax` + `typeId` as `forwardedProps` on the run so the agent
+ knows the gallery context without the user restating it.
+
+## 2. Agent: type-discovery question series
+
+### 2.1 Two distinct question modes
+
+Today's `ask_user` serves requirement refinement. Add a parallel mode:
+
+| Mode | When | Question series |
+|---|---|---|
+| `refine` (existing) | type already known/locked | participants, scope, level of detail |
+| `discover` (new) | **type unknown** — the default when the user hasn't named a syntax | audience → intent → shape → fidelity |
+
+The system prompt decides the mode: if the user has not named a syntax AND
+PROJECT MEMORY holds no locked type, `discover` mode is mandatory before any
+render.
+
+### 2.2 The discover series (new `recommend_diagram_type` tool)
+
+New agent tool `recommend_diagram_type` — like `ask_user` but structured for
+type discovery; server-side it can ALSO drive the gallery (see 2.4). Question
+series the prompt enforces, one `ask_user` round each, max 3 before the agent
+must commit to a recommendation:
+
+1. **Audience/purpose**: "Who is this for and what should they walk away
+ with?" (options: developers / managers / mixed audience; document / live
+ discussion / onboarding)
+2. **Intent shape**: "Which is closest to what you want to convey?" (options
+ drawn from the controlled vocabulary: *how a process flows*, *who talks to
+ whom and in what order*, *how things are structured/related*, *how data is
+ organized*, *what the network looks like*, *how it changes state over time*)
+3. **Fidelity** (only if still ambiguous): "Sketch-quality or
+ production/documentation-quality?"
+
+Then the agent **recommends** 1 primary + 1 alternative type with a one-line
+rationale each ("A sequence diagram shows the request→approval→provisioning
+handoffs in time order; an activity diagram would suit if you care more about
+the decision branches than the participants"), and asks a single confirm
+question ("Go with sequence?" [sequence / activity / show me both]).
+
+"Show me both" renders BOTH as sample panels — visual comparison beats prose.
+
+### 2.3 Type lock-in and memory
+
+- The chosen type is recorded in the project: `typeId` + `syntax` join
+ `requirements` in the lock-in (server-side capture, same `Locked in:`
+ marker, extended to parse `Type: `).
+- PROJECT MEMORY gains: `Type chosen: sequence (plantuml) — do not switch
+ without asking.`
+- Gallery handoff (`forwardedProps`) skips discovery round 2 (the shape is
+ already implied) but still confirms.
+
+### 2.4 Server support
+
+- `recommend_diagram_type` tool schema served from the manifest-adjacent tool
+ list; arguments carry `mode: "discover"|"refine"`, `question`, `options`,
+ `typeOptions` (id+name+blurb triples when options are diagram types).
+- Interrupts extend `responseSchema.properties.options.default` as today —
+ type options ride the same channel the UI already renders.
+- `/api/catalog` (GET, kr0ki server): the taxonomy above; the agent's skills
+ file is generated from it (single source of truth, no drift).
+
+## 3. Per-syntax agent skills (foundation, not full rollout)
+
+Directory layout lands now; content per type lands incrementally:
+
+```
+containers/kr0ki-storyb00k-agent/skills/types/.md
+```
+
+- Loaded into the system prompt ONLY when the type is locked (keeps context
+ small vs. today's all-skills dump).
+- Each file: what the type is good/bad at, syntax gotchas, a minimal correct
+ sample, and the render route/format pair.
+- Ship with `sequence.md`, `flow.md`, `erd.md`, `component.md`; the catalog
+ generator flags types missing a skill file (checklist in `just check`-adjacent
+ gate, warning only initially).
+
+## 4. Delivery plan
+
+1. **Catalog core** — `catalog.rs` taxonomy + `/api/catalog` + fixture
+ linkage; Rust unit tests for the controlled vocabulary (every example's
+ format resolves; every typeId unique).
+2. **Gallery UX** — filter bar (default All, auto-reset), card grid from
+ `/api/catalog`, deep links, `Test` + `Agent` buttons; `Agent` prefill via
+ forwardedProps. Vitest: filter selection, prefill content, deep-link parse.
+3. **Discover mode** — `recommend_diagram_type` tool + prompt-mode rules +
+ type lock-in parsing. Python E2E: discover series → recommend → confirm →
+ type captured in project; re-run does not re-discover.
+4. **Skills split** — skills/types/.md loading by locked type; four seed
+ files; missing-skill warning.
+5. **Live acceptance** — the user test: "I want to show how our release
+ process works" with NO syntax named → agent discovers (≤3 questions) →
+ recommends → confirm → renders; and from the gallery: click Agent on a
+ card → composer pre-filled → send → renders that type.
+
+## 5. Non-goals
+
+- No new render backends (Kroki serves all syntaxes already).
+- No user-defined taxonomies (Rust-owned vocabulary, PR-reviewed).
+- No auto-sent agent runs from gallery clicks (prefill only — the user is
+ always the one who sends).
+- Full per-type skill content is incremental; only 4 seed files in this plan.
diff --git a/docs/TODO.md b/docs/TODO.md
index 3c259bd..13aa444 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -6,9 +6,44 @@ in [`PLAN-KR0KI-002.md`](PLAN-KR0KI-002.md); the typed-layer shape in
[`DESIGN-NOTE-typed-model-layer.md`](DESIGN-NOTE-typed-model-layer.md). This file just
tracks *what is left to do*, ordered by the five-box pipeline.
-_Last updated: 2026-09-15; corrected 2026-09-17 — boxes 2/3 and the ledgrrr
+_Last updated: 2026-09-19; corrected 2026-09-17 — boxes 2/3 and the ledgrrr
cross-cutting item were already shipped (kr0ki#12/#13) but left unchecked._
+## ReqIF / Flexo requirements viewpoints — new tracked stream
+
+Read [`HANDOFF-2026-09-19-reqif-flexo.md`](HANDOFF-2026-09-19-reqif-flexo.md) before
+starting this stream. kr0ki's stateless view slice is on
+`feature/flexo-reqif-implementation` (`f9039be`); it is not a requirements store.
+
+- [x] **Normalized requirements graph + five viewpoints** — integration seam in
+ `kr0ki-core::{requirements,requirements_render}` (now re-exporting the upstream
+ model, see next item) and HTTP view/render routes. Preserves baseline/provenance/
+ evidence and gates inferred/proposed edges behind explicit promotion.
+- [x] **Upstream `ufo-types::mbse::requirements`** — moved/released as `ufo-types`
+ v0.15.0 (`PromptExecution/ufo-types#28`). `kr0ki-core` re-exports
+ `ufo_types::mbse::requirements` as `kr0ki_core::requirements` rather than owning a
+ copy — zero drift, zero duplication. View results stay typed; no renderer source
+ in the semantic contract (rendering stays in `kr0ki-core::requirements_render`).
+- [ ] **StrictDoc ReqIF / ReqIFz adapter** — use the Apache-2.0 Python `reqif` package
+ (parser, unparser, validation, schema validation, progress callbacks) through a
+ sidecar HTTP/MCP boundary. Do not write XML parsing in Rust. Cover malformed input,
+ attachments, large-file progress, and round trips against upstream fixtures.
+- [ ] **`reqif-opa-mcp` refactor** — retain its artifact → document graph → candidate
+ → OPA → ReqIF pipeline; replace its private requirement DTO/relation/provenance and
+ validation types with the upstream `ufo-types` contract.
+- [ ] **Flexo baseline adapter** — import validated ReqIF into versioned RDF, retain
+ original artifact and deterministic export per Flexo commit, then materialize the
+ same normalized graph. Prove commit ↔ graph ↔ export equivalence in contract tests.
+- [ ] **Playb00k requirements workspace** — after M2/M3 contracts exist: import and
+ validate, select baseline, browse hierarchy/provenance, run all five viewpoints,
+ distinguish asserted/inferred/proposed edges, explicitly promote, and open cached
+ artifacts. Keep Vue independent of the API schema so Flexo Web Modeler can reuse it.
+- [ ] **MCP requirements tools** — add accepted view/render contracts to
+ `McpTool::ALL`, bridge manifest tests, and API docs; do not hard-code a second
+ dispatcher.
+- [ ] **GraalVM LLVM/Rust spike** — only after HTTP/MCP integration works. Record
+ target/library constraints and keep it optional for first release.
+
---
## Done
diff --git a/playbook/src/App.vue b/playbook/src/App.vue
index c503ba2..fe5f6d0 100644
--- a/playbook/src/App.vue
+++ b/playbook/src/App.vue
@@ -14,6 +14,18 @@ const editedSource = ref('')
const editedRoute = ref(null)
let editedSourcePending = false
+// Gallery → Agent handoff (Plan 005 §1.3): prefill the Agent composer with a
+// prompt naming the diagram type, then flip to the Agent view. Never auto-sent.
+const agentUrl = `${window.location.protocol}//${window.location.hostname}:8789`
+const agentPrefill = ref('')
+const agentTypeId = ref('')
+
+function agentHandoff({ prompt, typeId }) {
+ agentPrefill.value = prompt
+ agentTypeId.value = typeId
+ viewMode.value = 'storyb00k'
+}
+
// `api/examples.json` is deliberately relative: it resolves beneath
// /playbook/ in the live service and beneath /kr0ki/playbook/ on Pages.
const catalogUrl = new URL('api/examples.json', window.location.href)
@@ -83,7 +95,7 @@ onMounted(async () => {
+
+
+
+ Nothing matches that filter yet.
+
+
+
+
+
+
+ {{ card.useCases.join(' · ') }}
+
+
![]()
+
Hit Test below to render a sample
+
+
+ {{ typeErrors[card.id] }}
+
+
+
diff --git a/playbook/src/components/StoryB00k.vue b/playbook/src/components/StoryB00k.vue
index 5a7821c..21307ce 100644
--- a/playbook/src/components/StoryB00k.vue
+++ b/playbook/src/components/StoryB00k.vue
@@ -28,12 +28,28 @@ if (!globalThis.crypto?.randomUUID) {
})
console.warn('[storyb00k] crypto.randomUUID unavailable (insecure context) — installed prototype fallback')
}
-const input = ref('')
+const props = defineProps({
+ // Gallery → Agent handoff: a sample prompt (names the diagram type) the
+ // composer starts with. The user edits/sends it — never auto-sent.
+ prefill: { type: String, default: '' },
+ lockedType: { type: String, default: '' },
+})
+
+const input = ref(props.prefill)
+const comparisonPanels = ref([])
+// Fresh handoffs replace a still-untouched composer; a half-typed draft wins.
+watch(() => props.prefill, (next) => {
+ if (next && (!input.value.trim() || input.value === props.prefill)) input.value = next
+})
const autoScroll = ref(true)
const transcriptEl = ref(null)
const chat = useChat({
url: `${agentUrl}/run`,
initialState: { panels: [], drafts: [] },
+ // Our submitAnswer() drives continuation explicitly via chat.send(answer);
+ // the client's auto-resume fires resume() the moment all interrupts have
+ // responses, colliding with that send ("A run is already in progress").
+ autoResumeInterrupts: false,
// Pipe every AG-UI event and lifecycle transition to the browser console —
// this is the debugger for the "UI feels disconnected" class of problem.
debug: { events: true, lifecycle: true },
@@ -50,7 +66,7 @@ const error = chat.error
const toolCallTrackers = chat.toolCallTrackers
const usage = chat.usage
const threadId = chat.threadId
-const panels = computed(() => chat.state.value?.panels || [])
+const panels = computed(() => [...(chat.state.value?.panels || []), ...comparisonPanels.value])
const drafts = computed(() => chat.state.value?.drafts || [])
const busy = computed(() => status.value === 'submitted' || status.value === 'streaming')
const toolActivity = computed(() => Array.from(toolCallTrackers.value?.values() || []).map(t => ({
@@ -60,6 +76,74 @@ const toolActivity = computed(() => Array.from(toolCallTrackers.value?.values()
failed: t.state === 'output-error' || t.state === 'output-denied',
output: t.output,
})))
+// ---- Project: the conceptual unit of work within a session ----
+// The agent tracks goal/Q&A/prompts server-side (project_store.py); the UI
+// mirrors it so the user can see (and edit) what was asked and answered.
+const project = ref(null)
+const projectError = ref('')
+const editingTitle = ref(false)
+const editedTitle = ref('')
+const showPromptLog = ref(false)
+const promptLog = computed(() => project.value?.prompts || [])
+const thinkingLog = computed(() => project.value?.thinking || [])
+
+async function loadProject() {
+ try {
+ const res = await fetch(`${agentUrl}/projects/${threadId.value}`)
+ if (res.status === 404) { project.value = null; return }
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ project.value = await res.json()
+ } catch (err) {
+ projectError.value = err.message
+ }
+}
+
+watch(threadId, () => { project.value = null; loadProject() })
+loadProject()
+
+async function renameProject() {
+ const title = editedTitle.value.trim()
+ editingTitle.value = false
+ if (!title) return
+ try {
+ const res = await fetch(`${agentUrl}/projects/rename`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: threadId.value, title }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (project.value) project.value.title = title
+ } catch (err) {
+ projectError.value = `rename failed: ${err.message}`
+ }
+}
+
+// Refresh the project mirror after each run completes (new prompts/Q&A/thinking).
+watch(status, (next, prev) => {
+ if (prev !== 'ready' && next === 'ready') loadProject()
+})
+
+// ---- Fast-track (Plan 005 UX): after 2 answered questions the user can
+// skip further questions and authorize best-judgement rendering immediately.
+const canFastTrack = computed(() => (project.value?.qa?.length || 0) >= 2 && !project.value?.fastTrack)
+const fastTrackArmed = computed(() => !!project.value?.fastTrack)
+
+async function diagramNow() {
+ if (busy.value) return
+ try {
+ const res = await fetch(`${agentUrl}/projects/fasttrack`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: threadId.value, enabled: true }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ await loadProject()
+ await chat.send('Diagram now — use your best judgement from what you know so far.')
+ loadProject()
+ } catch (err) {
+ console.error('[storyb00k] diagram-now failed:', err?.message ?? err)
+ projectError.value = `diagram-now failed: ${err.message}`
+ }
+}
+
const statusLabel = computed(() => ({
ready: 'Ready',
submitted: 'Thinking…',
@@ -170,6 +254,13 @@ async function sendMessage() {
input.value = ''
console.info('[storyb00k] send →', text, '| thread:', threadId.value, '| agent:', agentUrl)
try {
+ if (props.lockedType) {
+ const lock = await fetch(`${agentUrl}/projects/lock-type`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: threadId.value, typeId: props.lockedType }),
+ })
+ if (!lock.ok) throw new Error(`type lock failed: HTTP ${lock.status}`)
+ }
const result = await chat.send(text)
console.info('[storyb00k] run finished | new messages:', result?.newMessages?.length ?? 0,
'| usage:', usage.value.at(-1) ?? 'none reported')
@@ -187,6 +278,67 @@ async function answerInterrupt(interrupt, approved) {
chat.respondToInterrupt(interrupt.id, { approved })
}
+// Planning questions (ask_user interrupts): pick an option or type free text,
+// then continue the run with the answer appended as a user message.
+const questionChoice = ref('')
+const questionFreeText = ref('')
+
+function isQuestionInterrupt(interrupt) {
+ // ask-* = discovery/refinement questions; rec-* = type recommendations.
+ // Both render the multiple-choice answer UI (Approve/Decline is for
+ // draft proposals only — posting `approved` on a question yields 400).
+ const id = interrupt.id
+ return typeof id === 'string' && (id.startsWith('ask-') || id.startsWith('rec-'))
+}
+
+// zod strips unknown keys from interrupt objects, so the options ride in the
+// responseSchema's `options.default` (defaults survive parsing).
+function optionsFor(interrupt) {
+ return interrupt.responseSchema?.properties?.options?.default || []
+}
+
+function startAnswer(interrupt) {
+ // Kept for symmetry/test hooks; the answer UI renders inline now.
+ questionChoice.value = ''
+ questionFreeText.value = ''
+ void interrupt
+}
+
+async function submitAnswer(interruptId) {
+ const interrupt = interrupts.value.find((i) => i.id === interruptId)
+ if (!interrupt) return
+ const answer = questionFreeText.value.trim() || questionChoice.value
+ if (!answer) return
+ try {
+ const res = await fetch(`${agentUrl}/respond-to-interrupt`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: threadId.value, interruptId: interrupt.id, answer }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const response = await res.json()
+ if (response.comparisonPanels?.length) {
+ comparisonPanels.value = [...comparisonPanels.value, ...response.comparisonPanels]
+ questionChoice.value = ''
+ questionFreeText.value = ''
+ chat.respondToInterrupt(interrupt.id, { answer })
+ await loadProject()
+ return
+ }
+ questionChoice.value = ''
+ questionFreeText.value = ''
+ // Record the response with the client, then RESUME the interrupted run —
+ // chat.send() would start a new run, which the agent rejects while
+ // interrupts are pending ("Thread has N pending interrupt(s) not
+ // addressed by resume"). resume() carries the recorded response payload.
+ chat.respondToInterrupt(interrupt.id, { answer })
+ await chat.resume()
+ loadProject()
+ } catch (err) {
+ console.error('[storyb00k] answer failed:', err?.message ?? err)
+ projectError.value = `answer failed: ${err.message}`
+ }
+}
+
function decideDraft(draft, approved) {
// Mirror the interrupt flow for drafts surfaced in state (e.g. after reconnect).
return fetch(`${agentUrl}/respond-to-interrupt`, {
@@ -238,6 +390,45 @@ function formatTokens(u) {
{{ statusLabel }}
{{ formatTokens(usage[usage.length - 1]) }}
+
+
+
+
+
+
+
+
+ {{ project.title }}
+
+
+ 🔒 locked
+
+
{{ project.goal }}
+
Locked in: {{ project.requirements }}
+
+ {{ project.qa.length }} refinement answer{{ project.qa.length === 1 ? '' : 's' }}
+
+
+ Enough questions?
+
+
+
⚡ Fast-tracked — best judgement only
+
+
+
Nothing logged yet.
+
+
{{ entry.role }} · {{ entry.run.slice(0, 8) }}
+
{{ entry.text }}
+
+
+
thinking · {{ entry.run.slice(0, 8) }}
+
{{ entry.text }}
+
+
+
{{ projectError }}
+
Read the live model, assemble evidence panels, and narrate without changing the authoritative model.
@@ -258,9 +449,14 @@ function formatTokens(u) {
-
- {{ part.type === 'text' ? part.text : `[${part.type}]` }}
-
+
+ {{ part.text }}
+
+ 💭 thinking{{ part.streaming ? '…' : '' }}
+ {{ part.text }}
+
+ [{{ part.type }}]
+