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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ __pycache__/
.venv/
/.kr0ki-run/
containers/kr0ki-sysml-mcp/node_modules/
/.kr0ki-evidence/
/.kr0ki-audit/
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ kr0ki exposes a service surface; agent interaction should go through b00t conven
Long-term: a `kr0ki.mcp.toml` datum or vendored `kroki-mcp` server for MCP-native
render calls. P0 uses direct HTTP.

## 8b. Delegation: always use Qwen3.8 via pi

Errands (reviews, drafts, test scaffolding, summaries, fact checks) are **always delegated** to the local Qwen3.8
NEO-CODER through pi: `scripts/delegate-qwen38.sh` / `just delegate`. Read-only by default; `--tools` only in a throwaway
worktree. The output is a first-pass lead: triage it, verify against source, run the tests. Never delegate the
verification itself. Full pattern, exit codes and gotchas (`< /dev/null`, `127.0.0.1`): `docs/PATTERN-pi-subagents.md`.

## 8a. Agent communications and infrastructure squawks

Agent-to-agent work is migrating to A2A semantics over NATS JetStream. NATS is the
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions SKILLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Skills

A short index. Each portable skill is a directory with a `SKILL.md` (metadata + instructions)
and optional `scripts/` and `references/`, per the [Agent Skills
specification](https://agentskills.io/specification). Skill text **guides**; it never grants
permission. The tool gateway and the sandbox enforce what a caller may do.

| skill | what it is for |
|---|---|
| [`skills/requirements-authoring`](skills/requirements-authoring/SKILL.md) | Author, review and trace requirements through the assurance thread; keep satisfaction assertions apart from revision-bound verification results. |

The storyb00k agent's own prompt-sized skills (diagram types and MBSE guidance) live in
[`containers/kr0ki-storyb00k-agent/skills/`](containers/kr0ki-storyb00k-agent/skills) and are loaded
with its `load_mbse_skill` tool. They are a different, flatter format and are not Agent Skills packages.
46 changes: 46 additions & 0 deletions _b00t_/neo-coder.agent.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# b00t:map v1
# summary: NEO-CODER (local Qwen3.8 + vision, :8002) as a pi-driven errand agent; project-local override of the global datum
# tags: agent, neo-coder, qwen38, pi, kr0ki, local-override
# tier: worker
# cmds: b00t agent invoke neo-coder "task" (run from the kr0ki repo root)
# complexity: 2
#
# 🤓 Why this file exists: ~/.b00t/_b00t_/neo-coder.agent.toml points at the retired Qwen3.6 endpoint (http://sm3lly:8001)
# and has no [b00t.agent.executor], so `b00t agent invoke neo-coder` could not run. `b00t agent invoke` resolves
# `./_b00t_/<agent>.agent.toml` from the CURRENT DIRECTORY and never consults the global datum, so this project-local
# datum overrides it whenever invoke runs from this repo's root. The global one is b00t's shared file and is untouched.
# 🤓 Read-only by default (pi --no-tools): the model answers from the prompt. See docs/PATTERN-pi-subagents.md; for edits use
# scripts/delegate-qwen38.sh --tools in a throwaway worktree. The model is a first-pass worker: triage what it returns.
# 🤓 The pi provider `neo-coder` (-> http://127.0.0.1:8002/v1) lives in ~/.pi/agent/models.json. Use 127.0.0.1, not localhost.

[b00t]
name = "neo-coder"
type = "agent"
hint = "Local Qwen3.8 NEO-CODER (vision) via pi, read-only errand agent for kr0ki"

[b00t.agent]
pid = "neo-coder-kr0ki"
model = "neo-coder"
model_endpoint = "http://127.0.0.1:8002/v1"
skills = ["rust", "b00t-cli"]
personality = "precise"
humor = "none"
role = "worker"
context_budget = 32768

[b00t.agent.executor]
cli_path = "pi"
# prompt arrives on stdin from `b00t agent invoke`; --no-tools keeps it read-only; one pass, no tool loop
cli_args = ["-p", "--provider", "neo-coder", "--model", "neo-coder", "--no-session", "--no-tools"]
supports_tools = []
max_iterations = 1

[b00t.agent.ipc]
# transient, per-invocation: no socket, no pub/sub
socket = ""
pubsub = false
protocol = "stdio"

[b00t.agent.crew]
role = "specialist"
captain = false
54 changes: 53 additions & 1 deletion containers/kr0ki-storyb00k-agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
# Planner mode (gallery side panel): a thread whose id starts with this prefix is a *planning* conversation.
# The playbook uses the same id as the UI session id, so a tool call can steer exactly that browser tab.
PLANNER_THREAD_PREFIX = "planner-"
PLANNER_TOOLS = {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "ask_user"}
PLANNER_TOOLS = {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "ask_user", "load_mbse_skill"}

PLANNER_PREAMBLE = (
"You are the diagram planner inside kr0ki's gallery. The user is browsing a catalog of diagram types and "
Expand All @@ -124,6 +124,9 @@
"the user they can press Edit on a pick to start drawing, Agent to work on it with the drawing assistant, or "
"Show in gallery to see it among the others. If navigate_ui reports that no UI is connected, say so and "
"describe the pick in words.\n\n"
"If the user is really asking about requirements, traceability, SysML v2 modelling, cost attribution or "
"compliance rather than a diagram, call load_mbse_skill first and follow it; skills labelled 'recalled' are "
"guidance, not verified standards text, so say so.\n\n"
"Only use type ids and use cases returned by the tools. Keep answers short."
)

Expand Down Expand Up @@ -199,6 +202,41 @@ def list_mbse_skills():
return out


def mbse_skill(name):
"""Body of skills/mbse/<name>.md for a model: frontmatter stripped, but its confidence label kept on top so the model
cannot present recalled standards content as verified."""
text = mbse_skill_file(name)
if text is None:
return None
confidence = ""
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) == 3:
confidence = next((l[len("confidence:"):].strip() for l in parts[1].splitlines() if l.startswith("confidence:")), "")
text = parts[2]
head = f"[confidence: {confidence}]\n\n" if confidence else ""
return (head + text.strip())[:MAX_SKILL_CHARS]


load_mbse_skill_tool = {
"type": "function",
"function": {
"name": "load_mbse_skill",
"description": (
"Load a model-based systems engineering skill BEFORE advising on requirements, V-model traceability, "
"SysML v2 modelling, cost attribution codes or AI-governance compliance tags. Names: "
"v-model-traceability, requirement-writing, sysml-v2-modeling, cost-attribution-and-compliance. "
"Skills marked 'recalled' are guidance from memory, not verified standards text: say so when you rely on one."
),
"parameters": {
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string", "description": "The skill name."}},
},
},
}


class SkillRequiredError(Exception):
"""A render named a language whose syntax guide has not yet been delivered this run: deliver it first.
This is the deterministic gate: unlike a model-judged skill load, it cannot be skipped."""
Expand Down Expand Up @@ -836,6 +874,7 @@ 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())
tools.append(load_mbse_skill_tool)
project = project_store.get_project(thread_id) or {}
questions_asked = int(project.get("questionsAsked", len(project.get("qa", []))))
questions_remaining = max(0, MAX_CLARIFYING_QUESTIONS - questions_asked)
Expand Down Expand Up @@ -1202,6 +1241,19 @@ def _run(self, stream, payload, thread_id, draft, run_log):
}],
}))
return
if name == "load_mbse_skill" and name in offered_tool_names:
skill_name = str(arguments.get("name") or "")
body = mbse_skill(skill_name)
if body is None:
known = ", ".join(sorted(s["name"] for s in list_mbse_skills()))
body = f"unknown MBSE skill {skill_name!r}; available: {known}"
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"].get("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": body, "role": "tool"})
messages.append({"role": "tool", "tool_call_id": tool_call_id, "content": body})
run_log.event("skill.mbse", {"name": skill_name, "found": not body.startswith("unknown MBSE skill")})
continue
tool = find_tool(manifest, name)
# Only tools this thread was offered may run: a model can name a tool it was never given.
if tool is not None and name not in offered_tool_names:
Expand Down
58 changes: 58 additions & 0 deletions containers/kr0ki-storyb00k-agent/test_mbse_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

import server


class MbseSkillTest(unittest.TestCase):
"""Drafted by Qwen3.8 via pi (docs/PATTERN-pi-subagents.md); imports, naming and the mutation check are ours."""

def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.patcher = patch.object(server, "MBSE_SKILLS_DIR", self.tmp)
self.patcher.start()

def tearDown(self):
self.patcher.stop()
shutil.rmtree(self.tmp)

def test_confidence_included(self):
skill = self.tmp / "reqwriting.md"
skill.write_text("---\nname: reqwriting\nconfidence: recalled (2)\n---\nAsk clarifying questions.")
result = server.mbse_skill("reqwriting")
self.assertTrue(result.startswith("[confidence: recalled (2)]"))
self.assertNotIn("---", result)
self.assertNotIn("name:", result)

def test_no_frontmatter(self):
skill = self.tmp / "plain.md"
skill.write_text(" Just plain advice. ")
result = server.mbse_skill("plain")
self.assertEqual(result, "Just plain advice.")
self.assertFalse(result.startswith("[confidence:"))

def test_unknown_returns_none(self):
self.assertIsNone(server.mbse_skill("does-not-exist"))

def test_path_traversal_rejected(self):
# a real file just outside the skills directory: without name validation "../outside" would read it
outside = self.tmp.parent / "kr0ki-outside-skill.md"
outside.write_text("secret")
self.addCleanup(outside.unlink)
(self.tmp / "sub").mkdir()
(self.tmp / "sub" / "inner.md").write_text("nested")
for name in ["../kr0ki-outside-skill", "sub/inner", "..", ""]:
self.assertIsNone(server.mbse_skill(name), name)

def test_max_chars_cap(self):
skill = self.tmp / "long.md"
skill.write_text("x" * (server.MAX_SKILL_CHARS + 100))
result = server.mbse_skill("long")
self.assertEqual(len(result), server.MAX_SKILL_CHARS)


if __name__ == "__main__":
unittest.main()
30 changes: 29 additions & 1 deletion containers/kr0ki-storyb00k-agent/test_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def test_planner_runs_use_the_planner_prompt_and_tool_set(self):
self.run_thread("planner-mine", {"view": "gallery"})
messages, tools = self.client.chat_completion.call_args_list[0].args
self.assertTrue(messages[0]["content"].startswith(server.PLANNER_PREAMBLE))
self.assertEqual({t["function"]["name"] for t in tools} - {"ask_user"}, {"list_diagram_types", "suggest_diagram_type", "navigate_ui"})
self.assertEqual({t["function"]["name"] for t in tools} - {"ask_user"}, {"list_diagram_types", "suggest_diagram_type", "navigate_ui", "load_mbse_skill"})

def test_a_regular_thread_cannot_call_navigate_ui(self):
self.run_thread("regular-1", {"view": "gallery"})
Expand Down Expand Up @@ -329,3 +329,31 @@ def test_no_image_is_sent_to_a_text_only_model_and_the_model_is_told_why(self):
def test_a_vision_model_still_gets_the_image(self):
images, _ = self.run_png_render(vision=True)
self.assertEqual(len(images), 1)


class LoadMbseSkillRunTest(PlannerRunTest):
def run_load(self, thread_id, name):
call = {"id": "m1", "function": {"name": "load_mbse_skill", "arguments": json.dumps({"name": name})}}
self.client.chat_completion.side_effect = [
{"model": "m", "choices": [{"message": {"content": "", "tool_calls": [call]}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}},
{"model": "m", "choices": [{"message": {"content": "Done."}}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}},
]
body = json.dumps({"threadId": thread_id, "runId": "rm", "messages": [{"role": "user", "content": "are my requirements traced?"}]}).encode()
req = urllib.request.Request(f"http://127.0.0.1:{self.port}/run", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
stream = resp.read().decode()
sent = self.client.chat_completion.call_args_list[1].args[0]
return stream, [m for m in sent if m.get("role") == "tool"]

def test_the_planner_can_load_an_mbse_skill_and_the_model_is_told_it_is_recalled_not_verified(self):
stream, tool_msgs = self.run_load("planner-mbse", "v-model-traceability")
self.assertEqual(len(tool_msgs), 1)
self.assertTrue(tool_msgs[0]["content"].startswith("[confidence: recalled"), tool_msgs[0]["content"][:80])
self.assertIn("The V, as questions to ask of the model", tool_msgs[0]["content"])
self.assertIn("TOOL_CALL_RESULT", stream)
self.assertFalse(self.calls, "a local skill load must not call the kr0ki server")

def test_a_regular_drawing_thread_can_load_it_too_and_an_unknown_name_lists_what_exists(self):
_, tool_msgs = self.run_load("regular-mbse", "no-such-skill")
self.assertIn("unknown MBSE skill 'no-such-skill'", tool_msgs[0]["content"])
self.assertIn("requirement-writing", tool_msgs[0]["content"])
2 changes: 1 addition & 1 deletion crates/kr0ki-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ kr0ki-sysmlv2-client = { path = "../kr0ki-sysmlv2-client" }
# exists after ufo-types#29's squash-merge into main (2026-09-20). Re-pinned
# to that merge commit on main -- bump to a `tag =` pin once ufo-types cuts
# a release past v0.15.0 including it.
ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "ee873488f31a390073d5f8ab38d91654a3504bfa", features = ["reqif"] }
ufo-types = { git = "https://github.com/PromptExecution/ufo-types", rev = "db8e22fc2c09ff1b58cc4effce6096d17a89f3e4", features = ["reqif"] }
# 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
Expand Down
Loading
Loading