From 61da9e57cdae105904efbfc807a3779f7ff86adc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 14:58:05 -0600 Subject: [PATCH] feat(mcp): books.journal_history, the journal in words (P5) Agent reason: an agent traces a figure back to what produced it by reading the journal, but it must never read the file. books.journal_history is the one way: each line in order with its seq, time, type, a sentence saying what it did to what, its subject and the entry it belongs to, filtered by after_seq, type (exact, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2), paged with more, with the torn tails set aside on open listed beside them. It reads the verified journal through read_journal_and_format, so a damaged chain is an error, not a story. The sentences come from ledger-store's describe.rs, which quotes only identifiers, dates, amounts and the short names a person typed: never a setting's value (settings.set names its keys) and never a full TIN (masked, B-26). Every event type the store writes has a sentence (tested against EVENT_TYPES). The tool is read-only (books:read); the books://journal/{seq} and books://entries/{id} resources read through it. Departure from the plan: the viewer's own sentences (its describe arms in ledger-gui, which carry icons, routes and today's party and account names) stay where they are; moving a thousand lines of egui text behind a formatting trait was not worth the risk to the viewer's tests, so the server has the shared describer and the viewer keeps its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/mcp_journeys.rs | 2 +- crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server.rs | 31 ++ crates/ledger-mcp/src/server_tests.rs | 3 +- crates/ledger-mcp/src/tools/journal.rs | 137 +++++++++ crates/ledger-store/src/describe.rs | 378 ++++++++++++++++++++++++ crates/ledger-store/src/lib.rs | 1 + docs/mcp-interface.md | 41 +++ 8 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 crates/ledger-mcp/src/tools/journal.rs create mode 100644 crates/ledger-store/src/describe.rs diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index 8158e25..4a18715 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 133, "expected 133 tools, got {}", tools.len()); + assert_eq!(tools.len(), 134, "expected 134 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 991d2ce..84a2ece 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 133] = [ +pub const TABLE: [(&str, Needs); 134] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -200,6 +200,8 @@ pub const TABLE: [(&str, Needs); 133] = [ ("books.list_drafts", R), ("books.set_draft_state", W), ("books.discard_draft", C), + // P5 + ("books.journal_history", R), ("books.preview_entry", R), ]; diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 0a70caf..3508f1b 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -584,6 +584,7 @@ impl BooksServer { + Self::tax_router() + Self::write_offs_router() + Self::drafts_router() + + Self::journal_router() } /// The settings in force for this request: the books' own, taken from @@ -1403,6 +1404,8 @@ mod backup_tools; mod files; #[path = "tools/instruments.rs"] mod instruments; +#[path = "tools/journal.rs"] +mod journal_tools; #[path = "tools/kept.rs"] mod kept; #[path = "tools/projects.rs"] @@ -1539,6 +1542,34 @@ impl BooksServer { .await?; json(&out) } + _ if path.starts_with("books://entries/") => { + let id: u64 = path["books://entries/".len()..] + .parse() + .map_err(|_| format!("bad entry resource URI: {uri}"))?; + let Json(out) = self + .get_entry(Parameters(GetEntryParams { entry_id: id })) + .await?; + json(&out) + } + _ if path.starts_with("books://journal/") => { + let seq: u64 = path["books://journal/".len()..] + .parse() + .map_err(|_| format!("bad journal resource URI: {uri}"))?; + if seq == 0 { + return Err("journal seqs start at 1".into()); + } + let Json(out) = self + .journal_history(Parameters(journal_tools::JournalHistoryParams { + after_seq: Some(seq - 1), + limit: Some(1), + ..Default::default() + })) + .await?; + if out.lines.first().is_none_or(|l| l.seq != seq) { + return Err(format!("the journal has no line {seq}")); + } + json(&out) + } handbook::URI => { let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?; json(&out) diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index efce2c8..7d3ce2d 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 133] = [ + let table: [(&str, Hints); 134] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -178,6 +178,7 @@ fn annotations_complete() { ("books.list_drafts", R), ("books.set_draft_state", W), ("books.discard_draft", D), + ("books.journal_history", R), ("books.preview_entry", R), ]; let tools = BooksServer::tool_router().list_all(); diff --git a/crates/ledger-mcp/src/tools/journal.rs b/crates/ledger-mcp/src/tools/journal.rs new file mode 100644 index 0000000..fe40299 --- /dev/null +++ b/crates/ledger-mcp/src/tools/journal.rs @@ -0,0 +1,137 @@ +//! `books.journal_history`: the journal in words (plan P5 step 4). +//! +//! ```text +//! GOAL: an agent can trace what the books did, line by line, without the +//! journal file: the only way to "read the journal". +//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer +//! is ledger_store::describe. +//! INVARIANTS: +//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by +//! default, in journal order from the line after `after_seq`. +//! 2. A sentence quotes no setting value and no full TIN +//! (ledger_store::describe INVARIANT 2). +//! 3. `subject` is `kind:id`; a bad one is refused with the shapes. +//! ``` + +use super::*; +use ledger_store::describe::{HistoryFilter, parse_subject}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The most lines one call returns. +const HISTORY_MAX: usize = 200; +/// The lines a call returns when it names no limit. +const HISTORY_DEFAULT: usize = 50; + +/// Parameters for `books.journal_history`. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct JournalHistoryParams { + /// Only lines after this journal seq (default: from the first). + #[serde(default)] + pub after_seq: Option, + /// Only these line types: an exact type (`entry.posted`) or a group + /// ending in a dot (`entry.`). + #[serde(default)] + pub types: Vec, + /// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`, + /// `account:10`, `party:5` or `asset:2`. + #[serde(default)] + pub subject: Option, + /// At most this many lines (default 50, most 200). + #[serde(default)] + pub limit: Option, +} + +/// One journal line, in words. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JournalLineOut { + /// Its seq. + pub seq: u64, + /// When it was written (UTC). + pub ts: String, + /// Its type, e.g. `entry.posted`. + #[serde(rename = "type")] + pub line_type: String, + /// What it did, in a sentence. + pub summary: String, + /// What it is about, as `kind:id`, if it is about one thing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + /// The entry it belongs to, if it is about one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_id: Option, +} + +/// A torn journal tail set aside when the books opened. +#[derive(Debug, Serialize, JsonSchema)] +pub struct TailRepairOut { + /// When (UTC). + pub at: String, + /// How many bytes were set aside. + pub bytes: u64, +} + +/// Result of `books.journal_history`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JournalHistoryOut { + /// The lines, in journal order. + pub lines: Vec, + /// Whether more lines match after the last (ask again with its seq as + /// after_seq). + pub more: bool, + /// The journal's last seq. + pub head_seq: u64, + /// Torn tails set aside on open, oldest first. + pub tail_repairs: Vec, +} + +#[tool_router(router = journal_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.journal_history", + title = "Journal history", + description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn journal_history( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let subject = p + .subject + .as_deref() + .map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}"))) + .transpose()?; + let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX); + let filter = HistoryFilter { + after_seq: p.after_seq.unwrap_or(0), + types: p.types, + subject, + limit, + }; + let store = self.lock()?; + let h = store.journal_history(&filter).map_err(|e| e.to_string())?; + Ok(Json(JournalHistoryOut { + lines: h + .lines + .into_iter() + .map(|d| JournalLineOut { + seq: d.seq, + ts: d.ts, + line_type: d.line_type, + summary: d.summary, + subject: d.subjects.first().cloned(), + entry_id: d.entry_id, + }) + .collect(), + more: h.more, + head_seq: h.head_seq, + tail_repairs: h + .tail_repairs + .into_iter() + .map(|(at, bytes)| TailRepairOut { at, bytes }) + .collect(), + })) + } +} diff --git a/crates/ledger-store/src/describe.rs b/crates/ledger-store/src/describe.rs new file mode 100644 index 0000000..ee64236 --- /dev/null +++ b/crates/ledger-store/src/describe.rs @@ -0,0 +1,378 @@ +//! The journal in words: one sentence for each line, and the subjects a +//! line is about. The one describer the server's `books.journal_history` +//! reads. +//! +//! ```text +//! GOAL: an agent can read what the books did, in order, without the +//! journal file: each line says in a sentence what happened, to +//! what, and which entry it belongs to. +//! SOURCE: plan/served.md P5 step 4 (the only way to "read the journal"); +//! docs/receipt-store.md (the lines and their payloads); +//! docs/spec/rules.md B-26 (no output holds a full TIN). +//! INVARIANTS: +//! 1. Every line type the store writes has a sentence (test: every +//! `format::EVENT_TYPES` entry). +//! 2. A sentence quotes only identifiers, dates, amounts and the +//! short names and descriptions a person typed; never a setting's +//! value, a payload's secret, or a full TIN (they are masked). +//! 3. Lines are returned in journal order, from the line after +//! `after_seq`, at most `limit`; a filter never reorders them. +//! 4. A subject is `kind:id` (`entry:12`, `invoice:3`, `bill:3`, +//! `account:10`, `party:5`, `asset:2`); a line is about the subject +//! its type names and every one its payload refers to. +//! ``` + +use crate::journal::JournalLine; +use serde_json::Value; + +/// The most characters of any quoted text. +const QUOTE_MAX: usize = 80; + +/// One line of the journal, described. +#[derive(Clone, Debug, PartialEq)] +pub struct Described { + /// The line's seq. + pub seq: u64, + /// When it was written (UTC, RFC 3339). + pub ts: String, + /// Its type. + pub line_type: String, + /// A sentence. + pub summary: String, + /// The subjects it is about, as `kind:id`. + pub subjects: Vec, + /// The entry it belongs to, if it is about one. + pub entry_id: Option, +} + +/// Which lines to list (INVARIANT 3). +#[derive(Clone, Debug, Default)] +pub struct HistoryFilter { + /// Only lines after this seq. + pub after_seq: u64, + /// Only these types: an exact type (`entry.posted`) or a group ending + /// in a dot (`entry.`). Empty: every type. + pub types: Vec, + /// Only lines about this subject (`entry:12`). + pub subject: Option, + /// At most this many. + pub limit: usize, +} + +/// A subject as `kind:id`, parsed. +pub fn parse_subject(text: &str) -> Result<(String, u64), String> { + let bad = || format!("subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2"); + let (kind, id) = text.split_once(':').ok_or_else(bad)?; + let kinds = [ + "entry", "invoice", "bill", "account", "party", "asset", "period", "schedule", + "reconciliation", "payroll_run", "project", + ]; + if !kinds.contains(&kind.trim()) { + return Err(bad()); + } + let id = id.trim().parse::().map_err(|_| bad())?; + Ok((kind.trim().to_string(), id)) +} + +fn num(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_u64) +} + +fn quote(text: &str) -> String { + let t: String = text.split_whitespace().collect::>().join(" "); + if t.chars().count() > QUOTE_MAX { + let mut s: String = t.chars().take(QUOTE_MAX - 1).collect(); + s.push('…'); + s + } else { + t + } +} + +/// The subjects a line is about (INVARIANT 4). +pub fn subjects(line: &JournalLine) -> Vec { + let p = &line.payload; + let mut out: Vec = Vec::new(); + let mut add = |kind: &str, id: Option| { + if let Some(id) = id { + let s = format!("{kind}:{id}"); + if !out.contains(&s) { + out.push(s); + } + } + }; + let prefix = line.line_type.split('.').next().unwrap_or(""); + let own = match prefix { + "entry" => Some("entry"), + "invoice" => Some("invoice"), + "bill" => Some("bill"), + "account" => Some("account"), + "party" => Some("party"), + "asset" => Some("asset"), + "period" => Some("period"), + "schedule" => Some("schedule"), + "reconciliation" => Some("reconciliation"), + "payroll" => Some("payroll_run"), + "project" => Some("project"), + _ => None, + }; + if let Some(kind) = own { + // `entry.posted` carries `entry_id`; `entry.created` carries `id`. + let id = match prefix { + "entry" => num(p, "entry_id").or_else(|| num(p, "id")), + "invoice" => num(p, "invoice_id").or_else(|| num(p, "id")), + "bill" => num(p, "bill_id").or_else(|| num(p, "id")), + "asset" => num(p, "asset_id").or_else(|| num(p, "id")), + "party" => num(p, "party_id").or_else(|| num(p, "id")), + _ => num(p, "id"), + }; + add(kind, id); + } + add("entry", num(p, "entry_id")); + add("invoice", num(p, "invoice_id")); + add("bill", num(p, "bill_id")); + add("account", num(p, "account_id")); + add("asset", num(p, "asset_id")); + add("party", num(p, "party_id")); + if line.line_type == "receipt.attached" || line.line_type == "receipt.linked" { + add("entry", num(p, "entry_id")); + } + out +} + +/// A line's amount, when it has one: a string, or `{amount: ..}`. +fn amount_of(p: &Value) -> Option { + let a = p.get("amount")?; + a.as_str() + .map(str::to_string) + .or_else(|| a.get("amount").and_then(Value::as_str).map(str::to_string)) +} + +/// The sentence for a line (INVARIANT 2). +pub fn describe(line: &JournalLine) -> String { + let p = &line.payload; + let ty = line.line_type.as_str(); + let mut head = ty.replace(['.', '_'], " "); + if let Some(first) = head.get(0..1) { + head = first.to_uppercase() + &head[1..]; + } + let mut parts: Vec = Vec::new(); + // What it is about, by id. + for (k, label) in [ + ("entry_id", "entry"), + ("invoice_id", "invoice"), + ("bill_id", "bill"), + ("account_id", "account"), + ("asset_id", "asset"), + ("party_id", "party"), + ("attachment_id", "attachment"), + ] { + if let Some(n) = num(p, k) { + parts.push(format!("{label} {n}")); + } + } + if parts.is_empty() + && let Some(n) = num(p, "id") + { + parts.push(format!("#{n}")); + } + // What a person typed about it. + for k in ["description", "title", "name", "subject", "code"] { + if let Some(t) = p.get(k).and_then(Value::as_str) + && !t.is_empty() + { + parts.push(quote(t)); + break; + } + } + if let Some(a) = amount_of(p) { + parts.push(a); + } + if let Some(d) = p.get("date").and_then(Value::as_str) { + parts.push(d.to_string()); + } + // Settings: which keys, never their values. + if ty == "settings.set" + && let Some(changes) = p.get("changes").and_then(Value::as_array) + { + let keys: Vec<&str> = changes + .iter() + .filter_map(|c| c.get("key").and_then(Value::as_str)) + .take(8) + .collect(); + parts.push(keys.join(", ")); + } + let text = if parts.is_empty() { + head + } else { + format!("{head}: {}", parts.join(", ")) + }; + ledger_core::parties::redact_full_tins(&text).into_owned() +} + +/// Describe one line. +pub fn describe_line(line: &JournalLine) -> Described { + let subjects = subjects(line); + let entry_id = subjects + .iter() + .find_map(|s| s.strip_prefix("entry:").and_then(|n| n.parse().ok())); + Described { + seq: line.seq, + ts: line.ts.clone(), + line_type: line.line_type.clone(), + summary: describe(line), + subjects, + entry_id, + } +} + +/// The lines `filter` asks for, described, in journal order (INVARIANT 3), +/// and whether more match after the last one returned. +pub fn history(lines: &[JournalLine], filter: &HistoryFilter) -> (Vec, bool) { + let wanted = |l: &JournalLine| { + l.seq > filter.after_seq + && (filter.types.is_empty() + || filter.types.iter().any(|t| { + if t.ends_with('.') { + l.line_type.starts_with(t.as_str()) + } else { + l.line_type == *t + } + })) + && filter + .subject + .as_ref() + .is_none_or(|s| subjects(l).contains(s)) + }; + let mut out = Vec::new(); + let mut more = false; + for l in lines.iter().filter(|l| wanted(l)) { + if out.len() == filter.limit { + more = true; + break; + } + out.push(describe_line(l)); + } + (out, more) +} + +/// The journal in words, read from the books' own journal file. +#[derive(Clone, Debug, PartialEq)] +pub struct History { + /// The lines asked for, in order. + pub lines: Vec, + /// Whether more match after the last. + pub more: bool, + /// The journal's last seq. + pub head_seq: u64, + /// Torn tails set aside on open: (when, bytes). + pub tail_repairs: Vec<(String, u64)>, +} + +impl crate::Store { + /// `filter`'s lines of the journal, described (the only way an agent + /// reads the journal: `books.journal_history`). The file is read and + /// its chain verified, as `Store::verify` does. + pub fn journal_history(&self, filter: &HistoryFilter) -> Result { + let (lines, _format) = crate::journal::read_journal_and_format( + &self.data_dir().join(crate::store::JOURNAL_FILE), + )?; + let (described, more) = history(&lines, filter); + let tail_repairs = self + .tail_repairs()? + .into_iter() + .map(|(when, bytes, _saved_as)| (when, bytes)) + .collect(); + Ok(History { + lines: described, + more, + head_seq: lines.last().map_or(0, |l| l.seq), + tail_repairs, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn line(seq: u64, ty: &str, payload: Value) -> JournalLine { + JournalLine { + seq, + ts: "2026-10-29T00:00:00Z".into(), + line_type: ty.into(), + payload, + prev: String::new(), + } + } + + #[test] + fn every_event_type_has_a_sentence() { + for ty in crate::format::EVENT_TYPES { + let s = describe(&line(1, ty, json!({}))); + assert!(!s.is_empty(), "{ty}"); + } + } + + #[test] + fn a_sentence_names_the_entry_and_what_a_person_typed() { + let l = line( + 5, + "entry.posted", + json!({"entry_id": 12, "description": "Office supplies"}), + ); + assert_eq!(describe(&l), "Entry posted: entry 12, Office supplies"); + assert_eq!(subjects(&l), vec!["entry:12"]); + assert_eq!(describe_line(&l).entry_id, Some(12)); + } + + #[test] + fn settings_show_their_keys_never_their_values() { + let l = line( + 2, + "settings.set", + json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}), + ); + let s = describe(&l); + assert!(s.contains("company.ein") && !s.contains("12-3456789"), "{s}"); + } + + #[test] + fn a_full_tin_in_a_description_is_masked() { + let l = line(3, "entry.created", json!({"id": 1, "description": "TIN 123-45-6789"})); + assert!(!describe(&l).contains("6789"), "{}", describe(&l)); + } + + #[test] + fn history_filters_in_order_and_says_when_more_remain() { + let lines: Vec = (1..=6) + .map(|i| { + line( + i, + if i % 2 == 0 { "entry.posted" } else { "entry.created" }, + json!({"entry_id": i % 3, "id": i % 3}), + ) + }) + .collect(); + let f = HistoryFilter { + after_seq: 1, + types: vec!["entry.posted".into()], + subject: None, + limit: 2, + }; + let (got, more) = history(&lines, &f); + assert_eq!(got.iter().map(|d| d.seq).collect::>(), vec![2, 4]); + assert!(more); + let f = HistoryFilter { + subject: Some("entry:0".into()), + limit: 10, + ..Default::default() + }; + let (got, more) = history(&lines, &f); + assert_eq!(got.iter().map(|d| d.seq).collect::>(), vec![3, 6]); + assert!(!more); + assert!(parse_subject("entry:12").is_ok()); + assert!(parse_subject("nonsense").is_err()); + } +} diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index a6648f3..590a4ae 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -23,6 +23,7 @@ pub mod auth; pub mod backup; pub mod blob; pub mod chain; +pub mod describe; pub mod error; pub mod exports; pub mod form1099; diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 51a2a37..4bfa828 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2648,3 +2648,44 @@ state and discard tools wait for the books' format upgrade). drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems: [...] }` for the report's end date, and take `strict: true` to be refused, naming what is open, instead of warned. + +## Tools — the journal in words (P5) + +- `books.journal_history { after_seq?, types?, subject?, limit? }` → `{ + lines: [{ seq, ts, type, summary, subject?, entry_id? }], more, head_seq, + tail_repairs: [{ at, bytes }] }`. The journal in order, a sentence for + each line (`summary`: what it did, to what, the short names a person + typed; never a setting's value, never a full TIN), the line's own + `subject` (`entry:12`, `invoice:3`, `bill:3`, `account:10`, `party:5`, + `asset:2`) and the entry it belongs to. `types` takes an exact type + (`entry.posted`) or a group ending in a dot (`entry.`); `subject` keeps + the lines about one thing (a line is about the subject its type names and + every one its payload refers to); `limit` is 50 by default, 200 at most; + `more` says to ask again with the last `seq` as `after_seq`. Read-only, + `books:read`. It is how a figure is traced to what produced it and the + only way to read the journal; `tail_repairs` lists torn tails set aside + when the books opened (when, how many bytes). The sentences come from + `ledger-store`'s `describe.rs`. + +## Prompts (P5) + +The server advertises the prompts capability (also in the bridge's cold +`initialize`). `prompts/list` and `prompts/get` need a token that may read. +Each prompt's text is a file in `ledger-mcp/prompts/`, with the company's +name and the arguments filled in; it names only tools that exist (a test +checks). An argument is one token (letters, digits, `-`, `_`, `.`, `:`; at +most 40 characters) of the shape the prompt asks for, so an argument can +never rewrite the text; an unknown prompt, a missing, extra or malformed +argument is refused. + +| Prompt | Arguments | Drives | +|---|---|---| +| `onboard` | none | the company's facts, the chart, the policies, the opening balances, the trial-balance check | +| `reconcile` | `account` (a code), `month` (YYYY-MM) | the statement, `books.record_statement`, `books.mark_cleared`, `books.reconcile` | +| `month_end_close` | `month` (YYYY-MM) | the company's own close steps, reconciliations, drafts, schedules, checks, `books.close_period` | +| `cpa_handoff` | `year` (YYYY) | `books.verify`, the periods, 1099 work, `books.export_cpa_package` | +| `annual_compliance` | `year` (YYYY) | the year's filings, 1099s, assets, open items | +| `audit` | `scope` (`month`, `annual`, `cpa_handoff` or `full`) | `books.verify`, `books.control_check`, the digest, evidence against entries (P8 fills in its own tools) | + +`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46, +D-28, D-17).