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 () => {