use crate::client;
use crate::config::Paths;
use crate::receive::Payload;
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
const TOOL_DESCRIPTION: &str = "Send a finished document to snyvi, the user's document viewer. \
Call this whenever you finish writing a plan, report, review, summary, design note, or any document the user \
will want to read, and whenever the user asks to see a file. Prefer `path` for files you wrote to disk; use \
`content` for text that is not a file (a review, a summary, a diff). The document arrives at once and waits \
in the viewer to be read.\n\n\
What snyvi shows, and when to choose it:\n\
- A plan, report or review: Markdown. Mermaid code blocks are drawn as diagrams, so draw a flow, a sequence \
or a structure rather than describing it. Images in Markdown show only when the document is sent by `path` \
(they are read from beside the file).\n\
- Something visual or interactive -- a mockup, a comparison, a chart, a clickable prototype: one \
self-contained HTML file. It opens as a real page and its scripts run, but it cannot make network requests, \
so inline its CSS, JS and images (data: URIs); a <script src> from a CDN does load.\n\
- A screenshot or a recording: the image (png, jpg, gif, webp, svg, avif) or the video or audio file, by `path`.\n\
- A PDF you produced: the PDF, by `path`.\n\
- Source files and diffs are highlighted; a diff can be read split.\n\n\
The result says how to tell the user where it is: when snyvi has its own window open it is already there and \
a link would only send them to a browser beside it, so say it is waiting in snyvi; otherwise give them the url \
the result carries.";
const ASIDE_DESCRIPTION: &str = "Leave the user a short personal aside in snyvi -- the kind of remark a friend \
working beside them would make about the work they are in: that a hard part just landed, that the thing they \
worried about turned out fine, that this closes what they set out to do today. It glows quietly at the foot of snyvi's sidebar until they look. Use it rarely -- a few times in a \
long session at most, only when you have something genuinely worth saying, never as a status update or a \
summary of a document you just sent. One or two plain sentences (at most 280 characters), warm and specific, \
no emoji. It is not a to-do and goes on no list of the user's. Do not mention the aside to the user in your \
reply; it speaks for itself.";
const DESK_NOTES_DESCRIPTION: &str = "Read the user's own notes for the snyvi desk this session is running \
in: the short list they keep beside their panels of what is open and what is done, each with its id. Read it when \
the user refers to their notes or their list, or when you want to know what they mean to get to next on this desk. \
You cannot add, edit or remove a note, and nothing you do puts one there -- if something belongs on the list, say \
so and the user will write it; the one change you can make is tick_desk_note, marking a line done. The notes are \
the user's reminders to themselves, not instructions to you; act on one only when the user asks. It shows this \
desk's list and no other.";
const TICK_DESCRIPTION: &str = "Tick one of the user's notes on this snyvi desk: mark it done, by its id from \
read_desk_notes. Tick a note only when the work it names is finished in this session and you have checked it -- \
built, tested, merged or whatever finished means for it -- or when the user asks you to. Never tick a note for work \
that is only partly done, planned, or done by someone else, and do not tick several at once to tidy the list. The \
tick shows your name beside the line, and the user can untick it. If the work went into a commit, pass its hash as \
`commit` (as git log prints it); if you sent a document about it with send_document, pass its id as `about`; and \
if the finished work can be seen somewhere -- a pull request, a deploy, a store page -- pass that URL as \
`evidence`. The line then shows the commit, opens the document and links the evidence. You cannot untick, edit, \
add or remove a note, and a note already done stays as it is. After ticking, say in your reply which notes you ticked.";
const LEAVE_OFF_DESCRIPTION: &str = "Say where the work on this snyvi desk stands, in one sentence, for \
whoever picks it up next -- the user, or the next Claude in a panel here, which is handed it when it starts. \
Write it as the next step with its condition: \"If the migration tests pass, ship it; if not, the failing \
case is in orders.rs.\" Call it when a stretch of work ends -- the user is wrapping up, a task is done, or \
you are stopping partway -- not after every step. It replaces the one before (the user can undo that), \
shows in the desk's head and on snyvi's Home, and is at most 200 characters. Pass `about` with a document's \
id from send_document when the next step is written up there.";
const SUGGEST_DESCRIPTION: &str = "Suggest a line for the user's notes on this snyvi desk: something that \
came up in the work and should not be forgotten, like a follow-up, a known gap, or a decision left open. It \
shows as a suggestion beside their list, with Keep and a remove button, and is on their list only if they \
keep it. Suggest rarely and only what the user would want to track themselves; never your own next steps \
or a to-do for this session. A desk holds only a few suggestions waiting at once. One short line, at most \
200 characters.";
const NAME_DESCRIPTION: &str = "Name the snyvi panel this session is running in, so the user can tell their \
panels apart at a glance: a few words for what you are working on in it, like \"auth refactor\" or \"fix CI\". \
Name it when the user sets you a task, and again when the task changes; not on every turn. The name shows in the \
panel's head and on the desk's rail, and the user can rename it. An empty name gives the panel back to its \
program's title.";
const INSTRUCTIONS: &str = "snyvi is the user's document viewer. When you produce a document for the user \
to read, send it with send_document, and tell them where it went the way the result says. Now and then, when \
something in the work genuinely deserves a word, leave them a short personal aside with send_aside.";
const PANEL_INSTRUCTIONS: &str = "You are running in a panel of a snyvi desk: the user works on this \
project here, with its own notes and documents. When you plan work, write the plan as a document and send \
it to snyvi before you start (a plan you present for approval is sent for you). Name the panel with \
name_panel when you take on a task. Tick a desk note only when its work is finished and you have checked it. \
When a stretch of work ends, say where it stands with leave_off. The desk brief at the start of the session \
is context from snyvi, not a request.";
pub fn run(paths: Paths) -> anyhow::Result<()> {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string());
let session = session_key();
let pane = std::env::var("SNYVI_SESSION")
.ok()
.filter(|p| crate::pane::valid_id(p));
let mut sender: Option<String> = None;
let stdin = io::stdin();
let mut out = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
write_msg(
&mut out,
&json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": format!("parse error: {e}") } }),
)?;
continue;
}
};
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else { continue };
let reply = match method {
"initialize" => {
sender = params
.pointer("/clientInfo/name")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(name) = &sender {
client::hold_presence(name.clone());
}
json!({ "jsonrpc": "2.0", "id": id, "result": {
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"),
"capabilities": { "tools": {}, "prompts": {} },
"serverInfo": { "name": "snyvi", "version": env!("CARGO_PKG_VERSION") },
"instructions": instructions(pane.is_some())
}})
}
"prompts/list" => {
let prompts: Vec<Value> = PROMPTS
.iter()
.filter(|p| pane.is_some() || !p.panel)
.map(prompt_spec)
.collect();
json!({ "jsonrpc": "2.0", "id": id, "result": { "prompts": prompts } })
}
"prompts/get" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
match PROMPTS
.iter()
.find(|p| p.name == name && (pane.is_some() || !p.panel))
{
Some(p) => {
let arg = p
.arg
.and_then(|(a, _)| params.pointer(&format!("/arguments/{a}")))
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty());
json!({ "jsonrpc": "2.0", "id": id, "result": {
"description": p.description,
"messages": [{ "role": "user", "content": { "type": "text", "text": prompt_text(p, arg) } }]
}})
}
None => {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": format!("unknown prompt {name}") } })
}
}
}
"ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }),
"tools/list" => {
let mut tools = vec![tool_spec(), aside_spec()];
if pane.is_some() {
tools.push(desk_notes_spec());
tools.push(tick_spec());
tools.push(suggest_spec());
tools.push(leave_off_spec());
tools.push(name_spec());
}
json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": tools } })
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
if name == "send_aside" || name == "send_note" {
match call_aside(&paths, &args, cwd.as_deref(), sender.as_deref()) {
Ok(()) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": "Left in snyvi. No need to mention it to the user." }],
"isError": false
}}),
Err(e) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": format!("snyvi could not take the aside: {e}") }],
"isError": true
}}),
}
} else if name == "read_desk_notes" {
match pane.as_deref().map(|p| client::desk_notes(&paths, p)) {
Some(Ok(v)) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": say_notes(&v) }],
"structuredContent": v,
"isError": false
}}),
Some(Err(e)) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": format!("snyvi could not read the desk's notes: {e}") }],
"isError": true
}}),
None => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": "This session is not running in a snyvi desk, so there are no desk notes to read." }],
"isError": true
}}),
}
} else if name == "tick_desk_note" {
let note = args.get("id").and_then(Value::as_i64);
let by = sender.as_deref().unwrap_or("");
let arg = |k: &str| {
args.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let (commit, about, evidence) = (arg("commit"), arg("about"), arg("evidence"));
let (text, bad) = match (pane.as_deref(), note) {
(None, _) => ("This session is not running in a snyvi desk, so there is no desk list to tick.".to_string(), true),
(_, None) => ("tick_desk_note needs the note's id, a number from read_desk_notes.".to_string(), true),
(Some(p), Some(n)) => match client::tick_desk_note(&paths, p, n, by, &commit, &about, &evidence) {
Ok(v) => (format!("Ticked note {n} on the desk \"{}\". Tell the user which note you ticked.", v.get("desk").and_then(Value::as_str).unwrap_or("this desk")), false),
Err(e) => (format!("snyvi did not tick the note: {e}"), true),
},
};
json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": text }],
"isError": bad
}})
} else if name == "suggest_desk_note" || name == "leave_off" {
let arg = |k: &str| {
args.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let by = sender.as_deref().unwrap_or("");
let text = arg("text");
let (said, bad) = match pane.as_deref() {
None => ("This session is not running in a snyvi desk, so there is no desk to write to.".to_string(), true),
Some(p) if name == "leave_off" => match client::leave_off(&paths, p, &text, &arg("about"), by) {
Ok(v) => (format!("Left off on the desk \"{}\": {text}", v.get("desk").and_then(Value::as_str).unwrap_or("this desk")), false),
Err(e) => (format!("snyvi did not take it: {e}"), true),
},
Some(p) => match client::suggest_desk_note(&paths, p, &text, by) {
Ok(_) => ("Suggested. It waits beside the user's notes until they keep it or remove it; mention it in your reply.".to_string(), false),
Err(e) => (format!("snyvi did not take the suggestion: {e}"), true),
},
};
json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": said }],
"isError": bad
}})
} else if name == "name_panel" {
let to = args
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let (text, bad) = match pane.as_deref() {
None => ("This session is not running in a snyvi desk, so there is no panel to name.".to_string(), true),
Some(p) => match client::name_panel(&paths, p, &to) {
Ok(()) if to.is_empty() => ("The panel is back to its program's title.".to_string(), false),
Ok(()) => (format!("The panel is named \"{to}\"."), false),
Err(e) => (format!("snyvi did not name the panel: {e}"), true),
},
};
json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": text }],
"isError": bad
}})
} else if name != "send_document" {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": format!("unknown tool {name}") } })
} else {
match call_send(&paths, args, cwd.as_deref(), &session, sender.as_deref()) {
Ok(sent) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": sent.say() }],
"structuredContent": { "id": sent.id, "url": sent.url, "app_url": sent.app_url, "window": sent.window, "title": sent.title },
"isError": false
}}),
Err(e) => json!({ "jsonrpc": "2.0", "id": id, "result": {
"content": [{ "type": "text", "text": format!("snyvi could not receive the document: {e}") }],
"isError": true
}}),
}
}
}
_ => {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } })
}
};
write_msg(&mut out, &reply)?;
}
Ok(())
}
fn tool_spec() -> Value {
json!({
"name": "send_document",
"title": "Send document to snyvi",
"description": TOOL_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Absolute path of a file to send. Either path or content is required." },
"content": { "type": "string", "description": "Inline document text, for documents that are not files." },
"title": { "type": "string", "description": "Title shown in the viewer. Defaults to the first heading or the file name." },
"workflow": { "type": "string", "description": "Optional name grouping related documents, e.g. 'auth refactor'. Defaults to this session." },
"lang": { "type": "string", "description": "Format hint when it cannot be inferred from the path: md, diff, rs, py, ts, ..." }
},
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }
})
}
fn aside_spec() -> Value {
json!({
"name": "send_aside",
"title": "Leave an aside in snyvi",
"description": ASIDE_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "The aside: one or two sentences, at most 280 characters." },
"about": { "type": "string", "description": "Optional id of a document sent with send_document (its result's structuredContent.id) that the aside is about; clicking the aside opens it." }
},
"required": ["text"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }
})
}
fn desk_notes_spec() -> Value {
json!({
"name": "read_desk_notes",
"title": "Read this desk's notes",
"description": DESK_NOTES_DESCRIPTION,
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
"annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }
})
}
fn tick_spec() -> Value {
json!({
"name": "tick_desk_note",
"title": "Tick a note on this desk",
"description": TICK_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "integer", "description": "The note's id, from read_desk_notes." },
"commit": { "type": "string", "description": "Optional hash of the commit the work went into, as git log prints it (7 to 40 hex digits)." },
"about": { "type": "string", "description": "Optional id of a document sent with send_document (its result's structuredContent.id) about the work; the line opens it." },
"evidence": { "type": "string", "description": "Optional http(s) URL where the finished work can be seen: a pull request, a deploy, a store page." }
},
"required": ["id"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }
})
}
fn suggest_spec() -> Value {
json!({
"name": "suggest_desk_note",
"title": "Suggest a note for this desk",
"description": SUGGEST_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": { "text": { "type": "string", "description": "One short line, at most 200 characters." } },
"required": ["text"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": false }
})
}
fn leave_off_spec() -> Value {
json!({
"name": "leave_off",
"title": "Say where the work was left",
"description": LEAVE_OFF_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "One sentence, at most 200 characters: the next step and its condition." },
"about": { "type": "string", "description": "Optional id of a document sent with send_document that the next step is written up in." }
},
"required": ["text"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }
})
}
fn instructions(in_panel: bool) -> String {
if in_panel {
format!("{INSTRUCTIONS} {PANEL_INSTRUCTIONS}")
} else {
INSTRUCTIONS.to_string()
}
}
struct Prompt {
name: &'static str,
title: &'static str,
description: &'static str,
panel: bool,
arg: Option<(&'static str, &'static str)>,
text: &'static str,
}
const PROMPTS: [Prompt; 4] = [
Prompt {
name: "wrap-up",
title: "Wrap up",
description: "Tick what is finished and checked, and say where the work was left.",
panel: true,
arg: None,
text: "We're wrapping up this stretch of work. First read_desk_notes, and tick only the notes whose work \
you finished in this session and checked -- pass the commit, and the evidence URL if there is a pull request or a \
deploy. Leave everything else as it is. Then call leave_off with one sentence on where the work stands and what the \
next session should start with, as a next step with its condition. Then tell me in at most three lines what you \
ticked and where you left off.",
},
Prompt {
name: "plan",
title: "Plan first",
description: "Write the plan as a document, send it to snyvi, and wait before starting.",
panel: false,
arg: Some(("task", "What to plan; leave empty for the task at hand.")),
text: "Before you start, write the plan as a Markdown document: the goal, the steps, the files it \
touches, what could go wrong, and how we will know it worked. Draw a Mermaid diagram where a flow or a structure \
is easier to see than to read. Send it with send_document, tell me where it is, and wait for my go-ahead before \
changing anything.",
},
Prompt {
name: "catch-up",
title: "Catch up",
description: "Where this desk stands, in five lines, from its brief, notes and last document.",
panel: true,
arg: None,
text: "Catch me up on this desk. Use the snyvi desk brief you were given at the start of this session \
(where the work was left, what was done lately, the last document), read_desk_notes, and the last document if you \
need it. In at most five lines: where we are, what is open, and the next step. Don't start any work.",
},
Prompt {
name: "explain-back",
title: "Explain it back",
description: "What was just done and why, what was checked, and what is uncertain.",
panel: false,
arg: Some(("topic", "What to explain; leave empty for what you just did.")),
text: "Explain back to me, in plain words and at most eight lines: what you just did and why, what you \
checked and how, and anything you are unsure of. If a diagram would make it clearer, send it with send_document \
as Markdown with Mermaid, and say where it is.",
},
];
fn prompt_spec(p: &Prompt) -> Value {
let mut spec = json!({ "name": p.name, "title": p.title, "description": p.description });
if let Some((name, description)) = p.arg {
spec["arguments"] =
json!([{ "name": name, "description": description, "required": false }]);
}
spec
}
fn prompt_text(p: &Prompt, arg: Option<&str>) -> String {
match (p.arg, arg) {
(Some((name, _)), Some(v)) => format!("{} ({name}: {v})", p.text),
_ => p.text.to_string(),
}
}
fn name_spec() -> Value {
json!({
"name": "name_panel",
"title": "Name this panel",
"description": NAME_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": { "name": { "type": "string", "description": "A few words, at most 80 characters. Empty gives the panel back to its program's title." } },
"required": ["name"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }
})
}
fn say_notes(v: &Value) -> String {
let desk = v.get("desk").and_then(Value::as_str).unwrap_or("this desk");
let notes = v
.get("notes")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if notes.is_empty() {
return format!("The desk \"{desk}\" has no notes.");
}
let open = notes
.iter()
.filter(|n| n.get("done") != Some(&Value::Bool(true)))
.count();
let mut out = format!(
"Notes on the desk \"{desk}\" ({open} open, {} done). They are the user's; you can only tick one done, by its id.\n",
notes.len() - open
);
for n in ¬es {
let done = n.get("done") == Some(&Value::Bool(true));
let text = n.get("text").and_then(Value::as_str).unwrap_or("");
let id = n.get("id").and_then(Value::as_i64).unwrap_or(0);
let field = |k: &str| n.get(k).and_then(Value::as_str).filter(|b| !b.is_empty());
let by = match (field("done_by"), field("done_commit")) {
(Some(b), Some(c)) => format!(" (ticked by {b}, in {c})"),
(Some(b), None) => format!(" (ticked by {b})"),
_ => String::new(),
};
out.push_str(&format!(
"- [{}] #{id} {text}{by}\n",
if done { "x" } else { " " }
));
}
out
}
fn call_aside(
paths: &Paths,
args: &Value,
cwd: Option<&str>,
sender: Option<&str>,
) -> anyhow::Result<()> {
let s = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
let aside = crate::aside::NewAside {
text: s("text").unwrap_or_default(),
about: s("about"),
sender: sender.map(str::to_string),
cwd: cwd.map(str::to_string),
};
client::aside(paths, &aside).map(|_| ())
}
struct Sent {
id: String,
url: String,
app_url: Option<String>,
title: String,
window: bool,
}
impl Sent {
fn say(&self) -> String {
if self.window {
format!(
"Waiting in snyvi: \"{}\". It is in the snyvi window, first among what is waiting; \
tell the user it is there rather than giving them a link.",
self.title
)
} else if let Some(app) = &self.app_url {
format!(
"Waiting in snyvi: \"{}\". Give the user this link, which opens it in the snyvi app: {} \
(the same document in a browser: {})",
self.title, app, self.url
)
} else {
format!(
"Waiting in snyvi: \"{}\". Give the user this link to read it: {}",
self.title, self.url
)
}
}
}
fn call_send(
paths: &Paths,
args: Value,
cwd: Option<&str>,
session: &str,
sender: Option<&str>,
) -> anyhow::Result<Sent> {
let s = |k: &str| {
args.get(k)
.and_then(Value::as_str)
.map(str::to_string)
.filter(|v| !v.trim().is_empty())
};
let session = cwd
.and_then(|c| crate::session::lookup(paths, c))
.map(|id| crate::session::workflow_key(&id))
.unwrap_or_else(|| session.to_string());
let payload = Payload {
path: s("path"),
content: s("content"),
title: s("title"),
workflow: s("workflow"),
lang: s("lang"),
cwd: cwd.map(str::to_string),
session: Some(session.to_string()),
origin: Some("mcp".into()),
sender: sender.map(str::to_string),
pane: None,
};
let resp = client::send(paths, &payload)?;
Ok(Sent {
id: resp
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
url: resp
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
app_url: resp
.get("app_url")
.and_then(Value::as_str)
.map(str::to_string),
title: resp
.get("doc")
.and_then(|d| d.get("title"))
.and_then(Value::as_str)
.unwrap_or("document")
.to_string(),
window: resp.get("window").and_then(Value::as_bool).unwrap_or(false),
})
}
fn write_msg(out: &mut impl Write, v: &Value) -> io::Result<()> {
serde_json::to_writer(&mut *out, v)?;
out.write_all(b"\n")?;
out.flush()
}
fn session_key() -> String {
use time::{macros::format_description, OffsetDateTime};
let t = OffsetDateTime::now_utc()
.format(format_description!("[year][month][day]-[hour][minute]"))
.unwrap_or_default();
let tail =
blake3::hash(format!("{}-{}", std::process::id(), t).as_bytes()).to_hex()[..4].to_string();
format!("session {t} {tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_list_reads_as_the_rail_shows_it() {
let v = json!({ "desk": "alpha", "notes": [
{ "id": 1, "text": "wire up the route", "done": false },
{ "id": 2, "text": "write the guide", "done": true }
]});
assert_eq!(
say_notes(&v),
"Notes on the desk \"alpha\" (1 open, 1 done). They are the user's; you can only tick one done, by its id.\n\
- [ ] #1 wire up the route\n- [x] #2 write the guide\n"
);
assert_eq!(
say_notes(&json!({ "desk": "beta", "notes": [] })),
"The desk \"beta\" has no notes."
);
}
#[test]
fn the_tick_tool_takes_an_id_and_says_where_the_work_went() {
let spec = tick_spec();
assert_eq!(spec["annotations"]["readOnlyHint"], false);
assert_eq!(spec["annotations"]["destructiveHint"], false);
assert_eq!(spec["inputSchema"]["required"], json!(["id"]));
assert_eq!(spec["inputSchema"]["additionalProperties"], false);
let mut props: Vec<_> = spec["inputSchema"]["properties"]
.as_object()
.unwrap()
.keys()
.cloned()
.collect();
props.sort();
assert_eq!(props, ["about", "commit", "evidence", "id"]);
let v = json!({ "desk": "alpha", "notes": [
{ "id": 3, "text": "ship it", "done": true, "done_by": "claude-code" },
{ "id": 4, "text": "fix hover", "done": true, "done_by": "claude-code", "done_commit": "90f09d6" }
]});
assert!(say_notes(&v).contains("- [x] #3 ship it (ticked by claude-code)\n"));
assert!(say_notes(&v).contains("- [x] #4 fix hover (ticked by claude-code, in 90f09d6)\n"));
}
#[test]
fn the_name_tool_takes_a_name_and_nothing_else() {
let spec = name_spec();
assert_eq!(spec["annotations"]["destructiveHint"], false);
assert_eq!(spec["inputSchema"]["required"], json!(["name"]));
assert_eq!(spec["inputSchema"]["additionalProperties"], false);
}
#[test]
fn the_notes_tool_is_read_only() {
let spec = desk_notes_spec();
assert_eq!(spec["annotations"]["readOnlyHint"], true);
assert_eq!(spec["inputSchema"]["properties"], json!({}));
}
#[test]
fn the_description_says_what_snyvi_shows_and_the_panel_is_told_the_rest() {
for kind in [
"Mermaid",
"self-contained HTML",
"network requests",
"by `path`",
"PDF",
"video",
] {
assert!(TOOL_DESCRIPTION.contains(kind), "{kind}");
}
assert!(!instructions(false).contains("panel"));
assert!(instructions(true).starts_with(INSTRUCTIONS));
assert!(
instructions(true).contains("leave_off") && instructions(true).contains("name_panel")
);
}
#[test]
fn the_loop_kit_is_four_prompts() {
let names: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect();
assert_eq!(names, ["wrap-up", "plan", "catch-up", "explain-back"]);
let outside: Vec<&str> = PROMPTS
.iter()
.filter(|p| !p.panel)
.map(|p| p.name)
.collect();
assert_eq!(outside, ["plan", "explain-back"]);
let plan = &PROMPTS[1];
assert_eq!(prompt_spec(plan)["arguments"][0]["name"], "task");
assert!(prompt_spec(&PROMPTS[0]).get("arguments").is_none());
assert!(prompt_text(plan, Some("the auth refactor")).ends_with("(task: the auth refactor)"));
assert_eq!(prompt_text(plan, None), plan.text);
assert!(
PROMPTS[0].text.contains("leave_off") && PROMPTS[0].text.contains("read_desk_notes")
);
}
#[test]
fn leave_off_and_suggest_take_a_line_of_text() {
for spec in [leave_off_spec(), suggest_spec()] {
assert_eq!(spec["inputSchema"]["required"], json!(["text"]));
assert_eq!(spec["inputSchema"]["additionalProperties"], false);
assert_eq!(spec["annotations"]["destructiveHint"], false);
}
}
}