diff --git a/.trinity/seals/t27mcp_T27mcpVisualOutput.json b/.trinity/seals/t27mcp_T27mcpVisualOutput.json new file mode 100644 index 0000000000..53bca11e6d --- /dev/null +++ b/.trinity/seals/t27mcp_T27mcpVisualOutput.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:076a0683dcc4e8487922bd6ff561422a1dfa707069e639fffaafea9d70ad8e2d", + "gen_hash_rust": "sha256:6267998500edaecfe3378c6ff7ed7482d1b0c239e1b8480fa697a8fd05b0d7b8", + "gen_hash_verilog": "sha256:241e62e3030919b4f540ff38480f8d726a4cc8301fbc30443369e7978adbbb1b", + "gen_hash_zig": "sha256:e7446390463513c4496f484f375ef59ae2f260172c8e46326f21008d84860411", + "module": "T27mcpVisualOutput", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T15:31:37Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:06c1c5e73142e2f79fa5d7be037eace63c1f20597a6e33274ebf8e1b428b7584", + "spec_path": "specs/t27mcp/visual_output.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 9, + "total": 9, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/specs/t27mcp/visual_output.t27 b/specs/t27mcp/visual_output.t27 new file mode 100644 index 0000000000..95a2fd98a1 --- /dev/null +++ b/specs/t27mcp/visual_output.t27 @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27mcp/visual_output.t27 -- every answer about a spec, a flow, numbers or a piece of work carries +; one visual, and the visual is the T27F way: drawn from tool rows, labelled, with the file it came from. +; Where it is drawn depends on the host. The rule is said to Claude Code through the MCP server's +; `instructions` (gHashTag/t27-mcp) and to TRI CLAW's chat agent through its prompt +; (gHashTag/999-multibots-telegraf, apps/vibee-editor/render): both read RULE_* from this spec, so the rule +; has one home. See the epic #10066 and T27F, gHashTag/trinity#1648. +; -- +; What was measured (2026-10-18, t27-mcp db98153): +; - t27f_graph of the reel fixture: an SVG 1860 wide, text at 13 px, 5923 bytes. Shown inline at 680 px it +; scales to 0.37, so its text is about 4.75 px: unreadable. t27f_view deck-evidence-wide: 1280 wide, body +; text 26 px (13.8 px inline, readable), a caption at 15 px (8 px inline, not readable). +; - The Claude Code desktop app draws an inline SVG or HTML visual with its visual tool (show_widget), at a +; container 680 wide, text at least 11 px. The CLI draws no image; it renders markdown tables. +; - An MCP server's `instructions` (the initialize result) reach the client model's system prompt; a tool +; result does not reach the user as an image on these hosts (reel.t27 header, 2026-10-17). +; - TRI CLAW's chat shows an image for an https URL ending in .svg on its own line or for ![title](url) +; (player/src/components/Chat/ChatAssets.tsx IMAGE_RE). The t27f tools write their SVG to the server's +; temporary directory, so TRI CLAW serves that directory by URL. +; -- +; Hosts 0..3 carry the numbers of reel.t27's HOST_*, and channels 0..3 its CHANNEL_*; this spec adds TRI +; CLAW, the inline widget, the image URL and the markdown table. reel.t27 channel_for() stays the channel +; of a reel still; answer_channel() here is the channel of an answer's visual. +; Readability: a drawing of width W shown at INLINE_W shows a font of F px at F * INLINE_W / W px, so it is +; readable when F * INLINE_W >= MIN_FONT_PX * W for its smallest text. A tool SVG is embedded as it is only +; when readable and at most INLINE_MAX_BYTES (the model re-emits it, so its bytes are output); otherwise it +; is redrawn at INLINE_W from t27f_outline rows. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module T27mcpVisualOutput; + +/// Hosts. 0..3 are reel.t27's numbers. +pub const HOST_CLAUDE_CODE_CLI : u32 = 0; +pub const HOST_CLAUDE_CODE_DESKTOP : u32 = 1; +pub const HOST_MCP_APPS : u32 = 2; +pub const HOST_SITE : u32 = 3; +pub const HOST_TRI_CLAW : u32 = 4; +pub const HOST_COUNT : u32 = 5; + +/// Channels. 0..3 are reel.t27's numbers. +pub const CH_FILE_PATH : u32 = 0; +pub const CH_FILE_AND_PANE : u32 = 1; +pub const CH_UI_RESOURCE : u32 = 2; +pub const CH_LIVE_PLAYER : u32 = 3; +pub const CH_INLINE_WIDGET : u32 = 4; +pub const CH_IMAGE_URL : u32 = 5; +pub const CH_MARKDOWN_TABLE : u32 = 6; +pub const CHANNEL_NAME : [7]str = [ + "file path", + "file and pane", + "ui resource", + "live player", + "inline widget", + "image url", + "markdown table" +]; + +/// Answer kinds. The first four carry a visual. +pub const K_SPEC : u32 = 0; +pub const K_FLOW : u32 = 1; +pub const K_NUMBERS : u32 = 2; +pub const K_REPORT : u32 = 3; +pub const K_ONE_SENTENCE : u32 = 4; +pub const K_QUESTION : u32 = 5; + +/// The inline container and the smallest text it may show. +pub const INLINE_W : u32 = 680; +pub const MIN_FONT_PX : u32 = 11; +pub const INLINE_MAX_BYTES : u32 = 16384; + +/// Where a tool SVG's drawing comes from. +pub const SRC_EMBED : u32 = 0; +pub const SRC_REDRAW : u32 = 1; + +/// The T27F labels, as t27f_outline gives them. +pub const LABEL_NAMES : [4]str = ["exact", "derived", "suggested", "unknown"]; + +/// answer_verdict() answers. +pub const V_OK : u32 = 0; +pub const V_NO_VISUAL : u32 = 1; +pub const V_INVENTED : u32 = 2; +pub const V_NO_LEGEND : u32 = 3; +pub const V_NO_SOURCE : u32 = 4; +pub const V_UNREADABLE : u32 = 5; +pub const VERDICT_TEXT : [6]str = [ + "ok", + "no visual: this answer needs one", + "invented: a node no tool row backs", + "no legend: labels drawn without their key", + "no source: the file or URL the drawing came from is not given", + "unreadable: its smallest text is under 11 px where it is shown" +]; + +/// The rule as Claude Code reads it, from the t27 MCP server's `instructions`. ASCII, one line each. +pub const RULE_CLAUDE_CODE : [7]str = [ + "Visual output (specs/t27mcp/visual_output.t27):", + "- Every answer that explains a spec, a flow, numbers or a piece of work carries one visual. Only a one-sentence reply or a question back goes without.", + "- In the Claude Code desktop app, draw it inline with your visual tool (show_widget) when you have one; else give the SVG path, which the app opens. In the CLI, give the path, or a markdown table when there is no SVG.", + "- Draw from tool rows, never from memory: t27f_outline, t27f_graph, t27f_view, t27_spec. Nothing is drawn that a row does not back.", + "- Label every node exact, derived, suggested or unknown as its row says, and show the legend.", + "- Embed a tool SVG as it is only when its smallest font_px * 680 >= 11 * its width and it is at most 16384 bytes; else redraw it 680 wide from t27f_outline rows.", + "- Always name the file the drawing came from: the file is the record, the drawing is a view of it." +]; + +/// The rule as TRI CLAW's chat agent reads it, from its prompt. ASCII, one line each. +pub const RULE_TRI_CLAW : [6]str = [ + "Visual output (specs/t27mcp/visual_output.t27):", + "- Every answer that explains a spec, a flow, numbers or a piece of work carries one visual. Only a one-sentence reply or a question back goes without.", + "- When a t27f tool answered, show its drawing as an image: its https URL ending in .svg on a line of its own, or ![title](url). Never paste SVG source into the chat.", + "- With no drawing, show the rows as a markdown table, not as prose.", + "- Draw from tool rows, never from memory. Label every item exact, derived, suggested or unknown as its row says, and give the legend.", + "- Name where the drawing came from: the tool and its file or URL." +]; +pub const RULE_CLAUDE_CODE_LINES : u32 = 7; +pub const RULE_TRI_CLAW_LINES : u32 = 6; + +/// Whether an answer of kind `kind` carries a visual. +pub fn needs_visual(kind: u32) -> bool { + return kind <= K_REPORT; +} + +/// How an answer's visual reaches the user on `host`. `inline_tool`: the client has a visual tool +/// (show_widget). `svg`: a t27f tool produced a drawing for this answer. +pub fn answer_channel(host: u32, inline_tool: bool, svg: bool) -> u32 { + if (host == HOST_CLAUDE_CODE_DESKTOP) { + if (inline_tool) { return CH_INLINE_WIDGET; } + if (svg) { return CH_FILE_AND_PANE; } + return CH_MARKDOWN_TABLE; + } + if (host == HOST_MCP_APPS) { return CH_UI_RESOURCE; } + if (host == HOST_SITE) { return CH_LIVE_PLAYER; } + if (host == HOST_TRI_CLAW) { + if (svg) { return CH_IMAGE_URL; } + return CH_MARKDOWN_TABLE; + } + if (svg) { return CH_FILE_PATH; } + return CH_MARKDOWN_TABLE; +} + +/// Whether text of `font_px` in a drawing `view_w` wide is at least MIN_FONT_PX when shown INLINE_W wide. +pub fn inline_readable(view_w: u32, font_px: u32) -> bool { + if (view_w == 0) { return false; } + return font_px * INLINE_W >= MIN_FONT_PX * view_w; +} + +/// Whether a tool SVG goes inline as it is or is redrawn from t27f_outline rows. `min_font_px` is its +/// smallest text. +pub fn inline_source(view_w: u32, min_font_px: u32, bytes: u32) -> u32 { + if (inline_readable(view_w, min_font_px) == false) { return SRC_REDRAW; } + if (bytes > INLINE_MAX_BYTES) { return SRC_REDRAW; } + return SRC_EMBED; +} + +/// The verdict on an answer. `drawn`: nodes in its visual; `backed`: those a tool row backs. +/// `label_kinds`: how many of the four labels it draws. `view_w` / `min_font_px`: the drawing's width +/// and its smallest text, in its own units (0 width when it is not shown inline). +pub fn answer_verdict(kind: u32, has_visual: bool, drawn: u32, backed: u32, label_kinds: u32, legend: bool, source: bool, view_w: u32, min_font_px: u32) -> u32 { + if (needs_visual(kind) == false) { return V_OK; } + if (has_visual == false) { return V_NO_VISUAL; } + if (drawn > backed) { return V_INVENTED; } + if (label_kinds > 0) { + if (legend == false) { return V_NO_LEGEND; } + } + if (source == false) { return V_NO_SOURCE; } + if (view_w > 0) { + if (inline_readable(view_w, min_font_px) == false) { return V_UNREADABLE; } + } + return V_OK; +} + +test "the tables are the size they say" { + assert CHANNEL_NAME[CH_INLINE_WIDGET] == "inline widget"; + assert CHANNEL_NAME[CH_MARKDOWN_TABLE] == "markdown table"; + assert LABEL_NAMES[3] == "unknown"; + assert VERDICT_TEXT[V_OK] == "ok"; + assert RULE_CLAUDE_CODE[0] == "Visual output (specs/t27mcp/visual_output.t27):"; + assert RULE_TRI_CLAW[0] == "Visual output (specs/t27mcp/visual_output.t27):"; + assert RULE_CLAUDE_CODE[1] == "- Every answer that explains a spec, a flow, numbers or a piece of work carries one visual. Only a one-sentence reply or a question back goes without."; + assert RULE_TRI_CLAW[1] == "- Every answer that explains a spec, a flow, numbers or a piece of work carries one visual. Only a one-sentence reply or a question back goes without."; +} + +test "needs_visual: spec, flow, numbers and a report carry one; one sentence and a question do not" { + assert needs_visual(K_SPEC); + assert needs_visual(K_FLOW); + assert needs_visual(K_NUMBERS); + assert needs_visual(K_REPORT); + assert needs_visual(K_ONE_SENTENCE) == false; + assert needs_visual(K_QUESTION) == false; +} + +test "answer_channel: the desktop app draws inline, TRI CLAW shows an image URL" { + assert answer_channel(HOST_CLAUDE_CODE_DESKTOP, true, true) == CH_INLINE_WIDGET; + assert answer_channel(HOST_CLAUDE_CODE_DESKTOP, true, false) == CH_INLINE_WIDGET; + assert answer_channel(HOST_TRI_CLAW, false, true) == CH_IMAGE_URL; + assert answer_channel(HOST_TRI_CLAW, true, true) == CH_IMAGE_URL; +} + +test "answer_channel: with no inline tool or no drawing, a path or a markdown table" { + assert answer_channel(HOST_CLAUDE_CODE_DESKTOP, false, true) == CH_FILE_AND_PANE; + assert answer_channel(HOST_CLAUDE_CODE_DESKTOP, false, false) == CH_MARKDOWN_TABLE; + assert answer_channel(HOST_CLAUDE_CODE_CLI, false, true) == CH_FILE_PATH; + assert answer_channel(HOST_CLAUDE_CODE_CLI, false, false) == CH_MARKDOWN_TABLE; + assert answer_channel(HOST_TRI_CLAW, false, false) == CH_MARKDOWN_TABLE; + assert answer_channel(HOST_MCP_APPS, false, true) == CH_UI_RESOURCE; + assert answer_channel(HOST_SITE, false, true) == CH_LIVE_PLAYER; + assert answer_channel(HOST_COUNT, false, true) == CH_FILE_PATH; +} + +test "inline_readable: the measured drawings" { + assert inline_readable(1860, 13) == false; + assert inline_readable(1280, 26); + assert inline_readable(1280, 15) == false; + assert inline_readable(680, 11); + assert inline_readable(680, 10) == false; + assert inline_readable(340, 6); +} + +test "inline_source: embed a small readable SVG, redraw the rest" { + assert inline_source(680, 12, 5923) == SRC_EMBED; + assert inline_source(1860, 13, 5923) == SRC_REDRAW; + assert inline_source(680, 12, INLINE_MAX_BYTES) == SRC_EMBED; + assert inline_source(680, 12, INLINE_MAX_BYTES + 1) == SRC_REDRAW; +} + +test "answer_verdict: a backed, labelled, sourced, readable drawing is ok" { + assert answer_verdict(K_SPEC, true, 12, 12, 3, true, true, 680, 12) == V_OK; + assert answer_verdict(K_REPORT, true, 4, 4, 0, false, true, 680, 11) == V_OK; + assert answer_verdict(K_ONE_SENTENCE, false, 0, 0, 0, false, false, 0, 0) == V_OK; + assert answer_verdict(K_NUMBERS, true, 5, 5, 1, true, true, 0, 0) == V_OK; +} + +test "negative control: a spec answer with no visual, or with a node no row backs" { + assert answer_verdict(K_SPEC, false, 0, 0, 0, false, false, 0, 0) == V_NO_VISUAL; + assert answer_verdict(K_FLOW, false, 0, 0, 0, false, true, 0, 0) == V_NO_VISUAL; + assert answer_verdict(K_SPEC, true, 13, 12, 3, true, true, 680, 12) == V_INVENTED; +} + +test "negative control: labels without a legend, a drawing without its source, unreadable text" { + assert answer_verdict(K_SPEC, true, 12, 12, 2, false, true, 680, 12) == V_NO_LEGEND; + assert answer_verdict(K_SPEC, true, 12, 12, 2, true, false, 680, 12) == V_NO_SOURCE; + assert answer_verdict(K_SPEC, true, 12, 12, 2, true, true, 1860, 13) == V_UNREADABLE; + assert inline_readable(0, 13) == false; +}